Просмотр исходного кода

First cut at logging for js / swift oref

This commit introduces a logging module that stores anonymous results
from running both the JS and Swift implementations for oref
functions. Now it only supports `makeProfile` but it should be easy to
extend as we port new functions.
Sam King 1 год назад
Родитель
Сommit
010d4d9e71

+ 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 {

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

@@ -0,0 +1,105 @@
+import Foundation
+
+/// 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
+    }
+}

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

@@ -0,0 +1,251 @@
+import Foundation
+
+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)")
+            }
+        }
+    }
+
+    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
+        }
+    }
+}

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

@@ -0,0 +1,169 @@
+import Foundation
+import UIKit
+
+actor JsSwiftOrefComparisonLogger {
+    // MARK: - API Models
+
+    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
+    }
+
+    enum LoggerError: Error {
+        case fileOperationFailed
+        case encodingFailed
+        case decodingFailed
+        case invalidSignedURLResponse
+        case urlGenerationFailed
+        case timezoneError
+    }
+
+    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 URL that we can PUT to
+    private let baseUrlString = "https://event-log-server.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()
+        }
+    }
+
+    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.urlGenerationFailed
+        }
+
+        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.urlGenerationFailed
+        }
+
+        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 URLError(.badServerResponse)
+        }
+    }
+}

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

@@ -0,0 +1,25 @@
+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)
         }
     }
 }

+ 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
-        }
-    }
-}

+ 206 - 0
TrioTests/OpenAPSSwiftTests/JSONCompareTests.swift

@@ -0,0 +1,206 @@
+import Foundation
+@testable import FreeAPS
+import Testing
+
+@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,
+            native: 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
+    }
+}

+ 138 - 6
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,143 @@ import Testing
             trioSettings: inputs.8
         )
 
-        let differences = try! JSONCompare.differences(function: .makeProfile, native: profileNative, javascript: profileJs)
+        switch (profileSwift, profileJs) {
+        case let (.success(swiftJson), .success(jsJson)):
+            let differences = try! JSONCompare.differences(function: .makeProfile, swift: swiftJson, javascript: jsJson)
 
-        if !differences.isEmpty {
-            JSONCompare.prettyPrint(differences)
+            if !differences.isEmpty {
+                JSONCompare.prettyPrint(differences)
+            }
+            #expect(differences.isEmpty)
+        default:
+            #expect(Bool(false))
         }
-        #expect(differences.isEmpty)
+    }
+}
+
+@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
+        )
+
+        #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