Kaynağa Gözat

Merge pull request #294 from kingst/logging-for-swift-oref

[Part 3 of 3] Logging for swift implementation of `makeProfile`
Deniz Cengiz 1 yıl önce
ebeveyn
işleme
44a8653094

+ 9 - 0
DATA_MAINTAINERS.md

@@ -0,0 +1,9 @@
+# Data maintainers
+
+Contacts (GitHub handles):
+- @kingst
+- @marv-out
+- @mikeplante1
+- @dsnallfot
+- @bjornoleh
+- @dnzxy

+ 103 - 0
PRIVACY.md

@@ -0,0 +1,103 @@
+# Privacy Policy for Trio Debug Data Collection
+*A Nightscout Foundation Project*
+
+## Purpose and Scope
+This privacy policy outlines the principles and practices for collecting, using, and protecting debug data in Trio, an open-source insulin dosing algorithm project of the Nightscout Foundation. Our primary goal is to ensure algorithm safety and accuracy while maintaining the highest standards of user privacy.
+
+## Data Collection Principles
+
+### 1. Minimal Collection
+- We collect only the mathematical differences between JavaScript and Swift algorithm implementations
+- No personal identifiers, device information, or timestamps are collected
+- No insulin doses, blood glucose values, or other medical data are stored
+- Data collected is limited strictly to algorithm debugging purposes
+
+### 2. Anonymization
+- All data is anonymized at the source before transmission
+- Device identification uses Apple's vendor ID system, which:
+  - Allows users to reset their device identifier at any time
+  - Provides consistent identification only until user reset
+  - Cannot be used to track across different apps
+- No IP addresses are stored
+- No geographic or temporal information is retained
+- No personal user information is collected
+
+### 3. Transparency
+- Data collection code is [open source](https://github.com/kingst/trio-oref-logs) and available for community review
+- Specific data points being collected are documented in the source code
+- Your information will only be used as in this privacy policy -- any changes to data collection must go through public code review
+- Regular reports on data usage will be published to the community
+
+## Data Usage
+
+### Permitted Uses
+- Identifying mathematical discrepancies between implementations
+- Validating algorithm consistency across platforms
+- Debugging edge cases in calculations
+- Improving algorithm accuracy and safety
+
+### Prohibited Uses
+- No commercial use or sharing data with third parties
+- No attempt to re-identify or correlate data points
+- No use for marketing, analytics, or user behavior analysis
+- No combination with other data sources
+
+### Use in research publications
+- We will maintain aggregate statistics, like invocation rates and average timing differences between Javascript and Swift, for use in research publications
+- We will not use individual records
+
+## Data Protection
+
+### Security Measures
+- Data is encrypted in transit and rest using industry-standard protocols
+- Access to collected data is strictly limited to core algorithm developers
+- Data is stored in a secure, isolated environment
+- Regular security audits are performed
+- We will do everything we can to maintain the security of your data, but complete data security cannot be guaranteed
+
+### Data Retention
+- Debug data is retained only for the duration necessary for verification
+- Maximum retention period of 90 days
+- Automatic data deletion after the retention period
+- Option for immediate deletion upon request
+
+## Community Oversight
+
+### Transparency Reports
+- Monthly reports on:
+  - Volume of data collected
+  - How the data was used
+  - Any findings or improvements made
+  - Confirmation of data deletion
+
+### Community Control
+- User can disable data collection at any time
+- Community voting is required for any changes to this policy
+- Annual review of data collection necessity
+- Public issue tracker for privacy-related concerns
+
+## User Rights
+
+### Control and Consent
+- Explicit opt-in required for data collection
+- Right to opt out at any time
+- Right to reset device identifier through iOS settings
+- Right to request verification of data deletion
+
+### Communication
+- 72-hour response time commitment for privacy concerns
+- Regular updates on privacy-related improvements
+- Clear documentation of all privacy features
+
+## Updates to This Policy
+- Changes require a community discussion period
+- Minimum 90-day notice before any changes
+- All historical versions of this policy are maintained in this repository
+- Change log with justifications maintained
+
+## Contact Information
+- Dedicated privacy contacts listed in DATA_MAINTAINERS.md
+- Public discussion in GitHub issues
+- Optional private communication channel for sensitive concerns
+
+This policy is maintained in the Trio project repository at `/PRIVACY.md` and is governed by the same open-source principles as the rest of the project. As a Nightscout Foundation project, Trio adheres to the Foundation's commitment to transparency, security, and patient privacy in diabetes technology.

+ 28 - 4
Trio.xcodeproj/project.pbxproj

@@ -215,7 +215,6 @@
 		3B5CD29C2D4AEA3C00CE213C /* ProfileGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2952D4AEA3C00CE213C /* ProfileGenerator.swift */; };
 		3B5CD29D2D4AEA3C00CE213C /* Targets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2962D4AEA3C00CE213C /* Targets.swift */; };
 		3B5CD2A12D4AEA5100CE213C /* JavascriptOptional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD29E2D4AEA5100CE213C /* JavascriptOptional.swift */; };
-		3B5CD2A22D4AEA5100CE213C /* JSONCompare.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD29F2D4AEA5100CE213C /* JSONCompare.swift */; };
 		3B5CD2A52D4AEA5D00CE213C /* Date+MinutesFromMidnight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2A32D4AEA5D00CE213C /* Date+MinutesFromMidnight.swift */; };
 		3B5CD2B72D4AEA6600CE213C /* ComputedBGTargets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2A72D4AEA6600CE213C /* ComputedBGTargets.swift */; };
 		3B5CD2B82D4AEA6600CE213C /* ComputedInsulinSensitivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2AD2D4AEA6600CE213C /* ComputedInsulinSensitivities.swift */; };
@@ -227,7 +226,12 @@
 		3B5CD2CE2D4AECD500CE213C /* ProfileBasalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5CD2C12D4AECD500CE213C /* ProfileBasalTests.swift */; };
 		3BCE75B32D4B38AE009E9453 /* InsulinSensitivities+Convert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BCE75B22D4B38A0009E9453 /* InsulinSensitivities+Convert.swift */; };
 		3BCE75B52D4B391F009E9453 /* Decimal+rounding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BCE75B42D4B3917009E9453 /* Decimal+rounding.swift */; };
+		3BEA3AE02D58F79700A67A1D /* OrefFunction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADE2D58F79700A67A1D /* OrefFunction.swift */; };
+		3BEA3AE12D58F79700A67A1D /* AlgorithmComparison.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADB2D58F79700A67A1D /* AlgorithmComparison.swift */; };
+		3BEA3AE22D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADD2D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift */; };
+		3BEA3AE32D58F79700A67A1D /* JSONCompare.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEA3ADC2D58F79700A67A1D /* JSONCompare.swift */; };
 		3BF8D0C12D5175BE001B3F84 /* ProfileJsNativeCompareTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF8D0C02D5175B3001B3F84 /* ProfileJsNativeCompareTests.swift */; };
+		3BF8D14B2D530397001B3F84 /* JSONCompareTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF8D14A2D530397001B3F84 /* JSONCompareTests.swift */; };
 		45252C95D220E796FDB3B022 /* ConfigEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */; };
 		45717281F743594AA9D87191 /* ConfigEditorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 920DDB21E5D0EB813197500D /* ConfigEditorRootView.swift */; };
 		491D6FBD2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491D6FBC2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift */; };
@@ -961,7 +965,6 @@
 		3B5CD2952D4AEA3C00CE213C /* ProfileGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileGenerator.swift; sourceTree = "<group>"; };
 		3B5CD2962D4AEA3C00CE213C /* Targets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Targets.swift; sourceTree = "<group>"; };
 		3B5CD29E2D4AEA5100CE213C /* JavascriptOptional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JavascriptOptional.swift; sourceTree = "<group>"; };
-		3B5CD29F2D4AEA5100CE213C /* JSONCompare.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCompare.swift; sourceTree = "<group>"; };
 		3B5CD2A32D4AEA5D00CE213C /* Date+MinutesFromMidnight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+MinutesFromMidnight.swift"; sourceTree = "<group>"; };
 		3B5CD2A72D4AEA6600CE213C /* ComputedBGTargets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComputedBGTargets.swift; sourceTree = "<group>"; };
 		3B5CD2AD2D4AEA6600CE213C /* ComputedInsulinSensitivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComputedInsulinSensitivities.swift; sourceTree = "<group>"; };
@@ -974,8 +977,13 @@
 		3BCE75B22D4B38A0009E9453 /* InsulinSensitivities+Convert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InsulinSensitivities+Convert.swift"; sourceTree = "<group>"; };
 		3BCE75B42D4B3917009E9453 /* Decimal+rounding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decimal+rounding.swift"; sourceTree = "<group>"; };
 		3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorProvider.swift; sourceTree = "<group>"; };
+		3BEA3ADB2D58F79700A67A1D /* AlgorithmComparison.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlgorithmComparison.swift; sourceTree = "<group>"; };
+		3BEA3ADC2D58F79700A67A1D /* JSONCompare.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCompare.swift; sourceTree = "<group>"; };
+		3BEA3ADD2D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsSwiftOrefComparisonLogger.swift; sourceTree = "<group>"; };
+		3BEA3ADE2D58F79700A67A1D /* OrefFunction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrefFunction.swift; sourceTree = "<group>"; };
 		3BF768BD6264FF7D71D66767 /* NightscoutConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigProvider.swift; sourceTree = "<group>"; };
 		3BF8D0C02D5175B3001B3F84 /* ProfileJsNativeCompareTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileJsNativeCompareTests.swift; sourceTree = "<group>"; };
+		3BF8D14A2D530397001B3F84 /* JSONCompareTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCompareTests.swift; sourceTree = "<group>"; };
 		3F60E97100041040446F44E7 /* PumpConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigStateModel.swift; sourceTree = "<group>"; };
 		3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorDataFlow.swift; sourceTree = "<group>"; };
 		42369F66CF91F30624C0B3A6 /* BasalProfileEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BasalProfileEditorProvider.swift; sourceTree = "<group>"; };
@@ -2346,6 +2354,7 @@
 			isa = PBXGroup;
 			children = (
 				3B5CD2A42D4AEA5D00CE213C /* Extensions */,
+				3BEA3ADF2D58F79700A67A1D /* Logging */,
 				3B5CD2B22D4AEA6600CE213C /* Models */,
 				3B5CD2972D4AEA3C00CE213C /* Profile */,
 				3B5CD2A02D4AEA5100CE213C /* Utils */,
@@ -2372,7 +2381,6 @@
 			isa = PBXGroup;
 			children = (
 				3B5CD29E2D4AEA5100CE213C /* JavascriptOptional.swift */,
-				3B5CD29F2D4AEA5100CE213C /* JSONCompare.swift */,
 			);
 			path = Utils;
 			sourceTree = "<group>";
@@ -2400,6 +2408,7 @@
 		3B5CD2C72D4AECD500CE213C /* OpenAPSSwiftTests */ = {
 			isa = PBXGroup;
 			children = (
+				3BF8D14A2D530397001B3F84 /* JSONCompareTests.swift */,
 				3BF8D0C02D5175B3001B3F84 /* ProfileJsNativeCompareTests.swift */,
 				3B5CD2C12D4AECD500CE213C /* ProfileBasalTests.swift */,
 				3B5CD2C32D4AECD500CE213C /* ProfileCarbsTests.swift */,
@@ -2410,6 +2419,17 @@
 			path = OpenAPSSwiftTests;
 			sourceTree = "<group>";
 		};
+		3BEA3ADF2D58F79700A67A1D /* Logging */ = {
+			isa = PBXGroup;
+			children = (
+				3BEA3ADB2D58F79700A67A1D /* AlgorithmComparison.swift */,
+				3BEA3ADC2D58F79700A67A1D /* JSONCompare.swift */,
+				3BEA3ADD2D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift */,
+				3BEA3ADE2D58F79700A67A1D /* OrefFunction.swift */,
+			);
+			path = Logging;
+			sourceTree = "<group>";
+		};
 		4E8C7B59F8065047ECE20965 /* View */ = {
 			isa = PBXGroup;
 			children = (
@@ -3834,6 +3854,10 @@
 				582DF97B2C8CE209001F516D /* CarbView.swift in Sources */,
 				DD940BAA2CA7585D000830A5 /* GlucoseColorScheme.swift in Sources */,
 				3811DE2225C9D48300A708ED /* MainProvider.swift in Sources */,
+				3BEA3AE02D58F79700A67A1D /* OrefFunction.swift in Sources */,
+				3BEA3AE12D58F79700A67A1D /* AlgorithmComparison.swift in Sources */,
+				3BEA3AE22D58F79700A67A1D /* JsSwiftOrefComparisonLogger.swift in Sources */,
+				3BEA3AE32D58F79700A67A1D /* JSONCompare.swift in Sources */,
 				3811DE0C25C9D32F00A708ED /* BaseProvider.swift in Sources */,
 				CE95BF5A2BA62E4A00DC3DE3 /* PluginSource.swift in Sources */,
 				DD21FCB52C6952AD00AF2C25 /* DecimalPickerSettings.swift in Sources */,
@@ -4109,7 +4133,6 @@
 				38569349270B5DFB0002C50D /* AppGroupSource.swift in Sources */,
 				F5CA3DB1F9DC8B05792BBFAA /* CGMDataFlow.swift in Sources */,
 				3B5CD2A12D4AEA5100CE213C /* JavascriptOptional.swift in Sources */,
-				3B5CD2A22D4AEA5100CE213C /* JSONCompare.swift in Sources */,
 				BDF34F952C10D27300D51995 /* DeterminationData.swift in Sources */,
 				BA00D96F7B2FF169A06FB530 /* CGMStateModel.swift in Sources */,
 				BD7DA9A52AE06DFC00601B20 /* BolusCalculatorConfigDataFlow.swift in Sources */,
@@ -4182,6 +4205,7 @@
 				3B5CD2CD2D4AECD500CE213C /* ProfileIsfTests.swift in Sources */,
 				3B5CD2CE2D4AECD500CE213C /* ProfileBasalTests.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
+				3BF8D14B2D530397001B3F84 /* JSONCompareTests.swift in Sources */,
 				3BF8D0C12D5175BE001B3F84 /* ProfileJsNativeCompareTests.swift in Sources */,
 				CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,

+ 36 - 32
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -693,28 +693,33 @@ final class OpenAPS {
         model: JSON,
         autotune: JSON,
         trioSettings: JSON
-    ) async throws -> RawJSON {
-        try await withCheckedThrowingContinuation { continuation in
-            jsWorker.inCommonContext { worker in
-                worker.evaluateBatch(scripts: [
-                    Script(name: Prepare.log),
-                    Script(name: Bundle.profile),
-                    Script(name: Prepare.profile)
-                ])
-                let result = worker.call(function: Function.generate, with: [
-                    pumpSettings,
-                    bgTargets,
-                    isf,
-                    basalProfile,
-                    preferences,
-                    carbRatio,
-                    tempTargets,
-                    model,
-                    autotune,
-                    trioSettings
-                ])
-                continuation.resume(returning: result)
+    ) async -> OrefFunctionResult {
+        do {
+            let result = try await withCheckedThrowingContinuation { continuation in
+                jsWorker.inCommonContext { worker in
+                    worker.evaluateBatch(scripts: [
+                        Script(name: Prepare.log),
+                        Script(name: Bundle.profile),
+                        Script(name: Prepare.profile)
+                    ])
+                    let result = worker.call(function: Function.generate, with: [
+                        pumpSettings,
+                        bgTargets,
+                        isf,
+                        basalProfile,
+                        preferences,
+                        carbRatio,
+                        tempTargets,
+                        model,
+                        autotune,
+                        trioSettings
+                    ])
+                    continuation.resume(returning: result)
+                }
             }
+            return .success(result)
+        } catch {
+            return .failure(error)
         }
     }
 
@@ -731,9 +736,8 @@ final class OpenAPS {
         trioSettings: JSON,
         useSwiftOref: Bool
     ) async throws -> RawJSON {
-        // TODO: Compare exceptions as well
         let startJavascriptAt = Date()
-        let jsJson = try await makeProfileJavascript(
+        let jsResult = await makeProfileJavascript(
             preferences: preferences,
             pumpSettings: pumpSettings,
             bgTargets: bgTargets,
@@ -750,11 +754,11 @@ final class OpenAPS {
         // Important: we want to make sure that this flag ensures that none
         // of the native code runs
         guard useSwiftOref else {
-            return jsJson
+            return try jsResult.returnOrThrow()
         }
 
-        let startNativeAt = Date()
-        let nativeJson = OpenAPSSwift.makeProfile(
+        let startSwiftAt = Date()
+        let swiftResult = OpenAPSSwift.makeProfile(
             preferences: preferences,
             pumpSettings: pumpSettings,
             bgTargets: bgTargets,
@@ -765,17 +769,17 @@ final class OpenAPS {
             model: model,
             trioSettings: trioSettings
         )
-        let nativeDuration = Date().timeIntervalSince(startNativeAt)
+        let swiftDuration = Date().timeIntervalSince(startSwiftAt)
 
         JSONCompare.logDifferences(
             function: .makeProfile,
-            native: nativeJson,
-            nativeRuntime: nativeDuration,
-            javascript: jsJson,
-            javascriptRuntime: javascriptDuration
+            swift: swiftResult,
+            swiftDuration: swiftDuration,
+            javascript: jsResult,
+            javascriptDuration: javascriptDuration
         )
 
-        return jsJson
+        return try jsResult.returnOrThrow()
     }
 
     private func loadJSON(name: String) -> String {

+ 108 - 0
Trio/Sources/APS/OpenAPSSwift/Logging/AlgorithmComparison.swift

@@ -0,0 +1,108 @@
+import Foundation
+
+/// After the port from Javascript to Swift is complete, we should remove the logging module:
+/// https://github.com/nightscout/Trio-dev/issues/293
+
+/// Represents an exception that occurred during algorithm execution
+struct AlgorithmException: Codable {
+    let message: String
+    let stackTrace: String?
+    let errorType: String?
+
+    init(message: String, stackTrace: String? = nil, errorType: String? = nil) {
+        self.message = message
+        self.stackTrace = stackTrace
+        self.errorType = errorType
+    }
+
+    init(error: Error) {
+        // Get the error message
+        if let localizedError = error as? LocalizedError {
+            message = localizedError.errorDescription ?? error.localizedDescription
+        } else {
+            message = error.localizedDescription
+        }
+
+        // Get error type
+        errorType = String(describing: type(of: error))
+
+        // Get stack trace if available
+        if let nsError = error as NSError? {
+            var traceComponents: [String] = []
+
+            // Add domain and code
+            traceComponents.append("Domain: \(nsError.domain)")
+            traceComponents.append("Code: \(nsError.code)")
+
+            // Add userInfo details
+            if !nsError.userInfo.isEmpty {
+                traceComponents.append("UserInfo: \(nsError.userInfo)")
+            }
+
+            // Add call stack
+            let callStackSymbols = Thread.callStackSymbols as [String]
+            if !callStackSymbols.isEmpty {
+                traceComponents.append("Call Stack:")
+                traceComponents.append(contentsOf: callStackSymbols)
+            }
+
+            stackTrace = traceComponents.isEmpty ? nil : traceComponents.joined(separator: "\n")
+        } else {
+            stackTrace = nil
+        }
+    }
+}
+
+/// Represents the type of comparison result
+enum ComparisonResultType: String, Codable {
+    case matching // Both implementations succeed with matching results
+    case valueDifference // Both implementations succeed but values differ
+    case matchingExceptions // Both implementations threw exceptions
+    case jsOnlyException // Only JS threw an exception
+    case swiftOnlyException // Only Swift threw an exception
+    case comparisonError // The comparison algorithm itself failed
+}
+
+/// Represents a complete comparison between JS and Swift implementations
+struct AlgorithmComparison: Codable {
+    let id: UUID
+    let createdAt: Date
+    let function: OrefFunction
+    let resultType: ComparisonResultType
+
+    // Performance metrics (optional as they may not be available in error cases)
+    let jsDuration: TimeInterval?
+    let swiftDuration: TimeInterval?
+
+    // Value differences (present when resultType is .valueDifference)
+    let differences: [String: ValueDifference]?
+
+    // Exception information (present for various error cases)
+    let jsException: AlgorithmException?
+    let swiftException: AlgorithmException?
+    let comparisonError: AlgorithmException?
+
+    init(
+        function: OrefFunction,
+        resultType: ComparisonResultType,
+        jsDuration: TimeInterval? = nil,
+        swiftDuration: TimeInterval? = nil,
+        differences: [String: ValueDifference]? = nil,
+        jsException: AlgorithmException? = nil,
+        swiftException: AlgorithmException? = nil,
+        comparisonError: AlgorithmException? = nil,
+        id: UUID = UUID(),
+        createdAt: Date = Date()
+    ) {
+        self.id = id
+        self.createdAt = createdAt
+        self.function = function
+        self.resultType = resultType
+        self.jsDuration = jsDuration
+        self.swiftDuration = swiftDuration
+        self.differences = differences
+        self.jsException = jsException
+        self.swiftException = swiftException
+        self.comparisonError = comparisonError
+    }
+}

+ 254 - 0
Trio/Sources/APS/OpenAPSSwift/Logging/JSONCompare.swift

@@ -0,0 +1,254 @@
+import Foundation
+
+/// After the port from Javascript to Swift is complete, we should remove the logging module:
+/// https://github.com/nightscout/Trio-dev/issues/293
+
+enum JSONValue: Codable, Equatable {
+    case string(String)
+    case number(Double)
+    case boolean(Bool)
+    case array([JSONValue])
+    case object([String: JSONValue])
+    case null
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.singleValueContainer()
+
+        if container.decodeNil() {
+            self = .null
+            return
+        }
+
+        if let string = try? container.decode(String.self) {
+            self = .string(string)
+        } else if let number = try? container.decode(Double.self) {
+            self = .number(number)
+        } else if let boolean = try? container.decode(Bool.self) {
+            self = .boolean(boolean)
+        } else if let array = try? container.decode([JSONValue].self) {
+            self = .array(array)
+        } else if let object = try? container.decode([String: JSONValue].self) {
+            self = .object(object)
+        } else {
+            throw DecodingError.typeMismatch(JSONValue.self, DecodingError.Context(
+                codingPath: decoder.codingPath,
+                debugDescription: "Invalid JSON value"
+            ))
+        }
+    }
+
+    func encode(to encoder: Encoder) throws {
+        var container = encoder.singleValueContainer()
+        switch self {
+        case let .string(value): try container.encode(value)
+        case let .number(value): try container.encode(value)
+        case let .boolean(value): try container.encode(value)
+        case let .array(value): try container.encode(value)
+        case let .object(value): try container.encode(value)
+        case .null: try container.encodeNil()
+        }
+    }
+
+    static func == (lhs: JSONValue, rhs: JSONValue) -> Bool {
+        switch (lhs, rhs) {
+        case (.null, .null):
+            return true
+        case let (.string(lhs), .string(rhs)):
+            return lhs == rhs
+        case let (.number(lhs), .number(rhs)):
+            return lhs == rhs
+        case let (.boolean(lhs), .boolean(rhs)):
+            return lhs == rhs
+        case let (.array(lhs), .array(rhs)):
+            return lhs == rhs
+        case let (.object(lhs), .object(rhs)):
+            return lhs == rhs
+        default:
+            return false
+        }
+    }
+}
+
+struct ValueDifference: Codable {
+    let js: JSONValue
+    let swift: JSONValue
+    let jsKeyMissing: Bool
+    let nativeKeyMissing: Bool
+}
+
+enum JSONCompare {
+    static let log = try? JsSwiftOrefComparisonLogger()
+    static func logDifferences(
+        function: OrefFunction,
+        swift: OrefFunctionResult,
+        swiftDuration: TimeInterval,
+        javascript: OrefFunctionResult,
+        javascriptDuration: TimeInterval
+    ) {
+        let comparison = createComparison(
+            function: function,
+            swift: swift,
+            swiftDuration: swiftDuration,
+            javascript: javascript,
+            javascriptDuration: javascriptDuration
+        )
+
+        Task {
+            do {
+                try await log?.logComparison(comparison: comparison)
+                debug(.openAPS, "\(function) -> n: \(swiftDuration)s, js: \(javascriptDuration)s")
+                prettyPrint(comparison.differences ?? [:])
+            } catch {
+                warning(.openAPS, "logComparison exception: \(error)", error: error)
+            }
+        }
+    }
+
+    static func createComparison(
+        function: OrefFunction,
+        swift: OrefFunctionResult,
+        swiftDuration: TimeInterval,
+        javascript: OrefFunctionResult,
+        javascriptDuration: TimeInterval
+    ) -> AlgorithmComparison {
+        switch (swift, javascript) {
+        case let (.success(swiftJson), .success(javascriptJson)):
+            do {
+                let differences = try differences(function: function, swift: swiftJson, javascript: javascriptJson)
+                let resultType: ComparisonResultType = differences.isEmpty ? .matching : .valueDifference
+                return AlgorithmComparison(
+                    function: function,
+                    resultType: resultType,
+                    jsDuration: javascriptDuration,
+                    swiftDuration: swiftDuration,
+                    differences: differences.isEmpty ? nil : differences
+                )
+            } catch {
+                return AlgorithmComparison(
+                    function: function,
+                    resultType: .comparisonError,
+                    jsDuration: javascriptDuration,
+                    swiftDuration: swiftDuration,
+                    comparisonError: AlgorithmException(error: error)
+                )
+            }
+
+        case let (.failure(swiftError), .failure(jsError)):
+            return AlgorithmComparison(
+                function: function,
+                resultType: .matchingExceptions,
+                jsException: AlgorithmException(error: jsError),
+                swiftException: AlgorithmException(error: swiftError)
+            )
+
+        case let (.failure(swiftError), .success):
+            return AlgorithmComparison(
+                function: function,
+                resultType: .swiftOnlyException,
+                jsDuration: javascriptDuration,
+                swiftException: AlgorithmException(error: swiftError)
+            )
+
+        case let (.success, .failure(jsError)):
+            return AlgorithmComparison(
+                function: function,
+                resultType: .jsOnlyException,
+                swiftDuration: swiftDuration,
+                jsException: AlgorithmException(error: jsError)
+            )
+        }
+    }
+
+    static func prettyPrint(_ differences: [String: ValueDifference]) {
+        let encoder = JSONEncoder()
+        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+
+        if let data = try? encoder.encode(differences),
+           let prettyString = String(data: data, encoding: .utf8)
+        {
+            debug(.openAPS, prettyString)
+        }
+    }
+
+    static func differences(
+        function: OrefFunction,
+        swift: String,
+        javascript: String
+    ) throws -> [String: ValueDifference] {
+        guard let jsData = javascript.data(using: .utf8),
+              let swiftData = swift.data(using: .utf8),
+              let jsDict = try JSONSerialization.jsonObject(with: jsData) as? [String: Any],
+              let swiftDict = try JSONSerialization.jsonObject(with: swiftData) as? [String: Any]
+        else {
+            throw NSError(domain: "JSONBridge", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON format"])
+        }
+
+        var differences: [String: ValueDifference] = [:]
+
+        // Check all keys present in either dictionary
+        Set(jsDict.keys).union(swiftDict.keys).forEach { key in
+            let jsValue = jsDict[key].map(convertToJSONValue) ?? .null
+            let swiftValue = swiftDict[key].map(convertToJSONValue) ?? .null
+
+            if !valuesAreEqual(jsValue, swiftValue) {
+                differences[key] = ValueDifference(
+                    js: jsValue,
+                    swift: swiftValue,
+                    jsKeyMissing: !jsDict.keys.contains(key),
+                    nativeKeyMissing: !swiftDict.keys.contains(key)
+                )
+            }
+        }
+
+        let keysToIgnore = function.keysToIgnore()
+        return differences.filter { !keysToIgnore.contains($0.key) }
+    }
+
+    private static func convertToJSONValue(_ value: Any) -> JSONValue {
+        switch value {
+        case let string as String:
+            return .string(string)
+        case let number as NSNumber:
+            // NSNumber can represent both booleans and numbers
+            // Check if it's a boolean using the objCType
+            let objCType = String(cString: number.objCType)
+            if objCType == "c" || objCType == "B" { // These represent BOOLs in ObjC
+                return .boolean(number.boolValue)
+            } else {
+                return .number(number.doubleValue)
+            }
+        case let bool as Bool:
+            return .boolean(bool)
+        case let array as [Any]:
+            return .array(array.map(convertToJSONValue))
+        case let dict as [String: Any]:
+            return .object(dict.mapValues(convertToJSONValue))
+        case is NSNull:
+            return .null
+        default:
+            return .null
+        }
+    }
+
+    private static func valuesAreEqual(_ value1: JSONValue, _ value2: JSONValue) -> Bool {
+        switch (value1, value2) {
+        case (.null, .null):
+            return true
+        case let (.string(s1), .string(s2)):
+            return s1 == s2
+        case let (.number(n1), .number(n2)):
+            return n1 == n2
+        case let (.boolean(b1), .boolean(b2)):
+            return b1 == b2
+        case let (.array(a1), .array(a2)):
+            return a1.count == a2.count && zip(a1, a2).allSatisfy(valuesAreEqual)
+        case let (.object(o1), .object(o2)):
+            return o1.keys == o2.keys && o1.keys.allSatisfy { key in
+                guard let v1 = o1[key], let v2 = o2[key] else { return false }
+                return valuesAreEqual(v1, v2)
+            }
+        default:
+            return false
+        }
+    }
+}

+ 198 - 0
Trio/Sources/APS/OpenAPSSwift/Logging/JsSwiftOrefComparisonLogger.swift

@@ -0,0 +1,198 @@
+import Foundation
+import UIKit
+
+/// At a high level, this module stores arrays of `AlgorithmComparison` objects in a
+/// Google Cloud Storage (GCS) bucket. We use these arrays to confirm that the swift and js
+/// implementations of oref produce the same results.
+///
+/// The basic flow is that the GCS bucket is private, so there is a server that we use to
+/// get a signed URL to PUT this data in the bucket.
+///
+/// To analyze this data, we have some scripts that load this data into a sqlite3 database
+/// where we run some basic statistics.
+///
+/// To keep the overhead of this library small we batch results and store them in a local
+/// file system file, and we do all operations async by having the caller log new results
+/// using a `Task`, and since this is an Actor it runs in a background thread.
+///
+/// Note: This Actor is temporary -- once the port is complete we will remove it
+/// https://github.com/nightscout/Trio-dev/issues/293
+
+actor JsSwiftOrefComparisonLogger {
+    // MARK: - API Models for getting signed URLs
+
+    struct SignedURLRequest: Codable {
+        let project: String
+        let deviceId: String
+        let appVersion: String
+        let function: OrefFunction
+        let createdAt: Date
+    }
+
+    struct SignedURLResponse: Codable {
+        let url: String
+        let expiresAt: Double
+    }
+
+    // MARK: - Exceptions from the logger
+
+    enum LoggerError: Error {
+        case fileOperationFailed
+        case encodingFailed
+        case decodingFailed
+        case timezoneError
+
+        case invalidSignedUrlResponse
+        case signedUrlGenerationFailed
+        case signedUrlNetworkError(statusCode: Int)
+
+        case uploadNetworkError(statusCode: Int)
+    }
+
+    // MARK: - Logger implementation
+
+    private let minBatchSize = 8
+    private let maxStoredEntries = 256
+    private let fileManager = FileManager.default
+    private let encoder = JSONEncoder()
+    private let decoder = JSONDecoder()
+
+    // server settings for getting a signed Google Cloud Storage URL
+    // that we can PUT to
+    private let baseUrlString = "https://trio-oref-logs.uc.r.appspot.com"
+    private let project = "trio-oref-validation"
+
+    private let storageUrl: URL
+
+    init() throws {
+        encoder.dateEncodingStrategy = .secondsSince1970
+        decoder.dateDecodingStrategy = .secondsSince1970
+
+        guard let documentsPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
+            throw LoggerError.fileOperationFailed
+        }
+
+        self.storageUrl = documentsPath.appendingPathComponent("swift_js_oref_compare.json")
+
+        if !fileManager.fileExists(atPath: storageUrl.path) {
+            try "[]".write(to: storageUrl, atomically: true, encoding: .utf8)
+        }
+    }
+
+    private func readComparisons() throws -> [AlgorithmComparison] {
+        let data = try Data(contentsOf: storageUrl)
+        return try decoder.decode([AlgorithmComparison].self, from: data)
+    }
+
+    private func writeComparisons(_ comparisons: [AlgorithmComparison]) throws {
+        let data = try encoder.encode(comparisons)
+        try data.write(to: storageUrl, options: .atomicWrite)
+    }
+
+    func logComparison(comparison: AlgorithmComparison) async throws {
+        var comparisons = try readComparisons()
+        comparisons.append(comparison)
+        if comparisons.count > maxStoredEntries {
+            comparisons.removeFirst(comparisons.count - maxStoredEntries)
+        }
+
+        try writeComparisons(comparisons)
+
+        if comparisons.count >= minBatchSize {
+            try await uploadCurrentBatch()
+        }
+    }
+
+    // We use the vendor ID to identify devices because it is something
+    // that people can reset if they want to, thus is Apple's recommended
+    // privacy-friendly way to group results by a device.
+    private func getSignedURL(for function: OrefFunction, createdAt: Date) async throws -> URL {
+        let request = await SignedURLRequest(
+            project: project,
+            deviceId: UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString,
+            appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown",
+            function: function,
+            createdAt: createdAt
+        )
+
+        guard let baseURL = URL(string: baseUrlString) else {
+            throw LoggerError.signedUrlGenerationFailed
+        }
+
+        let signedURLEndpoint = baseURL.appendingPathComponent("v1/signed-url")
+        var urlRequest = URLRequest(url: signedURLEndpoint)
+        urlRequest.httpMethod = "POST"
+        urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
+        urlRequest.httpBody = try encoder.encode(request)
+
+        let (data, response) = try await URLSession.shared.data(for: urlRequest)
+
+        guard let httpResponse = response as? HTTPURLResponse,
+              (200 ... 299).contains(httpResponse.statusCode)
+        else {
+            throw LoggerError.signedUrlNetworkError(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1)
+        }
+
+        let signedURLResponse = try decoder.decode(SignedURLResponse.self, from: data)
+        guard let uploadURL = URL(string: signedURLResponse.url) else {
+            throw LoggerError.invalidSignedUrlResponse
+        }
+
+        return uploadURL
+    }
+
+    private func uploadCurrentBatch() async throws {
+        let comparisons = try readComparisons()
+        guard comparisons.count >= minBatchSize else { return }
+
+        guard let utcTimeZone = TimeZone(identifier: "UTC") else {
+            throw LoggerError.timezoneError
+        }
+        var calendar = Calendar(identifier: .gregorian)
+        calendar.timeZone = utcTimeZone
+
+        // First, group by UTC date
+        let dateGroupedComparisons = Dictionary(grouping: comparisons) { comparison in
+            calendar.startOfDay(for: comparison.createdAt)
+        }
+
+        // Then for each date, group by function
+        for (date, dateComparisons) in dateGroupedComparisons {
+            let functionGroupedComparisons = Dictionary(grouping: dateComparisons) { $0.function }
+
+            for (function, functionComparisons) in functionGroupedComparisons {
+                let comparisonsToUpload = Array(functionComparisons.prefix(min(functionComparisons.count, maxStoredEntries)))
+                let uploadedIds = Set(comparisonsToUpload.map(\.id))
+
+                // Get signed URL for this date and function combination
+                let url = try await getSignedURL(for: function, createdAt: date)
+                try await uploadBatch(comparisonsToUpload, to: url)
+
+                // Important: Even though we're using Actors, they give up
+                // the lock when you call await which could change our set
+                // of comparisons and create an atomicity violation. Thus
+                // we need to re-read the comparisons from disk and only remove
+                // the ones we actually uploaded.
+                var updatedComparisons = try readComparisons()
+                updatedComparisons.removeAll(where: { uploadedIds.contains($0.id) })
+                try writeComparisons(updatedComparisons)
+            }
+        }
+    }
+
+    private func uploadBatch(_ comparisons: [AlgorithmComparison], to url: URL) async throws {
+        let data = try encoder.encode(comparisons)
+
+        var request = URLRequest(url: url)
+        request.httpMethod = "PUT"
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+        let (_, response) = try await URLSession.shared.upload(for: request, from: data)
+
+        guard let httpResponse = response as? HTTPURLResponse,
+              (200 ... 299).contains(httpResponse.statusCode)
+        else {
+            throw LoggerError.uploadNetworkError(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1)
+        }
+    }
+}

+ 28 - 0
Trio/Sources/APS/OpenAPSSwift/Logging/OrefFunction.swift

@@ -0,0 +1,28 @@
+/// After the port from Javascript to Swift is complete, we should remove the logging module:
+/// https://github.com/nightscout/Trio-dev/issues/293
+
+enum OrefFunctionResult {
+    case success(RawJSON)
+    case failure(Error)
+
+    func returnOrThrow() throws -> RawJSON {
+        switch self {
+        case let .success(json): return json
+        case let .failure(error): throw error
+        }
+    }
+}
+
+enum OrefFunction: String, Codable {
+    case makeProfile
+
+    // since we're removing some keys from our Profile that exist in Javascript
+    // we need to let the difference function know which keys to ignore when
+    // calculating differences
+    func keysToIgnore() -> Set<String> {
+        switch self {
+        case .makeProfile:
+            return Set(["calc_glucose_noise", "enableEnliteBgproxy", "exercise_mode", "offline_hotspot"])
+        }
+    }
+}

+ 3 - 4
Trio/Sources/APS/OpenAPSSwift/OpenAPSSwift.swift

@@ -11,7 +11,7 @@ struct OpenAPSSwift {
         tempTargets: JSON,
         model: JSON,
         trioSettings: JSON
-    ) -> RawJSON {
+    ) -> OrefFunctionResult {
         do {
             let preferences = try JSONBridge.preferences(from: preferences)
             let pumpSettings = try JSONBridge.pumpSettings(from: pumpSettings)
@@ -35,10 +35,9 @@ struct OpenAPSSwift {
                 trioSettings: trioSettings
             )
 
-            return try JSONBridge.to(profile)
+            return try .success(JSONBridge.to(profile))
         } catch {
-            warning(.openAPS, "OpenAPSSwift exception \(error)")
-            return .null
+            return .failure(error)
         }
     }
 }

+ 1 - 1
Trio/Sources/APS/OpenAPSSwift/Profile/ProfileGenerator.swift

@@ -110,7 +110,7 @@ enum ProfileGenerator {
     }
 
     /// Direct port of the OpenAPS profile generate function
-    static func generate(
+    private static func generate(
         pumpSettings: PumpSettings,
         bgTargets: BGTargets,
         basalProfile: [BasalProfileEntry],

+ 0 - 170
Trio/Sources/APS/OpenAPSSwift/Utils/JSONCompare.swift

@@ -1,170 +0,0 @@
-import Foundation
-
-enum JSONValue: Codable {
-    case string(String)
-    case number(Double)
-    case boolean(Bool)
-    case array([JSONValue])
-    case object([String: JSONValue])
-    case null
-
-    init(from decoder: Decoder) throws {
-        let container = try decoder.singleValueContainer()
-
-        if container.decodeNil() {
-            self = .null
-            return
-        }
-
-        if let string = try? container.decode(String.self) {
-            self = .string(string)
-        } else if let number = try? container.decode(Double.self) {
-            self = .number(number)
-        } else if let boolean = try? container.decode(Bool.self) {
-            self = .boolean(boolean)
-        } else if let array = try? container.decode([JSONValue].self) {
-            self = .array(array)
-        } else if let object = try? container.decode([String: JSONValue].self) {
-            self = .object(object)
-        } else {
-            throw DecodingError.typeMismatch(JSONValue.self, DecodingError.Context(
-                codingPath: decoder.codingPath,
-                debugDescription: "Invalid JSON value"
-            ))
-        }
-    }
-
-    func encode(to encoder: Encoder) throws {
-        var container = encoder.singleValueContainer()
-        switch self {
-        case let .string(value): try container.encode(value)
-        case let .number(value): try container.encode(value)
-        case let .boolean(value): try container.encode(value)
-        case let .array(value): try container.encode(value)
-        case let .object(value): try container.encode(value)
-        case .null: try container.encodeNil()
-        }
-    }
-}
-
-struct ValueDifference: Codable {
-    let js: JSONValue
-    let native: JSONValue
-    let jsKeyMissing: Bool
-    let nativeKeyMissing: Bool
-}
-
-enum JSONCompare {
-    enum Function {
-        case makeProfile
-
-        // since we're removing some keys from our Profile that exist in Javascript
-        // we need to let the difference function know which keys to ignore when
-        // calculating differences
-        func keysToIgnore() -> Set<String> {
-            switch self {
-            case .makeProfile:
-                return Set(["calc_glucose_noise", "enableEnliteBgproxy", "exercise_mode", "offline_hotspot"])
-            }
-        }
-    }
-
-    static func logDifferences(
-        function: Function,
-        native: String,
-        nativeRuntime: TimeInterval,
-        javascript: String,
-        javascriptRuntime: TimeInterval
-    ) {
-        guard let differences = try? differences(function: function, native: native, javascript: javascript) else {
-            warning(.openAPS, "Exception calculating differences")
-            return
-        }
-
-        // TODO: For now we'll just print this out to the console but we'll add proper logging next
-        debug(.openAPS, "\(function) -> n: \(nativeRuntime)s, js: \(javascriptRuntime)s")
-        prettyPrint(differences)
-    }
-
-    static func prettyPrint(_ differences: [String: ValueDifference]) {
-        let encoder = JSONEncoder()
-        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
-
-        if let data = try? encoder.encode(differences),
-           let prettyString = String(data: data, encoding: .utf8)
-        {
-            debug(.openAPS, prettyString)
-        }
-    }
-
-    static func differences(function: Function, native: String, javascript: String) throws -> [String: ValueDifference] {
-        guard let jsData = javascript.data(using: .utf8),
-              let nativeData = native.data(using: .utf8),
-              let jsDict = try JSONSerialization.jsonObject(with: jsData) as? [String: Any],
-              let nativeDict = try JSONSerialization.jsonObject(with: nativeData) as? [String: Any]
-        else {
-            throw NSError(domain: "JSONBridge", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON format"])
-        }
-
-        var differences: [String: ValueDifference] = [:]
-
-        // Check all keys present in either dictionary
-        Set(jsDict.keys).union(nativeDict.keys).forEach { key in
-            let jsValue = jsDict[key].map(convertToJSONValue) ?? .null
-            let nativeValue = nativeDict[key].map(convertToJSONValue) ?? .null
-
-            if !valuesAreEqual(jsValue, nativeValue) {
-                differences[key] = ValueDifference(
-                    js: jsValue,
-                    native: nativeValue,
-                    jsKeyMissing: !jsDict.keys.contains(key),
-                    nativeKeyMissing: !nativeDict.keys.contains(key)
-                )
-            }
-        }
-
-        let keysToIgnore = function.keysToIgnore()
-        return differences.filter { !keysToIgnore.contains($0.key) }
-    }
-
-    private static func convertToJSONValue(_ value: Any) -> JSONValue {
-        switch value {
-        case let string as String:
-            return .string(string)
-        case let number as NSNumber:
-            return .number(number.doubleValue)
-        case let bool as Bool:
-            return .boolean(bool)
-        case let array as [Any]:
-            return .array(array.map(convertToJSONValue))
-        case let dict as [String: Any]:
-            return .object(dict.mapValues(convertToJSONValue))
-        case is NSNull:
-            return .null
-        default:
-            return .null
-        }
-    }
-
-    private static func valuesAreEqual(_ value1: JSONValue, _ value2: JSONValue) -> Bool {
-        switch (value1, value2) {
-        case (.null, .null):
-            return true
-        case let (.string(s1), .string(s2)):
-            return s1 == s2
-        case let (.number(n1), .number(n2)):
-            return n1 == n2
-        case let (.boolean(b1), .boolean(b2)):
-            return b1 == b2
-        case let (.array(a1), .array(a2)):
-            return a1.count == a2.count && zip(a1, a2).allSatisfy(valuesAreEqual)
-        case let (.object(o1), .object(o2)):
-            return o1.keys == o2.keys && o1.keys.allSatisfy { key in
-                guard let v1 = o1[key], let v2 = o2[key] else { return false }
-                return valuesAreEqual(v1, v2)
-            }
-        default:
-            return false
-        }
-    }
-}

+ 25 - 3
Trio/Sources/Modules/AlgorithmAdvancedSettings/View/AlgorithmAdvancedSettingsRootView.swift

@@ -352,15 +352,22 @@ extension AlgorithmAdvancedSettings {
                     units: state.units,
                     type: .boolean,
                     label: NSLocalizedString("Use Swift Oref", comment: "Use Swift Oref"),
-                    miniHint: "Use new Swift OpenAPS Oref algorithm",
+                    miniHint: "Enables new algorithm version and helps verify it works correctly by allowing anonymous data uploads.",
                     verboseHint:
                     VStack(alignment: .leading, spacing: 10) {
                         Text("Default: OFF").bold()
                         Text(
-                            "This experimental option uses a new version of the OpenAPS Oref algorithm written directly in Swift programming language, replacing the current JavaScript version."
+                            "We're building a faster and more maintainable Swift version of the Oref algorithm to improve Trio for everyone."
                         )
+                        Text("When enabled:")
+                        BulletPoint("App logs anonymous calculation data to verify accuracy")
+                        BulletPoint("Data is stored securely on UC Davis servers in Google Cloud")
+                        BulletPoint("Only development team has access")
+                        BulletPoint("All data will be deleted after verification")
+                        BulletPoint("No personal information is collected")
+
                         Text(
-                            "When enabled, we'll securely log anonymous technical data comparing the Swift and existing JavaScript algorithms to improve calculation accuracy."
+                            "You can disable this feature anytime."
                         )
                     }
                 )
@@ -386,3 +393,18 @@ extension AlgorithmAdvancedSettings {
         }
     }
 }
+
+struct BulletPoint: View {
+    let text: String
+
+    init(_ text: String) {
+        self.text = text
+    }
+
+    var body: some View {
+        HStack(alignment: .top) {
+            Text("•")
+            Text(text)
+        }
+    }
+}

+ 206 - 0
TrioTests/OpenAPSSwiftTests/JSONCompareTests.swift

@@ -0,0 +1,206 @@
+import Foundation
+import Testing
+@testable import Trio
+
+@Suite("JSON Compare") struct JSONCompareTests {
+    // Test fixtures
+    let matchingJSON = """
+    {
+        "value": 42,
+        "text": "hello",
+        "flag": true,
+        "nested": {
+            "array": [1, 2, 3],
+            "object": {"key": "value"}
+        }
+    }
+    """
+
+    @Test("should find no differences between identical JSONs") func matchingJSONs() async throws {
+        let differences = try JSONCompare.differences(
+            function: .makeProfile,
+            swift: matchingJSON,
+            javascript: matchingJSON
+        )
+        #expect(differences.isEmpty)
+    }
+
+    @Test("should detect scalar value differences") func scalarDifferences() async throws {
+        let jsJSON = """
+        {
+            "number": 42,
+            "text": "hello",
+            "boolean": true
+        }
+        """
+
+        let swiftJSON = """
+        {
+            "number": 43,
+            "text": "world",
+            "boolean": false
+        }
+        """
+
+        let differences = try JSONCompare.differences(
+            function: .makeProfile,
+            swift: swiftJSON,
+            javascript: jsJSON
+        )
+
+        #expect(differences.count == 3)
+        #expect(differences["number"]?.js == .number(42))
+        #expect(differences["number"]?.swift == .number(43))
+        #expect(differences["text"]?.js == .string("hello"))
+        #expect(differences["text"]?.swift == .string("world"))
+        #expect(differences["boolean"]?.js == .boolean(true))
+        #expect(differences["boolean"]?.swift == .boolean(false))
+    }
+
+    @Test("should detect missing keys") func missingKeys() async throws {
+        let jsJSON = """
+        {
+            "common": 42,
+            "jsOnly": "hello"
+        }
+        """
+
+        let swiftJSON = """
+        {
+            "common": 42,
+            "swiftOnly": "world"
+        }
+        """
+
+        let differences = try JSONCompare.differences(
+            function: .makeProfile,
+            swift: swiftJSON,
+            javascript: jsJSON
+        )
+
+        #expect(differences.count == 2)
+        #expect(differences["jsOnly"]?.nativeKeyMissing == true)
+        #expect(differences["jsOnly"]?.jsKeyMissing == false)
+        #expect(differences["swiftOnly"]?.jsKeyMissing == true)
+        #expect(differences["swiftOnly"]?.nativeKeyMissing == false)
+    }
+
+    @Test("should detect nested object differences") func nestedDifferences() async throws {
+        let jsJSON = """
+        {
+            "nested": {
+                "value": 42,
+                "array": [1, 2, 3]
+            }
+        }
+        """
+
+        let swiftJSON = """
+        {
+            "nested": {
+                "value": 43,
+                "array": [1, 2, 4]
+            }
+        }
+        """
+
+        let differences = try JSONCompare.differences(
+            function: .makeProfile,
+            swift: swiftJSON,
+            javascript: jsJSON
+        )
+
+        #expect(differences.count == 1)
+        guard case let .object(nestedDiff) = differences["nested"]?.js else {
+            throw TestFailure("Expected nested object difference")
+        }
+        #expect(nestedDiff["value"] == .number(42))
+        #expect(nestedDiff["array"] == .array([.number(1), .number(2), .number(3)]))
+    }
+
+    @Test("should ignore specified keys for makeProfile") func keyIgnoring() async throws {
+        let jsJSON = """
+        {
+            "value": 42,
+            "calc_glucose_noise": true,
+            "enableEnliteBgproxy": false
+        }
+        """
+
+        let swiftJSON = """
+        {
+            "value": 42
+        }
+        """
+
+        let differences = try JSONCompare.differences(
+            function: .makeProfile,
+            swift: swiftJSON,
+            javascript: jsJSON
+        )
+        #expect(differences.isEmpty)
+    }
+
+    @Test("should handle invalid JSON") func invalidJSON() async throws {
+        let invalidJSON = "{ invalid json }"
+
+        do {
+            _ = try JSONCompare.differences(
+                function: .makeProfile,
+                swift: invalidJSON,
+                javascript: matchingJSON
+            )
+            throw TestFailure("Expected error for invalid JSON")
+        } catch {
+            // Expected error
+            #expect(true)
+        }
+    }
+
+    @Test("should handle empty JSON objects") func emptyObjects() async throws {
+        let emptyJSON = "{}"
+        let differences = try JSONCompare.differences(
+            function: .makeProfile,
+            swift: emptyJSON,
+            javascript: emptyJSON
+        )
+        #expect(differences.isEmpty)
+    }
+
+    @Test("should detect array length differences") func arrayLengthDifferences() async throws {
+        let jsJSON = """
+        {
+            "array": [1, 2, 3]
+        }
+        """
+
+        let swiftJSON = """
+        {
+            "array": [1, 2]
+        }
+        """
+
+        let differences = try JSONCompare.differences(
+            function: .makeProfile,
+            swift: swiftJSON,
+            javascript: jsJSON
+        )
+
+        #expect(differences.count == 1)
+        guard case let .array(jsArray) = differences["array"]?.js,
+              case let .array(swiftArray) = differences["array"]?.swift
+        else {
+            throw TestFailure("Expected array differences")
+        }
+        #expect(jsArray.count == 3)
+        #expect(swiftArray.count == 2)
+    }
+}
+
+struct TestFailure: Error {
+    let message: String
+
+    init(_ message: String) {
+        self.message = message
+    }
+}

+ 137 - 7
TrioTests/OpenAPSSwiftTests/ProfileJsNativeCompareTests.swift

@@ -60,7 +60,7 @@ import Testing
     @Test("should compare Profile for js and native with base inputs") func withBasicInputs() async throws {
         let inputs = createBaseInputs()
         let openAps = OpenAPS(storage: BaseFileStorage())
-        let profileJs = try! await openAps.makeProfileJavascript(
+        let profileJs = await openAps.makeProfileJavascript(
             preferences: inputs.0,
             pumpSettings: inputs.1,
             bgTargets: inputs.2,
@@ -73,7 +73,7 @@ import Testing
             trioSettings: inputs.8
         )
 
-        let profileNative = OpenAPSSwift.makeProfile(
+        let profileSwift = OpenAPSSwift.makeProfile(
             preferences: inputs.0,
             pumpSettings: inputs.1,
             bgTargets: inputs.2,
@@ -85,11 +85,141 @@ import Testing
             trioSettings: inputs.8
         )
 
-        let differences = try! JSONCompare.differences(function: .makeProfile, native: profileNative, javascript: profileJs)
+        let comparison = JSONCompare.createComparison(
+            function: .makeProfile,
+            swift: profileSwift,
+            swiftDuration: 0.1,
+            javascript: profileJs,
+            javascriptDuration: 0.1
+        )
+
+        #expect(comparison.resultType == .matching)
+    }
+}
+
+@Suite("Algorithm Comparison Creation") struct ComparisonCreationTests {
+    // Test fixtures
+    let matchingJSON = """
+    {
+        "value": 42
+    }
+    """
+
+    let differentJSON = """
+    {
+        "value": 43
+    }
+    """
+
+    let invalidJSON = "{ invalid json"
+
+    @Test("should create matching comparison when values are identical") func matchingValues() async throws {
+        let comparison = JSONCompare.createComparison(
+            function: .makeProfile,
+            swift: .success(matchingJSON),
+            swiftDuration: 0.1,
+            javascript: .success(matchingJSON),
+            javascriptDuration: 0.2
+        )
+
+        #expect(comparison.resultType == .matching)
+        #expect(comparison.differences == nil)
+        #expect(comparison.jsDuration == 0.2)
+        #expect(comparison.swiftDuration == 0.1)
+        #expect(comparison.jsException == nil)
+        #expect(comparison.swiftException == nil)
+        #expect(comparison.comparisonError == nil)
+    }
+
+    @Test("should detect value differences") func valueDifferences() async throws {
+        let comparison = JSONCompare.createComparison(
+            function: .makeProfile,
+            swift: .success(differentJSON),
+            swiftDuration: 0.1,
+            javascript: .success(matchingJSON),
+            javascriptDuration: 0.2
+        )
+
+        #expect(comparison.resultType == .valueDifference)
+        #expect(comparison.differences != nil)
+        #expect(comparison.differences?["value"] != nil)
+        #expect(comparison.jsDuration == 0.2)
+        #expect(comparison.swiftDuration == 0.1)
+        #expect(comparison.jsException == nil)
+        #expect(comparison.swiftException == nil)
+        #expect(comparison.comparisonError == nil)
+    }
+
+    @Test("should handle matching exceptions") func matchingExceptions() async throws {
+        let error = NSError(domain: "test", code: 1, userInfo: nil)
+        let comparison = JSONCompare.createComparison(
+            function: .makeProfile,
+            swift: .failure(error),
+            swiftDuration: 0.1,
+            javascript: .failure(error),
+            javascriptDuration: 0.2
+        )
+
+        #expect(comparison.resultType == .matchingExceptions)
+        #expect(comparison.differences == nil)
+        #expect(comparison.jsException != nil)
+        #expect(comparison.swiftException != nil)
+        #expect(comparison.comparisonError == nil)
+    }
+
+    @Test("should handle Swift-only exceptions") func swiftOnlyException() async throws {
+        let error = NSError(domain: "test", code: 1, userInfo: nil)
+        let comparison = JSONCompare.createComparison(
+            function: .makeProfile,
+            swift: .failure(error),
+            swiftDuration: 0.1,
+            javascript: .success(matchingJSON),
+            javascriptDuration: 0.2
+        )
+
+        #expect(comparison.resultType == .swiftOnlyException)
+        #expect(comparison.differences == nil)
+        #expect(comparison.jsException == nil)
+        #expect(comparison.swiftException != nil)
+        #expect(comparison.jsDuration == 0.2)
+        #expect(comparison.swiftDuration == nil)
+        #expect(comparison.comparisonError == nil)
+    }
+
+    @Test("should handle JavaScript-only exceptions") func javascriptOnlyException() async throws {
+        let error = NSError(domain: "test", code: 1, userInfo: nil)
+        let comparison = JSONCompare.createComparison(
+            function: .makeProfile,
+            swift: .success(matchingJSON),
+            swiftDuration: 0.1,
+            javascript: .failure(error),
+            javascriptDuration: 0.2
+        )
+
+        #expect(comparison.resultType == .jsOnlyException)
+        #expect(comparison.differences == nil)
+        #expect(comparison.jsException != nil)
+        #expect(comparison.swiftException == nil)
+        #expect(comparison.jsDuration == nil)
+        #expect(comparison.swiftDuration == 0.1)
+        #expect(comparison.comparisonError == nil)
+    }
+
+    @Test("should handle comparison errors with invalid JSON") func comparisonError() async throws {
+        let comparison = JSONCompare.createComparison(
+            function: .makeProfile,
+            swift: .success(invalidJSON),
+            swiftDuration: 0.1,
+            javascript: .success(matchingJSON),
+            javascriptDuration: 0.2
+        )
 
-        if !differences.isEmpty {
-            JSONCompare.prettyPrint(differences)
-        }
-        #expect(differences.isEmpty)
+        #expect(comparison.resultType == .comparisonError)
+        #expect(comparison.differences == nil)
+        #expect(comparison.jsException == nil)
+        #expect(comparison.swiftException == nil)
+        #expect(comparison.comparisonError != nil)
+        #expect(comparison.jsDuration == 0.2)
+        #expect(comparison.swiftDuration == 0.1)
     }
 }

+ 5 - 0
scripts/run_ios_tests.sh

@@ -0,0 +1,5 @@
+#!/bin/bash
+xcodebuild test -workspace Trio.xcworkspace \
+	   -scheme Trio \
+	   -destination 'platform=iOS Simulator,name=iPhone 16' \
+    | xcpretty