Skip to content

Commit 6e3ea28

Browse files
authored
Merge PR #31: preserve insulin clock and Combo start evidence
Preserve insulin clock provenance and Combo start uncertainty
2 parents 6a3921d + 70f6be7 commit 6e3ea28

11 files changed

Lines changed: 404 additions & 9 deletions

File tree

‎AI_NOTES.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,28 @@
1+
# Native Insulin Clock And Combo Anchors - 2026-10-23
2+
3+
- Verified current main6a3921d2 and all three CI39930202544 jobs.
4+
- Reproduced three pure timestamp/offset failures. The first Room test exposed
5+
a missing app field in its synthetic fixture; after correcting that fixture,
6+
accepted wire data reproduced the intended OBSERVED-versus-INVALID failure.
7+
- Preserve original optional UTC offsets without shifting epoch dates. Verify
8+
retained aliases, millisecond precision and the emulated child's own clock.
9+
Contradictions reject before time-window filtering; no partial basal result.
10+
- Combo-labelled boluses now retain explicit unverified-start semantics. This
11+
reflects the pinned driver's infused/finished event path, not proof of the
12+
installed driver, complete delivery, meal intent or a clinically safe interval.
13+
- Existing owner diagnostics expose only active uncertain-start/missing-offset
14+
counts and retain lifecycle cleanup. No new workers, API, schema or authority.
15+
- Initial full quality passed6815tests/six existing skips in6m38s. Review then
16+
strengthened the old mixed-child fixture with a valid date and positive control
17+
so the new clock guard could not mask its intended form validation.
18+
- Final frozen full test/lint/compile/assemble passed in2m44s:6815passed/six existing
19+
skips, all ten new cases executed, real stock SDK TLS2.708s, zero lint errors,
20+
382existing warnings/eight hints. All1431source hashes stayed unchanged and
21+
all7178application classes were retained in the40,670,995byte local APK.
22+
Exact-source CI and main integration remain separate gates. See
23+
AAPS4_INSULIN_TIME_EVIDENCE.md for the source contract and remaining limits.
24+
- No AAPS, phone, server, live timer, target, dose or alert change.
25+
126
# Meal Research Interval Inventory - 2026-10-23
227

328
- Main bdf7b070 has all three successful Verify jobs in run 39918968049.

‎android-app/app/src/main/kotlin/io/aaps/copilot/service/AapsV3InsulinInventory.kt‎

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import io.aaps.copilot.data.repository.HistoryJson
55
import io.aaps.copilot.data.repository.LocalNightscoutV3Store
66
import io.aaps.copilot.data.repository.LocalV3RetainedSnapshot
77
import java.security.MessageDigest
8+
import java.time.Instant
89
import java.util.Collections
910
import kotlinx.serialization.json.*
1011

1112
enum class AapsV3ObservedInsulinStatus { UNCHECKED, OBSERVED, PARTIAL_CONTEXT, NOT_OBSERVED, INVALID, CONFLICTING, LATE_EVIDENCE, LIMIT_REACHED, ERROR }
1213

1314
internal enum class AapsV3TempBasalType { NORMAL, EMULATED_PUMP_SUSPEND, PUMP_SUSPEND, SUPERBOLUS, FAKE_EXTENDED }
15+
internal enum class AapsV3InsulinAnchorKind { UNSPECIFIED_EVENT, COMBO_BOLUS_START_UNVERIFIED, REPORTED_INTERVAL_START }
1416

1517
internal sealed class AapsV3InsulinDose {
1618
data class Bolus(val units: Double, val type: String, val basalInsulin: Boolean,
@@ -25,7 +27,9 @@ internal sealed class AapsV3InsulinDose {
2527
internal data class AapsV3InsulinObservation(
2628
val identifier: String, val eventAtMs: Long, val availableAtMs: Long,
2729
val createdRevision: Long, val modifiedRevision: Long, val invalidated: Boolean,
28-
val pumpRecordIdentity: String?, val pumpEndId: Long?, val dose: AapsV3InsulinDose
30+
val pumpRecordIdentity: String?, val pumpEndId: Long?, val dose: AapsV3InsulinDose,
31+
val recordedUtcOffsetMinutes: Int? = null,
32+
val anchorKind: AapsV3InsulinAnchorKind = AapsV3InsulinAnchorKind.UNSPECIFIED_EVENT
2933
) {
3034
val endAtMs: Long get() = eventAtMs + when (dose) {
3135
is AapsV3InsulinDose.Bolus -> 0
@@ -44,7 +48,8 @@ internal class AapsV3InsulinRead(val status: AapsV3ObservedInsulinStatus, val re
4448

4549
internal class AapsV3InsulinInventoryTimeline(snapshot: LocalV3RetainedSnapshot) {
4650
val revision = snapshot.collectionRevision
47-
private data class Entry(val atMs: Long, val observation: AapsV3InsulinObservation?)
51+
private data class Entry(val atMs: Long, val observation: AapsV3InsulinObservation?, val conflictingTime: Boolean = false)
52+
private data class RecordedClock(val atMs: Long, val offsetMinutes: Int?)
4853
private val entries: List<Entry>
4954
private val failure: AapsV3ObservedInsulinStatus?
5055

@@ -68,8 +73,17 @@ internal class AapsV3InsulinInventoryTimeline(snapshot: LocalV3RetainedSnapshot)
6873
val dose = dose(body) ?: return@mapNotNull null
6974
val identityBody = if (dose is AapsV3InsulinDose.Extended && string(body, "eventType") == "Temp Basal")
7075
HistoryJson.obj(body.getValue("extendedEmulated")) else body
71-
val at = HistoryJson.long(body.getValue("date"))
72-
require(at == row.eventAtMs && at in 1..LocalNightscoutV3Store.MAX_TIME)
76+
val clock = try {
77+
val outer = recordedClock(body)
78+
require(outer.atMs == row.eventAtMs)
79+
if (identityBody === body) outer else recordedClock(identityBody).also { child ->
80+
require(child.atMs == outer.atMs)
81+
require(child.offsetMinutes == null || outer.offsetMinutes == null || child.offsetMinutes == outer.offsetMinutes)
82+
}
83+
} catch (_: Exception) {
84+
return@mapNotNull Entry(checkNotNull(row.eventAtMs), null, conflictingTime = true)
85+
}
86+
val at = clock.atMs
7387
val duration = when (dose) {
7488
is AapsV3InsulinDose.Bolus -> 0
7589
is AapsV3InsulinDose.TempBasal -> dose.durationMs
@@ -79,7 +93,12 @@ internal class AapsV3InsulinInventoryTimeline(snapshot: LocalV3RetainedSnapshot)
7993
Entry(at, AapsV3InsulinObservation(row.identifier, at, row.modifiedRevision,
8094
row.createdRevision, row.modifiedRevision, row.deleted || boolean(body, "isValid") == false ||
8195
boolean(identityBody, "isValid") == false,
82-
pumpIdentity(identityBody, dose), identityBody["endId"]?.takeUnless { it == JsonNull }?.let(HistoryJson::long), dose))
96+
pumpIdentity(identityBody, dose), identityBody["endId"]?.takeUnless { it == JsonNull }?.let(HistoryJson::long), dose,
97+
clock.offsetMinutes, when {
98+
dose !is AapsV3InsulinDose.Bolus -> AapsV3InsulinAnchorKind.REPORTED_INTERVAL_START
99+
string(identityBody, "pumpType") == "ACCU_CHEK_COMBO" -> AapsV3InsulinAnchorKind.COMBO_BOLUS_START_UNVERIFIED
100+
else -> AapsV3InsulinAnchorKind.UNSPECIFIED_EVENT
101+
}))
83102
} catch (_: Exception) {
84103
Entry(checkNotNull(row.eventAtMs), null)
85104
}
@@ -93,6 +112,8 @@ internal class AapsV3InsulinInventoryTimeline(snapshot: LocalV3RetainedSnapshot)
93112
failure?.let { return unavailable(it) }
94113
// Latest retained versions cannot recover a record moved outside this earlier query.
95114
if (revision > knownAtMs) return unavailable(AapsV3ObservedInsulinStatus.LATE_EVIDENCE)
115+
// A contradictory anchor cannot establish that its delivery lies outside this window.
116+
if (entries.any { it.conflictingTime }) return unavailable(AapsV3ObservedInsulinStatus.INVALID)
96117
val relevant = entries.filter { it.atMs <= atMs &&
97118
(it.observation == null || it.atMs >= fromMs || it.observation.endAtMs > fromMs) }
98119
if (relevant.any { it.observation == null }) return unavailable(AapsV3ObservedInsulinStatus.INVALID)
@@ -195,6 +216,22 @@ internal class AapsV3InsulinInventoryTimeline(snapshot: LocalV3RetainedSnapshot)
195216
return MessageDigest.getInstance("SHA-256").digest(identity.toByteArray()).joinToString("") { "%02x".format(it.toInt() and 255) }
196217
}
197218

219+
private fun recordedClock(body: JsonObject): RecordedClock {
220+
val at = HistoryJson.long(body.getValue("date"))
221+
require(at in 1..LocalNightscoutV3Store.MAX_TIME)
222+
for (key in listOf("mills", "timestamp")) {
223+
body[key]?.takeUnless { it == JsonNull }?.let { require(HistoryJson.long(it) == at) }
224+
}
225+
string(body, "created_at")?.let {
226+
val instant = Instant.parse(it)
227+
require(instant.nano % 1_000_000 == 0 && instant.toEpochMilli() == at)
228+
}
229+
val offset = body["utcOffset"]?.takeUnless { it == JsonNull }?.let {
230+
HistoryJson.signedLong(it).also { value -> require(value in -1440..1440) }.toInt()
231+
}
232+
return RecordedClock(at, offset)
233+
}
234+
198235
private fun number(body: JsonObject, key: String) = body[key]?.takeUnless { it == JsonNull }?.let(HistoryJson::double)
199236
private fun string(body: JsonObject, key: String) = body[key]?.takeUnless { it == JsonNull }?.let(HistoryJson::string)
200237
private fun boolean(body: JsonObject, key: String) = body[key]?.takeUnless { it == JsonNull }?.let(HistoryJson::bool)

‎android-app/app/src/main/kotlin/io/aaps/copilot/service/AapsV3TargetChannel.kt‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ data class AapsV3ConnectionState(
2727
val profileHistoryIntervalCount: Int = 0,
2828
val observedBasal: AapsV3ReportedBasalStatus = AapsV3ReportedBasalStatus.UNCHECKED,
2929
val basalSegmentCount: Int = 0,
30-
val basalGapCount: Int = 0
30+
val basalGapCount: Int = 0,
31+
val comboBolusStartUnverifiedCount: Int = 0,
32+
val insulinMissingUtcOffsetCount: Int = 0
3133
)
3234

3335
/** Foreground-runtime owner. Observation never commits a preview or replays a command. */
@@ -174,7 +176,13 @@ internal class AapsV3TargetChannel(
174176
if (showEvidence) evidence?.profileHistory?.intervals?.size ?: 0 else 0,
175177
if (showEvidence) evidence?.reportedBasal?.status ?: AapsV3ReportedBasalStatus.UNCHECKED else AapsV3ReportedBasalStatus.UNCHECKED,
176178
if (showEvidence) evidence?.reportedBasal?.segments?.size ?: 0 else 0,
177-
if (showEvidence) evidence?.reportedBasal?.gaps?.size ?: 0 else 0)
179+
if (showEvidence) evidence?.reportedBasal?.gaps?.size ?: 0 else 0,
180+
if (showEvidence) evidence?.insulin?.records?.count {
181+
!it.invalidated && it.anchorKind == AapsV3InsulinAnchorKind.COMBO_BOLUS_START_UNVERIFIED
182+
} ?: 0 else 0,
183+
if (showEvidence) evidence?.insulin?.records?.count {
184+
!it.invalidated && it.recordedUtcOffsetMinutes == null
185+
} ?: 0 else 0)
178186
}
179187

180188
override suspend fun send(command: ActionCommand, deliveryGuard: suspend () -> String?): NativeTargetOutcome = commandMutex.withLock {

‎android-app/app/src/test/kotlin/io/aaps/copilot/service/AapsV3EffectiveProfileSourceTest.kt‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,70 @@ class AapsV3EffectiveProfileSourceTest {
248248
assertTrue(moved.modifiedRevision > original.modifiedRevision)
249249
}
250250

251+
@Test fun acceptedEmulatedTimeConflictBlocksBasalAndSurvivesReopenUntilCorrected() = runBlocking {
252+
val from = at - 3_600_000
253+
store().create(input("profile", from))
254+
val api = LocalNightscoutV3EntriesApi(db) { at + 100_000 }
255+
fun body(childAt: Long) = buildJsonObject {
256+
put("app", "AAPS")
257+
put("date", from); put("utcOffset", 240); put("eventType", "Temp Basal")
258+
put("absolute", 1.0); put("duration", 60)
259+
put("extendedEmulated", buildJsonObject {
260+
put("date", childAt); put("utcOffset", 240); put("eventType", "Combo Bolus")
261+
put("enteredinsulin", 0.5); put("duration", 60); put("isEmulatingTempBasal", true)
262+
})
263+
}
264+
val response = api.handle("POST", "/api/v3/treatments", emptyMap()) { body(at + 60_000).toString().toByteArray() }
265+
assertEquals(201, response.status)
266+
val source = AapsV3EffectiveProfileSource(store())
267+
fun rejected(read: AapsV3ObservedClinicalRead) {
268+
assertEquals(AapsV3ObservedInsulinStatus.INVALID, read.insulin.status)
269+
assertTrue(read.insulin.records.isEmpty())
270+
assertEquals(AapsV3ReportedBasalStatus.INPUT_UNAVAILABLE, read.reportedBasal.status)
271+
assertTrue(read.reportedBasal.segments.isEmpty())
272+
}
273+
rejected(source.readEvidence(from, at, at + 200_000))
274+
db.close(); db = open()
275+
val reopened = AapsV3EffectiveProfileSource(store())
276+
rejected(reopened.readEvidence(from, at, at + 200_000))
277+
val record = store().retainedSnapshot(LocalV3Collection.TREATMENTS, 100, 1_000_000)
278+
.let { it as LocalV3RetainedSnapshot.Complete }.documents.single { it.identifier != "profile" }
279+
store().update(LocalV3DocumentInput(LocalV3Collection.TREATMENTS, record.identifier, record.source,
280+
from, body(from).toString()), record.modifiedRevision)
281+
val corrected = reopened.readEvidence(from, at, at + 200_000)
282+
assertEquals(AapsV3ObservedInsulinStatus.OBSERVED, corrected.insulin.status)
283+
assertEquals(AapsV3ReportedBasalStatus.OBSERVED_WINDOW, corrected.reportedBasal.status)
284+
assertEquals(0.5, corrected.reportedBasal.segments.single().reportedComponentUnits!!, 1e-12)
285+
assertFalse(corrected.insulin.upstreamCompletenessVerified)
286+
assertTrue(db.therapyDao().since(0).isEmpty())
287+
}
288+
289+
@Test fun comboRecordedClockAndStartUncertaintySurviveStorageCacheAndReopen() = runBlocking {
290+
val api = LocalNightscoutV3EntriesApi(db) { at + 100_000 }
291+
val body = buildJsonObject {
292+
put("app", "AAPS"); put("date", at); put("utcOffset", -300)
293+
put("eventType", "Meal Bolus"); put("insulin", 2.2); put("type", "NORMAL")
294+
put("pumpId", 42); put("pumpType", "ACCU_CHEK_COMBO"); put("pumpSerial", "fixture-pump")
295+
}.toString()
296+
val accepted = api.handle("POST", "/api/v3/treatments", emptyMap()) { body.toByteArray() }
297+
assertEquals(201, accepted.status)
298+
suspend fun verify(reader: AapsV3EffectiveProfileSource) {
299+
val result = reader.readEvidence(at - 60_000, at, at + 200_000).insulin
300+
assertEquals(AapsV3ObservedInsulinStatus.PARTIAL_CONTEXT, result.status)
301+
val record = result.records.single()
302+
assertEquals(at, record.eventAtMs)
303+
assertEquals(-300, record.recordedUtcOffsetMinutes)
304+
assertEquals(AapsV3InsulinAnchorKind.COMBO_BOLUS_START_UNVERIFIED, record.anchorKind)
305+
assertFalse(result.upstreamCompletenessVerified)
306+
}
307+
val source = AapsV3EffectiveProfileSource(store())
308+
verify(source); verify(source)
309+
db.close(); db = open()
310+
verify(AapsV3EffectiveProfileSource(store()))
311+
assertTrue(db.therapyDao().since(0).isEmpty())
312+
assertTrue(db.actionCommandDao().latest(10).isEmpty())
313+
}
314+
251315
@Test fun latestDeletionBlocksFallbackAndSurvivesDatabaseReopen() = runBlocking {
252316
store().create(input("earlier"))
253317
val last = store().create(input("last", at + 10_000)).document

0 commit comments

Comments
 (0)