diff --git a/AI_NOTES.md b/AI_NOTES.md index 232349d9..6990d55e 100644 --- a/AI_NOTES.md +++ b/AI_NOTES.md @@ -1,3 +1,23 @@ +# Native Insulin Form Audit - 2026-10-23 + +- Five pure regressions reproduced hidden basal/extended fields in carbohydrate, + unrelated, instantaneous and emulated-child branches. A sixth regression used + accepted wire data and the native Room source. Original failing logs retained. +- Audit explicit delivery evidence before native mapper precedence. Preserve + legitimate bolus/basal/extended forms and one native emulated child; competing + forms refuse a partial inventory. Unrelated durations/glucose/profile context + and null fields do not create insulin. Storage remains lossless and unchanged. +- Retained-cache refresh, database reopen and valid correction recovery pass; + no clinical/action rows are written. All164focused tests pass without skips. + Frozen full test/lint/compile/assemble passed in6m48s:6800passed/six existing + skips, zero failures/errors, real pristine SDK TLS2.142s and zero lint errors + with382existing warnings/eight hints. All1431source hashes stayed unchanged + and APK retention passed. Mainfa6f22b7 CI39906923561 passed all three jobs. + Exact-head feature CI and main integration remain separate post-commit gates. +- Existing0.1.11 release, AAPS, live onset clock, targets, doses and alarms remain + unchanged. This stage does not attest complete upstream/pump history or enable + a clinically recommended meal waiting time. + # Insulin History Knowledge Boundary And 0.1.11 - 2026-10-23 - Main6a0a7894 CI39881365894 passed all three jobs. Native basal/profile source diff --git a/android-app/app/src/main/kotlin/io/aaps/copilot/service/AapsV3InsulinInventory.kt b/android-app/app/src/main/kotlin/io/aaps/copilot/service/AapsV3InsulinInventory.kt index 53367f54..6794b7c8 100644 --- a/android-app/app/src/main/kotlin/io/aaps/copilot/service/AapsV3InsulinInventory.kt +++ b/android-app/app/src/main/kotlin/io/aaps/copilot/service/AapsV3InsulinInventory.kt @@ -111,9 +111,14 @@ internal class AapsV3InsulinInventoryTimeline(snapshot: LocalV3RetainedSnapshot) private fun dose(body: JsonObject): AapsV3InsulinDose? { val insulin = number(body, "insulin") + val event = string(body, "eventType") + val intervalFields = listOf("enteredinsulin", "absolute", "percent", "rate", "extendedEmulated") + val intervalEvidence = event in setOf("Temp Basal", "Combo Bolus") || + intervalFields.any { present(body, it) } || boolean(body, "isEmulatingTempBasal") == true require(insulin == null || insulin >= 0) - // The native mapper chooses insulin before eventType and before carbohydrate fields. + // Native branch precedence is not proof that competing delivery fields are absent. if (insulin != null && insulin > 0) { + require(!intervalEvidence) require((number(body, "carbs") ?: 0.0) == 0.0) require((number(body, "duration") ?: 0.0) == 0.0 && (number(body, "durationInMilliseconds") ?: 0.0) == 0.0) require(body["extendedEmulated"] == null || body["extendedEmulated"] == JsonNull) @@ -122,10 +127,20 @@ internal class AapsV3InsulinInventoryTimeline(snapshot: LocalV3RetainedSnapshot) val config = body["icfg"]?.takeUnless { it == JsonNull }?.let { AapsV3ProfileJson.insulin(HistoryJson.obj(it)) } return AapsV3InsulinDose.Bolus(insulin, type, boolean(body, "isBasalInsulin") ?: false, config) } - if ((number(body, "carbs") ?: 0.0) != 0.0) return null - return when (string(body, "eventType")) { + require(boolean(body, "isBasalInsulin") != true) + if ((number(body, "carbs") ?: 0.0) != 0.0) { + require(!intervalEvidence) + return null + } + return when (event) { "Temp Basal" -> { - body["extendedEmulated"]?.takeUnless { it == JsonNull }?.let { return extended(HistoryJson.obj(it)) } + require(!present(body, "enteredinsulin")) + body["extendedEmulated"]?.takeUnless { it == JsonNull }?.let { + val child = HistoryJson.obj(it) + require(boolean(child, "isEmulatingTempBasal") == true) + return extended(child) + } + require(boolean(body, "isEmulatingTempBasal") != true) val duration = duration(body) val absolute = number(body, "absolute")?.also { require(it >= 0) } val percent = number(body, "percent")?.also { require(it >= -100) } @@ -137,11 +152,18 @@ internal class AapsV3InsulinInventoryTimeline(snapshot: LocalV3RetainedSnapshot) } "Combo Bolus" -> extended(body) "Correction Bolus", "Meal Bolus", "Snack Bolus" -> throw IllegalArgumentException("missing_native_insulin") - else -> null + else -> { + require(!intervalEvidence) + null + } } } private fun extended(body: JsonObject): AapsV3InsulinDose.Extended { + require((number(body, "insulin") ?: 0.0) == 0.0 && (number(body, "carbs") ?: 0.0) == 0.0) + require(boolean(body, "isBasalInsulin") != true) + require(listOf("absolute", "percent", "extendedEmulated").none { present(body, it) }) + number(body, "rate")?.let { require(it >= 0) } val units = checkNotNull(number(body, "enteredinsulin")).also { require(it >= 0) } return AapsV3InsulinDose.Extended(units, duration(body), boolean(body, "isEmulatingTempBasal") ?: false) } @@ -176,4 +198,5 @@ internal class AapsV3InsulinInventoryTimeline(snapshot: LocalV3RetainedSnapshot) private fun number(body: JsonObject, key: String) = body[key]?.takeUnless { it == JsonNull }?.let(HistoryJson::double) private fun string(body: JsonObject, key: String) = body[key]?.takeUnless { it == JsonNull }?.let(HistoryJson::string) private fun boolean(body: JsonObject, key: String) = body[key]?.takeUnless { it == JsonNull }?.let(HistoryJson::bool) + private fun present(body: JsonObject, key: String) = body[key]?.let { it != JsonNull } == true } diff --git a/android-app/app/src/test/kotlin/io/aaps/copilot/service/AapsV3EffectiveProfileSourceTest.kt b/android-app/app/src/test/kotlin/io/aaps/copilot/service/AapsV3EffectiveProfileSourceTest.kt index 9e9d05f8..482f0c70 100644 --- a/android-app/app/src/test/kotlin/io/aaps/copilot/service/AapsV3EffectiveProfileSourceTest.kt +++ b/android-app/app/src/test/kotlin/io/aaps/copilot/service/AapsV3EffectiveProfileSourceTest.kt @@ -137,6 +137,41 @@ class AapsV3EffectiveProfileSourceTest { Unit } + @Test fun acceptedMixedWirePayloadCannotHideDeliveryAcrossCacheAndReopen() = runBlocking { + val from = at - 60_000 + store().create(input("profile", from)) + val api = LocalNightscoutV3EntriesApi(db) { at + 100_000 } + val body = buildJsonObject { + put("app", "AAPS"); put("eventType", "Temp Basal"); put("date", from) + put("duration", 1); put("absolute", 0.8); put("carbs", 24) + }.toString() + val accepted = api.handle("POST", "/api/v3/treatments", emptyMap()) { body.toByteArray() } + assertEquals(201, accepted.status) + val id = Json.parseToJsonElement(accepted.body).jsonObject.getValue("identifier").jsonPrimitive.content + suspend fun rejected(reader: AapsV3EffectiveProfileSource) { + val result = reader.readEvidence(from, at, at + 200_000) + assertEquals(AapsV3ObservedInsulinStatus.INVALID, result.insulin.status) + assertTrue(result.insulin.records.isEmpty()) + assertEquals(AapsV3ReportedBasalStatus.INPUT_UNAVAILABLE, result.reportedBasal.status) + assertTrue(result.reportedBasal.segments.isEmpty()) + } + val source = AapsV3EffectiveProfileSource(store()) + rejected(source) + db.close(); db = open() + val reopened = AapsV3EffectiveProfileSource(store()) + rejected(reopened) + val original = checkNotNull(store().get(LocalV3Collection.TREATMENTS, id)) + val corrected = JsonObject(Json.parseToJsonElement(original.payloadJson).jsonObject - "carbs") + store().update(LocalV3DocumentInput(LocalV3Collection.TREATMENTS, id, "local-v3-owner", from, + corrected.toString()), original.modifiedRevision) + val result = reopened.readEvidence(from, at, at + 200_000) + assertEquals(AapsV3ObservedInsulinStatus.OBSERVED, result.insulin.status) + assertEquals(AapsV3ReportedBasalStatus.OBSERVED_WINDOW, result.reportedBasal.status) + assertFalse(result.insulin.upstreamCompletenessVerified) + assertTrue(db.therapyDao().since(0).isEmpty()) + assertTrue(db.actionCommandDao().latest(10).isEmpty()) + } + @Test fun profileHistorySharesRevisionAndRetainsCorrectionsAndDeletionAcrossReopen() = runBlocking { store().create(input("old", at - 60_000)) val original = store().create(input("new", at - 10_000, 6.5)).document diff --git a/android-app/app/src/test/kotlin/io/aaps/copilot/service/AapsV3InsulinInventoryTest.kt b/android-app/app/src/test/kotlin/io/aaps/copilot/service/AapsV3InsulinInventoryTest.kt index 7ba241e1..22f2a08c 100644 --- a/android-app/app/src/test/kotlin/io/aaps/copilot/service/AapsV3InsulinInventoryTest.kt +++ b/android-app/app/src/test/kotlin/io/aaps/copilot/service/AapsV3InsulinInventoryTest.kt @@ -229,4 +229,93 @@ class AapsV3InsulinInventoryTest { assertFalse(result.records.single().toString().contains("dose-1")) assertFalse(result.records.single().dose.toString().contains("historical")) } + + @Test fun unrecognizedDeliveryFieldsCannotBecomeAnEmptyOrPartialInventory() { + val indicators = listOf( + buildJsonObject { put("enteredinsulin", 1.0) }, + buildJsonObject { put("absolute", 0.0) }, + buildJsonObject { put("percent", -100) }, + buildJsonObject { put("rate", 0.8) }, + buildJsonObject { put("isEmulatingTempBasal", true) }, + buildJsonObject { put("isBasalInsulin", true) }, + buildJsonObject { put("extendedEmulated", buildJsonObject { + put("enteredinsulin", 1.0); put("duration", 30); put("isEmulatingTempBasal", true) + }) } + ) + for (indicator in indicators) for (event in listOf("Note", "Future Delivery")) { + val fields = JsonObject(indicator + ("eventType" to JsonPrimitive(event))) + val hidden = row("hidden", fields = fields) + for (result in listOf(read(hidden), read(row(), hidden))) { + assertEquals("$event $indicator", AapsV3ObservedInsulinStatus.INVALID, result.status) + assertTrue(result.records.isEmpty()) + } + // Unknown interval support cannot be discarded as expired. + assertEquals(AapsV3ObservedInsulinStatus.INVALID, + read(row("old", time = at - 7_200_000, fields = fields)).status) + } + } + + @Test fun carbohydrateBranchCannotHideBasalOrExtendedDelivery() { + for (delivery in listOf( + buildJsonObject { put("eventType", "Temp Basal"); put("absolute", 0.8); put("duration", 30) }, + buildJsonObject { put("eventType", "Combo Bolus"); put("enteredinsulin", 1.0); put("duration", 30) }, + buildJsonObject { put("eventType", "Carb Correction"); put("enteredinsulin", 1.0) } + )) { + val result = read(row(fields = JsonObject(delivery + ("carbs" to JsonPrimitive(24))))) + assertEquals(AapsV3ObservedInsulinStatus.INVALID, result.status) + assertTrue(result.records.isEmpty()) + } + } + + @Test fun instantaneousInsulinCannotHideAdditionalDeliveryForms() { + for (extra in listOf( + buildJsonObject { put("enteredinsulin", 1.0) }, + buildJsonObject { put("absolute", 0.0) }, + buildJsonObject { put("percent", -100) }, + buildJsonObject { put("rate", 0.8) }, + buildJsonObject { put("isEmulatingTempBasal", true) } + )) { + assertEquals(AapsV3ObservedInsulinStatus.INVALID, read(row(fields = JsonObject(extra + + ("insulin" to JsonPrimitive(2.2))))).status) + } + } + + @Test fun basalAndExtendedBranchesCannotHideAnotherForm() { + val basal = buildJsonObject { put("eventType", "Temp Basal"); put("duration", 30); put("absolute", 0.8) } + val extended = buildJsonObject { put("eventType", "Combo Bolus"); put("duration", 30); put("enteredinsulin", 1.0) } + for (fields in listOf(JsonObject(basal + ("enteredinsulin" to JsonPrimitive(1.0))), + JsonObject(extended + ("absolute" to JsonPrimitive(0.8))), + JsonObject(extended + ("percent" to JsonPrimitive(0.0))))) { + assertEquals(AapsV3ObservedInsulinStatus.INVALID, read(row(fields = fields)).status) + } + } + + @Test fun emulatedChildCannotSilentlyDropMixedOrRecursivePayloads() { + val child = buildJsonObject { put("enteredinsulin", 1.0); put("duration", 30); put("isEmulatingTempBasal", true) } + for (extra in listOf( + buildJsonObject { put("insulin", 2.0) }, + buildJsonObject { put("carbs", 24) }, + buildJsonObject { put("absolute", 0.8) }, + buildJsonObject { put("isEmulatingTempBasal", false) }, + buildJsonObject { put("extendedEmulated", child) } + )) { + val result = read(row(fields = buildJsonObject { + put("eventType", "Temp Basal"); put("duration", 30); put("absolute", 0.8) + put("extendedEmulated", JsonObject(child + extra)) + })) + assertEquals(AapsV3ObservedInsulinStatus.INVALID, result.status) + assertTrue(result.records.isEmpty()) + } + } + + @Test fun nonDeliveryDurationsGlucoseAndInsulinConfigurationDoNotBecomeDoses() { + for (fields in listOf( + buildJsonObject { put("eventType", "Carb Correction"); put("carbs", 24); put("duration", 120) }, + buildJsonObject { put("eventType", "Exercise"); put("duration", 30) }, + buildJsonObject { put("eventType", "Note"); put("glucose", 5.5); put("icfg", config()) }, + buildJsonObject { put("eventType", "Note"); put("enteredinsulin", JsonNull); put("rate", JsonNull) } + )) { + assertEquals(AapsV3ObservedInsulinStatus.NOT_OBSERVED, read(row(fields = fields)).status) + } + } } diff --git a/docs/AAPS4_INSULIN_OBSERVATIONS.md b/docs/AAPS4_INSULIN_OBSERVATIONS.md index e3d5ffcd..cb70fa50 100644 --- a/docs/AAPS4_INSULIN_OBSERVATIONS.md +++ b/docs/AAPS4_INSULIN_OBSERVATIONS.md @@ -28,6 +28,15 @@ earlier absence. Latest retained versions are not a historical version archive. Queries include intervals that start before the requested window and overlap it. Malformed records cannot be declared expired using an unknown duration. +Native mapper precedence is not completeness evidence. A carbohydrate branch or +unknown event cannot hide entered insulin, basal rate/percentage or an emulated +child. An instantaneous bolus cannot silently discard interval fields. Conflicting +forms reject the whole observational inventory, including old records with unknown +support. Null fields, ordinary carbohydrate/exercise durations and glucose/profile +configuration alone are not delivery. Emulated wrappers still select one child, +which must explicitly be emulating basal and cannot contain another delivery form. +Transport retention stays lossless; successful storage is separate from admission. + The existing native connection owner publishes observation status and record count. The count includes invalidated retained records and is not delivered insulin or IOB. Stop/revocation/offline ownership clears the published observation status. No new diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 05f96bb0..d13d4c8b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -36,6 +36,9 @@ without separate coverage/clock/delivery admission. See AAPS4_REPORTED_BASAL.md. The insulin index, like profile history, rejects a newer retained collection revision before window filtering. Revisions of excluded rows must not erase exposure from an earlier as-known query; this is not a versioned history archive. +The typed insulin index also audits delivery fields before applying native branch +precedence. Mixed/unknown forms remain retained for diagnosis but cannot produce a +partial insulin or basal read. Transport behavior and clinical tables are unchanged. ## RC1 Target Transaction Boundary diff --git a/docs/INVARIANTS.md b/docs/INVARIANTS.md index 38eb13aa..4a8c8820 100644 --- a/docs/INVARIANTS.md +++ b/docs/INVARIANTS.md @@ -45,6 +45,11 @@ - As-known insulin reads reject a collection revision newer than knowledge time before filtering. A moved/shortened/deleted dose, unrelated change or empty latest snapshot cannot be used to reconstruct an earlier absence of delivery. +- Native branch precedence cannot erase competing insulin forms. Carbohydrate, + unrelated or unknown records with explicit interval-delivery evidence reject + the inventory rather than disappearing. Instantaneous and extended forms cannot + absorb another form's fields. A valid emulated wrapper selects exactly one child. + Successful transport storage does not qualify such a payload for prediction. ## RC1 Target Admission diff --git a/docs/PLAN.md b/docs/PLAN.md index 0a35157b..10c9b47e 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1,5 +1,16 @@ # PLAN +## Native Insulin Form Audit (2026-10-23) + +- [x] Reproduce five form omissions plus one accepted-wire/native-Room omission. +- [x] Reject hidden/competing native delivery forms without changing transport. +- [x] All164focused tests passed; frozen full Android quality passed6800tests + with six existing skips, real SDK TLS, lint and APK retention checks. +- [x] Mainfa6f22b7 CI39906923561 passed all three jobs. +- [ ] Publish reviewed source, inspect exact-head CI and integrate into main. +- Independent upload/pump/clock coverage and prospective meal-time validation + remain required. No recommended eating-time admission follows from this audit. + ## Insulin History Knowledge Boundary And 0.1.11 (2026-10-23) - [x] Confirm main6a0a7894 CI39881365894 passed all three jobs.