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

[Part 4 of 6] Remove JSON encoding for carbs

This PR replaces the old CoreData -> JSON string -> [CarbsEntry]
pipeline with one that goes directly from CoreData to
[CarbsEntry]. The conversion is almost identical with two changes
coming from (1) including IDs (the old way didn't copy IDs over to the
CarbsEntry objects) and (2) using full precision dates instead of
encoded / decoded ISO string dates.
Sam King 3 дней назад
Родитель
Сommit
3fea34f0f6

+ 0 - 35
Model/Helper/CarbEntryStored+helper.swift

@@ -69,38 +69,3 @@ extension CarbEntryStored {
         return request
     }
 }
-
-extension CarbEntryStored: Encodable {
-    enum CodingKeys: String, CodingKey {
-        case actualDate
-        case created_at
-        case carbs
-        case fat
-        case id
-        case isFPU
-        case note
-        case protein
-        case enteredBy
-    }
-
-    public func encode(to encoder: Encoder) throws {
-        var container = encoder.container(keyedBy: CodingKeys.self)
-
-        let dateFormatter = ISO8601DateFormatter()
-        dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
-
-        let formattedDate = dateFormatter.string(from: date ?? Date())
-        try container.encode(formattedDate, forKey: .actualDate)
-        try container.encode(formattedDate, forKey: .created_at)
-
-        // TODO: handle this conditionally; pass in the enteredBy string (manual entry or via NS or Apple Health)
-        try container.encode("Trio", forKey: .enteredBy)
-
-        try container.encode(carbs, forKey: .carbs)
-        try container.encode(fat, forKey: .fat)
-        try container.encode(isFPU, forKey: .isFPU)
-        try container.encode(note, forKey: .note)
-        try container.encode(protein, forKey: .protein)
-        try container.encode(id, forKey: .id)
-    }
-}

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -830,6 +830,7 @@
 		DDD7C8C22F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD7C8C02F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift */; };
 		DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */; };
 		DDDD0FFD2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFC2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift */; };
+		DDDD10A12F4E22C000F9C645 /* CarbsNativeConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD10A02F4E22C000F9C645 /* CarbsNativeConversionTests.swift */; };
 		DDDD0FFF2F4E231B00F9C645 /* MockTDDStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */; };
 		DDE179522C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */; };
 		DDE179532C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */; };
@@ -1842,6 +1843,7 @@
 		DDD7C8C02F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSmoothingTests.swift; sourceTree = "<group>"; };
 		DDDD0FFC2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseNativeConversionTests.swift; sourceTree = "<group>"; };
+		DDDD10A02F4E22C000F9C645 /* CarbsNativeConversionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbsNativeConversionTests.swift; sourceTree = "<group>"; };
 		DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTDDStorage.swift; sourceTree = "<group>"; };
 		DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealPresetStored+CoreDataClass.swift"; sourceTree = "<group>"; };
 		DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealPresetStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
@@ -2977,6 +2979,7 @@
 				DDDD0FFD2F4E231600F9C645 /* Mocks */,
 				DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */,
 				DDDD0FFC2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift */,
+				DDDD10A02F4E22C000F9C645 /* CarbsNativeConversionTests.swift */,
 				DDC6CA6C2DD90A2A0060EE25 /* LocalizationTests.swift */,
 				3B997DD22DC02AEF006B6BB2 /* JSONImporterData */,
 				BD8FC05C2D6618BE00B95AED /* BolusCalculatorTests */,
@@ -5641,6 +5644,7 @@
 				BD8FC0542D66186000B95AED /* TestError.swift in Sources */,
 				DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */,
 				DDDD0FFD2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift in Sources */,
+				DDDD10A12F4E22C000F9C645 /* CarbsNativeConversionTests.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				CA02000000000000000010C2 /* DeliveryLimitsSyncTests.swift in Sources */,
 				BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */,

+ 1 - 1
Trio/Sources/APS/APSManager.swift

@@ -170,7 +170,7 @@ final class BaseAPSManager: APSManager, Injectable {
 
     init(resolver: Resolver) {
         injectServices(resolver)
-        openAPS = OpenAPS(storage: storage, tddStorage: tddStorage, glucoseStorage: glucoseStorage)
+        openAPS = OpenAPS(storage: storage, tddStorage: tddStorage, glucoseStorage: glucoseStorage, carbsStorage: carbsStorage)
         subscribe()
         lastLoopDateSubject.send(lastLoopDate)
 

+ 16 - 63
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -8,15 +8,17 @@ final class OpenAPS {
     private let storage: FileStorage
     private let tddStorage: TDDStorage
     private let glucoseStorage: GlucoseStorage
+    private let carbsStorage: CarbsStorage
 
     let context = CoreDataStack.shared.newTaskContext()
 
     let jsonConverter = JSONConverter()
 
-    init(storage: FileStorage, tddStorage: TDDStorage, glucoseStorage: GlucoseStorage) {
+    init(storage: FileStorage, tddStorage: TDDStorage, glucoseStorage: GlucoseStorage, carbsStorage: CarbsStorage) {
         self.storage = storage
         self.tddStorage = tddStorage
         self.glucoseStorage = glucoseStorage
+        self.carbsStorage = carbsStorage
     }
 
     static let dateFormatter: ISO8601DateFormatter = {
@@ -95,58 +97,6 @@ final class OpenAPS {
         }
     }
 
-    private func fetchAndProcessCarbs(additionalCarbs: Decimal? = nil, carbsDate: Date? = nil) async throws -> String {
-        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: CarbEntryStored.self,
-            onContext: context,
-            predicate: NSPredicate.predicateForOneDayAgo,
-            key: "date",
-            ascending: false
-        )
-
-        let json = try await context.perform {
-            guard let carbResults = results as? [CarbEntryStored] else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-
-            var jsonArray = self.jsonConverter.convertToJSON(carbResults)
-
-            if let additionalCarbs = additionalCarbs {
-                let formattedDate = carbsDate.map { ISO8601DateFormatter().string(from: $0) } ?? ISO8601DateFormatter()
-                    .string(from: Date())
-
-                let additionalEntry = [
-                    "carbs": Double(additionalCarbs),
-                    "actualDate": formattedDate,
-                    "id": UUID().uuidString,
-                    "note": NSNull(),
-                    "protein": 0,
-                    "created_at": formattedDate,
-                    "isFPU": false,
-                    "fat": 0,
-                    "enteredBy": "Trio"
-                ] as [String: Any]
-
-                // Assuming jsonArray is a String, convert it to a list of dictionaries first
-                if let jsonData = jsonArray.data(using: .utf8) {
-                    var jsonList = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? [[String: Any]]
-                    jsonList?.append(additionalEntry)
-
-                    // Convert back to JSON string
-                    if let updatedJsonData = try? JSONSerialization
-                        .data(withJSONObject: jsonList ?? [], options: .prettyPrinted)
-                    {
-                        jsonArray = String(data: updatedJsonData, encoding: .utf8) ?? jsonArray
-                    }
-                }
-            }
-
-            return jsonArray
-        }
-
-        return json
-    }
-
     private func fetchPumpHistoryObjectIDs() async throws -> [NSManagedObjectID]? {
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: PumpEventStored.self,
@@ -332,7 +282,10 @@ final class OpenAPS {
 
         // Perform asynchronous calls in parallel
         async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
-        async let carbs = fetchAndProcessCarbs(additionalCarbs: simulatedCarbsAmount ?? 0, carbsDate: simulatedCarbsDate)
+        async let carbsFetch = carbsStorage.getCarbsForAlgorithm(
+            additionalCarbs: simulatedCarbsAmount ?? 0,
+            carbsDate: simulatedCarbsDate
+        )
 
         var preferences = await storage.retrieveAsync(OpenAPS.Settings.preferences, as: Preferences.self) ?? Preferences()
         let glucoseFetchHours = preferences.maxMealAbsorptionTime + 0.5 // MMAT + half hour buffer
@@ -351,7 +304,7 @@ final class OpenAPS {
         // Await the results of asynchronous tasks
         let (
             pumpHistoryJSON,
-            carbsAsJSON,
+            carbs,
             glucose,
             trioCustomOrefVariables,
             profile,
@@ -361,7 +314,7 @@ final class OpenAPS {
             hasSufficientTdd
         ) = await (
             try parsePumpHistory(await pumpHistoryObjectIDs, simulatedBolusAmount: simulatedBolusAmount),
-            try carbs,
+            try carbsFetch,
             try glucoseFetch,
             try prepareTrioCustomOrefVariables,
             profileAsync,
@@ -377,7 +330,7 @@ final class OpenAPS {
             profile: profile,
             basalProfile: basalProfile,
             clock: clock,
-            carbs: carbsAsJSON,
+            carbs: carbs,
             glucose: glucose
         )
 
@@ -511,7 +464,7 @@ final class OpenAPS {
 
         // Perform asynchronous calls in parallel
         async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
-        async let carbs = fetchAndProcessCarbs()
+        async let carbsFetch = carbsStorage.getCarbsForAlgorithm(additionalCarbs: nil, carbsDate: nil)
         // Autosens needs the full 24h window for its sensitivity algorithm.
         async let glucoseFetch = glucoseStorage.getGlucoseForAlgorithm(
             shouldSmoothGlucose: shouldSmoothGlucose,
@@ -522,9 +475,9 @@ final class OpenAPS {
         async let getTempTargets = loadFileFromStorageAsync(name: Settings.tempTargets)
 
         // Await the results of asynchronous tasks
-        let (pumpHistoryJSON, carbsAsJSON, glucose, profile, basalProfile, tempTargets) = await (
+        let (pumpHistoryJSON, carbs, glucose, profile, basalProfile, tempTargets) = await (
             try parsePumpHistory(await pumpHistoryObjectIDs),
-            try carbs,
+            try carbsFetch,
             try glucoseFetch,
             getProfile,
             getBasalProfile,
@@ -537,7 +490,7 @@ final class OpenAPS {
             pumpHistory: pumpHistoryJSON,
             basalprofile: basalProfile,
             profile: profile,
-            carbs: carbsAsJSON,
+            carbs: carbs,
             temptargets: tempTargets
         )
 
@@ -684,7 +637,7 @@ final class OpenAPS {
         profile: JSON,
         basalProfile: JSON,
         clock: JSON,
-        carbs: JSON,
+        carbs: [CarbsEntry],
         glucose: [BloodGlucose]
     ) throws -> RawJSON {
         let swiftResult = OpenAPSSwift
@@ -704,7 +657,7 @@ final class OpenAPS {
         pumpHistory: JSON,
         basalprofile: JSON,
         profile: JSON,
-        carbs: JSON,
+        carbs: [CarbsEntry],
         temptargets: JSON
     ) throws -> RawJSON {
         let swiftResult = OpenAPSSwift

+ 0 - 4
Trio/Sources/APS/OpenAPSSwift/JSONBridge.swift

@@ -44,10 +44,6 @@ enum JSONBridge {
         try JSONBridge.from(string: from.rawJSON)
     }
 
-    static func carbs(from: JSON) throws -> [CarbsEntry] {
-        try JSONBridge.from(string: from.rawJSON)
-    }
-
     static func iobResult(from: JSON) throws -> [IobResult] {
         try JSONBridge.from(string: from.rawJSON)
     }

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

@@ -92,7 +92,7 @@ struct OpenAPSSwift {
         profile: JSON,
         basalProfile: JSON,
         clock: JSON,
-        carbs: JSON,
+        carbs: [CarbsEntry],
         glucose: [BloodGlucose]
     ) -> (OrefFunctionResult) {
         do {
@@ -100,7 +100,6 @@ struct OpenAPSSwift {
             let profile = try JSONBridge.profile(from: profile)
             let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
             let clock = try JSONBridge.clock(from: clock)
-            let carbs = try JSONBridge.carbs(from: carbs)
 
             let mealResult = try MealGenerator.generate(
                 pumpHistory: pumpHistory,
@@ -142,7 +141,7 @@ struct OpenAPSSwift {
         pumpHistory: JSON,
         basalProfile: JSON,
         profile: JSON,
-        carbs: JSON,
+        carbs: [CarbsEntry],
         tempTargets: JSON,
         clock: JSON,
         includeDeviationsForTesting: Bool = false
@@ -151,7 +150,6 @@ struct OpenAPSSwift {
             let pumpHistory = try JSONBridge.pumpHistory(from: pumpHistory)
             let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
             let profile = try JSONBridge.profile(from: profile)
-            let carbs = try JSONBridge.carbs(from: carbs)
             let tempTargets = try JSONBridge.tempTargets(from: tempTargets)
             let clock = try JSONBridge.clock(from: clock)
 

+ 70 - 0
Trio/Sources/APS/Storage/CarbsStorage.swift

@@ -13,6 +13,7 @@ protocol CarbsStorage {
     func storeCarbs(_ carbs: [CarbsEntry], areFetchedFromRemote: Bool) async throws
     func deleteCarbsEntryStored(_ treatmentObjectID: NSManagedObjectID) async
     func syncDate() -> Date
+    func getCarbsForAlgorithm(additionalCarbs: Decimal?, carbsDate: Date?) async throws -> [CarbsEntry]
     func getCarbsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment]
     func getFPUsNotYetUploadedToNightscout() async throws -> [NightscoutTreatment]
     func getCarbsNotYetUploadedToHealth() async throws -> [CarbsEntry]
@@ -344,6 +345,75 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         Date().addingTimeInterval(-1.days.timeInterval)
     }
 
+    /// Fetches the last day of carbs and converts them into the `CarbsEntry` values the oref algorithm
+    /// consumes, optionally appending a synthetic "additional carbs" entry for bolus simulation.
+    func getCarbsForAlgorithm(additionalCarbs: Decimal? = nil, carbsDate: Date? = nil) async throws -> [CarbsEntry] {
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: CarbEntryStored.self,
+            onContext: context,
+            predicate: NSPredicate.predicateForOneDayAgo,
+            key: "date",
+            ascending: false
+        )
+
+        return try await context.perform {
+            guard let carbResults = results as? [CarbEntryStored] else {
+                throw CoreDataError.fetchError(function: #function, file: #file)
+            }
+
+            var entries = carbResults.map { Self.mapToCarbsEntry($0) }
+
+            if let additionalCarbs = additionalCarbs {
+                entries.append(Self.additionalCarbsEntry(carbs: additionalCarbs, date: carbsDate ?? Date()))
+            }
+
+            return entries
+        }
+    }
+
+    /// Converts a `Double` to the exact `Decimal` the old JSON round-trip produced.
+    /// The problem is that the CarbEntryStored values are Double, not Decimal so JSON
+    /// was doing some conversion for us.
+    static func algorithmDecimal(_ value: Double) -> Decimal {
+        Decimal(string: value.description) ?? Decimal(value)
+    }
+
+    /// Maps a `CarbEntryStored` to the `CarbsEntry` the algorithm consumes, reproducing the coercions
+    /// the old `CarbEntryStored` → JSON → `JSONBridge.carbs` round-trip applied
+    static func mapToCarbsEntry(_ carbEntry: CarbEntryStored) -> CarbsEntry {
+        // The old encode used `date ?? Date()` for both created_at and actualDate.
+        let date = carbEntry.date ?? Date()
+        return CarbsEntry(
+            id: carbEntry.id?.uuidString,
+            createdAt: date,
+            actualDate: date,
+            carbs: algorithmDecimal(carbEntry.carbs),
+            fat: algorithmDecimal(carbEntry.fat),
+            protein: algorithmDecimal(carbEntry.protein),
+            note: nil,
+            enteredBy: CarbsEntry.local,
+            isFPU: carbEntry.isFPU,
+            fpuID: nil
+        )
+    }
+
+    /// Builds the synthetic "additional carbs" entry that the determine-basal flow appends for bolus
+    /// simulation.
+    static func additionalCarbsEntry(carbs: Decimal, date: Date, id: String = UUID().uuidString) -> CarbsEntry {
+        CarbsEntry(
+            id: id,
+            createdAt: date,
+            actualDate: date,
+            carbs: carbs,
+            fat: 0,
+            protein: 0,
+            note: nil,
+            enteredBy: CarbsEntry.local,
+            isFPU: false,
+            fpuID: nil
+        )
+    }
+
     func deleteCarbsEntryStored(_ treatmentObjectID: NSManagedObjectID) async {
         // Use injected context if available, otherwise create new task context
         let taskContext = context != CoreDataStack.shared.newTaskContext()

+ 318 - 0
TrioTests/CarbsNativeConversionTests.swift

@@ -0,0 +1,318 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Trio
+
+/// Golden tests certifying that the native `CarbEntryStored` → `CarbsEntry` mapping
+/// (`BaseCarbsStorage.mapToCarbsEntry`) reproduces, field for field, the carbs the algorithm used to receive
+/// through the old JSON round-trip (`CarbEntryStored` → JSON → `JSONBridge.carbs`) — with one
+/// deliberate, documented change: `id`.
+///
+/// The old encode-side keys didn't line up with the `CarbsEntry` decode-side `CodingKeys`, so a
+/// couple of fields silently dropped to nil crossing the boundary. The golden literals below were
+/// captured from that old path while it still existed (via a temporary differential run), so a match
+/// proves the algorithm still sees identical inputs now that the JSON round-trip is gone:
+/// - `id` was encoded under "id" but decoded from "_id" → **always nil** in the old path. We now
+///   populate it from Core Data (harmless to the algorithm, which never reads `id`); the goldens
+///   below therefore carry the real id and `testIdIsNowPopulatedFromCoreData` pins the difference.
+/// - `note` was encoded under "note" but decoded from "notes" → **always nil**. We preserve that.
+///
+/// The comparison is field by field (see `expectFieldsEqual`), NOT `==`: `CarbsEntry.==` only
+/// compares `createdAt`, so a plain array comparison would pass even if
+/// `id`/`carbs`/`fat`/`protein`/`note`/`isFPU`/`actualDate` differed — exactly the coerced fields
+/// this migration must preserve.
+///
+/// Fixtures use fixed dates and ids so the mapping is fully deterministic.
+@Suite("Carbs Native Conversion Tests", .serialized) struct CarbsNativeConversionTests {
+    var coreDataStack: CoreDataStack!
+    var testContext: NSManagedObjectContext!
+
+    init() async throws {
+        coreDataStack = try await CoreDataStack.createForTests()
+        testContext = coreDataStack.newTaskContext()
+    }
+
+    // MARK: - Golden tests (native mapping vs frozen old-path output)
+
+    @Test("A basic carb entry maps identically (id now populated)") func testBasicEntry() async throws {
+        await insertCarb(carbs: 30, isFPU: false, date: fixedDate(minutesAgo: 0), note: "breakfast", id: uuid(1))
+
+        try await assertNativeMatchesGolden([
+            CarbsEntry(
+                id: uuid(1).uuidString,
+                createdAt: Date(timeIntervalSince1970: 1_700_000_000),
+                actualDate: Date(timeIntervalSince1970: 1_700_000_000),
+                carbs: 30,
+                fat: 0,
+                protein: 0,
+                note: nil, // old path decoded "note" as "notes" → nil; preserved
+                enteredBy: CarbsEntry.local,
+                isFPU: false,
+                fpuID: nil
+            )
+        ])
+    }
+
+    @Test("Fractional carbs/fat/protein keep clean decimal values") func testFractionalDecimals() async throws {
+        // 33.33 as a Double is 33.32999999999999488; the old JSON round-trip recovered the clean
+        // 33.33 (JSONEncoder writes the shortest round-trippable string). `Decimal(Double)` would
+        // leak the binary expansion, so `algorithmDecimal` must reproduce the clean value.
+        await insertCarb(carbs: 33.33, isFPU: true, date: fixedDate(minutesAgo: 0), fat: 5.5, protein: 3.2, id: uuid(1))
+        await insertCarb(carbs: 12.5, isFPU: false, date: fixedDate(minutesAgo: 5), id: uuid(2))
+
+        try await assertNativeMatchesGolden([
+            CarbsEntry(
+                id: uuid(1).uuidString,
+                createdAt: Date(timeIntervalSince1970: 1_700_000_000),
+                actualDate: Date(timeIntervalSince1970: 1_700_000_000),
+                carbs: Decimal(string: "33.33")!,
+                fat: Decimal(string: "5.5")!,
+                protein: Decimal(string: "3.2")!,
+                note: nil,
+                enteredBy: CarbsEntry.local,
+                isFPU: true,
+                fpuID: nil
+            ),
+            CarbsEntry(
+                id: uuid(2).uuidString,
+                createdAt: Date(timeIntervalSince1970: 1_699_999_700),
+                actualDate: Date(timeIntervalSince1970: 1_699_999_700),
+                carbs: Decimal(string: "12.5")!,
+                fat: 0,
+                protein: 0,
+                note: nil,
+                enteredBy: CarbsEntry.local,
+                isFPU: false,
+                fpuID: nil
+            )
+        ])
+
+        let native = try await nativeCarbsEntries()
+        #expect(native.first?.carbs == Decimal(string: "33.33")!, "33.33 must stay clean, not the Double expansion")
+    }
+
+    @Test("A zero-carb entry (nil stored id) maps identically") func testZeroCarbNilId() async throws {
+        // The old path always produced id == nil, so a stored entry with no id is indistinguishable
+        // there. Natively, a nil stored id still maps to a nil `CarbsEntry.id`.
+        await insertCarb(carbs: 0, isFPU: false, date: fixedDate(minutesAgo: 0), id: nil)
+
+        try await assertNativeMatchesGolden([
+            CarbsEntry(
+                id: nil,
+                createdAt: Date(timeIntervalSince1970: 1_700_000_000),
+                actualDate: Date(timeIntervalSince1970: 1_700_000_000),
+                carbs: 0,
+                fat: 0,
+                protein: 0,
+                note: nil,
+                enteredBy: CarbsEntry.local,
+                isFPU: false,
+                fpuID: nil
+            )
+        ])
+    }
+
+    @Test("A multi-entry sequence maps identically") func testMultiEntrySequence() async throws {
+        for i in 0 ..< 5 {
+            await insertCarb(
+                carbs: Double(10 * (i + 1)),
+                isFPU: false,
+                date: fixedDate(minutesAgo: Double(i) * 5),
+                id: uuid(i + 1)
+            )
+        }
+
+        try await assertNativeMatchesGolden((0 ..< 5).map { i in
+            CarbsEntry(
+                id: uuid(i + 1).uuidString,
+                createdAt: Date(timeIntervalSince1970: 1_700_000_000 - Double(i) * 300),
+                actualDate: Date(timeIntervalSince1970: 1_700_000_000 - Double(i) * 300),
+                carbs: Decimal(10 * (i + 1)),
+                fat: 0,
+                protein: 0,
+                note: nil,
+                enteredBy: CarbsEntry.local,
+                isFPU: false,
+                fpuID: nil
+            )
+        })
+    }
+
+    @Test("Sub-millisecond dates are truncated to millisecond resolution") func testMillisecondTruncation() async throws {
+        // The old path round-tripped the stored date through an ISO8601 fractional-seconds string
+        // (millisecond precision). The native path keeps the full-precision `Date`, but only
+        // millisecond precision is ever observable, so the comparison is at ms resolution.
+        await insertCarb(carbs: 25, isFPU: false, date: fixedDate(minutesAgo: 0, plusSeconds: 0.123_456), id: uuid(1))
+
+        try await assertNativeMatchesGolden([
+            CarbsEntry(
+                id: uuid(1).uuidString,
+                createdAt: Date(timeIntervalSince1970: 1_700_000_000.123),
+                actualDate: Date(timeIntervalSince1970: 1_700_000_000.123),
+                carbs: 25,
+                fat: 0,
+                protein: 0,
+                note: nil,
+                enteredBy: CarbsEntry.local,
+                isFPU: false,
+                fpuID: nil
+            )
+        ])
+    }
+
+    // MARK: - The one deliberate behavioral change
+
+    @Test("id is now populated from Core Data (old path always produced nil)") func testIdIsNowPopulatedFromCoreData() async throws {
+        await insertCarb(carbs: 40, isFPU: false, date: fixedDate(minutesAgo: 0), id: uuid(7))
+
+        let native = try await nativeCarbsEntries()
+        // Old JSON path: `id` was encoded under "id" but decoded from "_id", so this was always nil.
+        // The native mapping carries the real Core Data id instead.
+        #expect(native.first?.id == uuid(7).uuidString, "native mapping must carry the Core Data id")
+    }
+
+    // MARK: - Additional (synthetic) carbs entry
+
+    @Test("The synthetic additional-carbs entry matches the old spliced dictionary") func testAdditionalCarbsEntry() {
+        let date = Date(timeIntervalSince1970: 1_700_000_000)
+        let id = uuid(99).uuidString
+        let entry = BaseCarbsStorage.additionalCarbsEntry(carbs: 15, date: date, id: id)
+
+        // Old path spliced a dictionary that decoded to: id=nil (encoded "id", decoded "_id"),
+        // note=nil, fat=0, protein=0, isFPU=false, enteredBy="Trio", both dates == the passed date.
+        // The only intended difference is `id`, which we now carry (see `additionalCarbsEntry`).
+        let expected = CarbsEntry(
+            id: id,
+            createdAt: date,
+            actualDate: date,
+            carbs: 15,
+            fat: 0,
+            protein: 0,
+            note: nil,
+            enteredBy: CarbsEntry.local,
+            isFPU: false,
+            fpuID: nil
+        )
+        expectFieldsEqual(entry, expected, entry: 0)
+    }
+
+    @Test("A zero additional-carbs entry (the normal loop case) is well formed") func testAdditionalCarbsZero() {
+        // In the normal determine-basal loop `additionalCarbs` is `simulatedCarbsAmount ?? 0`, so a
+        // carbs=0 entry is always appended. MealHistory/AutosensGenerator drop carbs <= 0, so it is
+        // inert, but we still reproduce the old shape exactly.
+        let date = Date(timeIntervalSince1970: 1_700_000_000)
+        let entry = BaseCarbsStorage.additionalCarbsEntry(carbs: 0, date: date, id: uuid(1).uuidString)
+        #expect(entry.carbs == 0)
+        #expect(entry.isFPU == false)
+        #expect(entry.enteredBy == CarbsEntry.local)
+    }
+
+    // MARK: - Comparison helpers
+
+    /// Asserts the native mapping reproduces the frozen golden `CarbsEntry` values. The goldens were
+    /// captured from the old `CarbEntryStored` → JSON → `JSONBridge.carbs` path (see the differential
+    /// run in this migration's history), except `id`, which the old path always dropped to nil and
+    /// which we now populate from Core Data.
+    private func assertNativeMatchesGolden(_ golden: [CarbsEntry]) async throws {
+        let native = try await nativeCarbsEntries()
+
+        #expect(native.count == golden.count, "native produced \(native.count) entries, golden has \(golden.count)")
+
+        for (index, pair) in zip(native, golden).enumerated() {
+            expectFieldsEqual(pair.0, pair.1, entry: index)
+        }
+    }
+
+    /// Field-by-field comparison. We can't use `==`: `CarbsEntry.==` only compares `createdAt`, so a
+    /// direct comparison would pass even if `id`/`carbs`/`fat`/`protein`/`note`/`isFPU`/`actualDate`
+    /// differed — exactly the coerced fields we must pin.
+    ///
+    /// `createdAt`/`actualDate` are compared at millisecond resolution rather than as exact `Date`s:
+    /// the mapping keeps the reading's full-precision date, but only millisecond precision is ever
+    /// observable (it's what the old ISO8601 round-trip preserved) and Core Data's `Double` storage
+    /// perturbs sub-millisecond bits anyway, so an exact `Date` comparison would be flaky.
+    private func expectFieldsEqual(_ actual: CarbsEntry, _ expected: CarbsEntry, entry index: Int) {
+        #expect(actual.id == expected.id, "entry \(index): id \(actual.id ?? "nil") != \(expected.id ?? "nil")")
+        #expect(
+            Self.millisecondString(actual.createdAt) == Self.millisecondString(expected.createdAt),
+            "entry \(index): createdAt \(Self.millisecondString(actual.createdAt)) != \(Self.millisecondString(expected.createdAt))"
+        )
+        #expect(
+            actual.actualDate.map(Self.millisecondString) == expected.actualDate.map(Self.millisecondString),
+            "entry \(index): actualDate mismatch"
+        )
+        #expect(actual.carbs == expected.carbs, "entry \(index): carbs \(actual.carbs) != \(expected.carbs)")
+        #expect(
+            actual.fat == expected.fat,
+            "entry \(index): fat \(String(describing: actual.fat)) != \(String(describing: expected.fat))"
+        )
+        #expect(
+            actual.protein == expected.protein,
+            "entry \(index): protein \(String(describing: actual.protein)) != \(String(describing: expected.protein))"
+        )
+        #expect(actual.note == expected.note, "entry \(index): note \(actual.note ?? "nil") != \(expected.note ?? "nil")")
+        #expect(
+            actual.enteredBy == expected.enteredBy,
+            "entry \(index): enteredBy \(actual.enteredBy ?? "nil") != \(expected.enteredBy ?? "nil")"
+        )
+        #expect(
+            actual.isFPU == expected.isFPU,
+            "entry \(index): isFPU \(String(describing: actual.isFPU)) != \(String(describing: expected.isFPU))"
+        )
+        #expect(actual.fpuID == expected.fpuID, "entry \(index): fpuID \(actual.fpuID ?? "nil") != \(expected.fpuID ?? "nil")")
+    }
+
+    private static func millisecondString(_ date: Date) -> String {
+        Formatter.iso8601withFractionalSeconds.string(from: date)
+    }
+
+    private func nativeCarbsEntries() async throws -> [CarbsEntry] {
+        try await testContext.perform {
+            try self.fetchRowsNewestFirst().map { BaseCarbsStorage.mapToCarbsEntry($0) }
+        }
+    }
+
+    /// Must be called from within `testContext.perform`. Mirrors the `date`-descending order the
+    /// production fetch uses (`BaseCarbsStorage.getCarbsForAlgorithm`).
+    private func fetchRowsNewestFirst() throws -> [CarbEntryStored] {
+        let request = CarbEntryStored.fetchRequest()
+        request.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
+        return try testContext.fetch(request)
+    }
+
+    // MARK: - Fixture helpers
+
+    private func insertCarb(
+        carbs: Double,
+        isFPU: Bool,
+        date: Date,
+        fat: Double = 0,
+        protein: Double = 0,
+        note: String? = nil,
+        id: UUID?,
+        fpuID: UUID? = nil
+    ) async {
+        await testContext.perform {
+            let object = CarbEntryStored(context: self.testContext)
+            object.carbs = carbs
+            object.isFPU = isFPU
+            object.date = date
+            object.fat = fat
+            object.protein = protein
+            object.note = note
+            object.id = id
+            object.fpuID = fpuID
+            try! self.testContext.save()
+        }
+    }
+
+    /// A fixed base timestamp (2023-11-14T22:13:20Z) so fixtures are deterministic and reproducible.
+    private func fixedDate(minutesAgo: Double, plusSeconds: Double = 0) -> Date {
+        Date(timeIntervalSince1970: 1_700_000_000 + plusSeconds - minutesAgo * 60)
+    }
+
+    private func uuid(_ n: Int) -> UUID {
+        UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", n))!
+    }
+}