Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,9 @@ See `docs/qring-ble-adoption.md` §5a for the full history and the decompiled so
**Read this before touching `EventPersistenceSubscriber`'s `DeviceStateChanged` branch.**

**No ring re-supplies more history than its own buffer holds, so the app's copy is the only durable
one.** A connect may retire *demo* rows and nothing else. This is not a style preference — it was a
data-loss bug twice, in two different shapes (issue #43, and the sync-pass variant before it).
one.** A connect may delete **nothing at all** — not stored history, and (since the iOS-parity fix)
not demo rows either. This is not a style preference — it was a data-loss bug three times, in three
different shapes (issue #43, the sync-pass variant before it, and the demo-row purge below).

The original design deleted all sleep on connect and re-pulled it, carving YCBT out via
`preservesSleepOnConnect` because YCBT re-asserts CONNECTED mid-history. That premise was false for
Expand Down Expand Up @@ -100,13 +101,47 @@ transition:
status packets. `runStartup` re-sends them, and `runStartup` is also the ~30-minute background
sync — so they recur for the whole life of a connection.

`isConnectTransition(event.deviceType)` is that gate, and `connectPurge` is what it feeds. Be precise
about its scope, because it is narrower than it looks: it decides only what a CONNECTED event may
*delete*. The row write below it — `stateRaw = "CONNECTED"`, `lastConnectedAt`, `lastSyncAt` — is
`isConnectTransition(event.deviceType)` is that gate. It used to feed `connectPurge`; now that a
connect deletes nothing, `connectPurge` ignores its argument and returns `ConnectPurge.NOTHING`
unconditionally, so `isConnectTransition` has **no production caller left** — only
`EventPersistenceIdentityTest`. It is kept because the distinction is real and gets re-derived
wrongly each time someone needs it, and any future connect-time action wants exactly it. Be precise
about the gate's scope, because it is narrower than it looks: it decides only what a CONNECTED
event may *delete*. The row write below it — `stateRaw = "CONNECTED"`, `lastConnectedAt`, `lastSyncAt` — is
**outside** the gate and still runs for every decoder `Status`, so a jring `0x0C` reply does still
restamp the device row as freshly connected on each sync pass. That is harmless today; it is not
something the gate prevents, so don't cite it as if it were.

**Demo rows are not purged on connect either — match iOS, don't "clean up" for it.** The demo
purge was the last surviving fragment of the original "connect = clear everything and rebuild"
design, and it was gated only on "this is the BLE client's own connect". A paired ring
re-establishes its link constantly — measured at roughly one reconnect per five minutes on a COLMI
R10 — so every one of those re-ran `clearDemo()` and seeded demo data could never survive alongside
a paired ring. Captured live at 09:00:54 with the phone untouched: 772 measurements / 84 activity
days / 36 sleep sessions to zero. **iOS has no connect-time demo purge anywhere** — its only
deletion of seeded rows is user-initiated inside `SeedData`, and it handles the demo/real mix by
*detecting* it (`isDemo`, `source == "mock"`, `DataFreshness.demo`) and adapting the UI. Demo data
coexisting with a real ring is an expected state. Demo rows are retired only from
Settings → Privacy & Data → Clear Demo Data.

`ConnectPurge` now has exactly **one** member, `NOTHING`, and the single-branch `when`s that switch
on it in `EventPersistenceSubscriber` and `EventPersistenceIdentityTest` read as dead code but are
not: they are what makes *widening* what a connect deletes a compile error instead of a one-line
edit. Don't "simplify" them away. Know the limit, though — it only catches an author who routes the
new deletion through the enum; a bare `clearDemo()` dropped into the CONNECTED arm still compiles.
The two tests named below are the actual enforcement.

**Readers must choose between demo and real rows, because nothing separates them any more.** The
purge was also, accidentally, the thing keeping seeded rows out of every unfiltered query. With it
gone the two coexist indefinitely, so `DemoDataPolicy` (`data/DemoDataPolicy.kt`) states the rule:
**real wins** — a reader surfaces demo rows only while the corresponding real series is empty, and
switches to the `*Real` DAO queries the moment the ring has synced anything. Derived values that
are *persisted or exported* — `hrRestingBaseline`, `estimatedActiveCalories`, Health Connect
records — read `*Real` unconditionally, since a demo-derived number outlives the demo data behind
it. When you add a query over `measurements`, `activity_daily`, or `sleep_sessions`, decide which
of those two it is; "it's just a read" was how a seeded 56 bpm night became a real user's auto HR
zone floor.

Two related things worth knowing before changing this area:

- **Nothing bulk-deletes sleep any more, anywhere in the app.** That connect path was the only
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,29 @@ object CoachContextBuilder {
val latestHr = db.measurementDao().latest(MeasurementKind.HEART_RATE.name, now)
val latestSpo2 = db.measurementDao().latest(MeasurementKind.SPO2.name, now)

// 7-day trends
// 7-day trends. Real wins over seeded rows once the ring has synced (`DemoDataPolicy`):
// a connect no longer purges demo data, so without this the coach LLM is handed a
// demo/real blend — and, via `isDemo` below, told it is live.
val activityHasReal = db.activityDailyDao().hasReal()
val sevenDaysAgo = todayStart - 7 * 24 * 3600_000L
val activityWeek = db.activityDailyDao().recent(7).filter { it.date >= sevenDaysAgo }
val activityWeek = (if (activityHasReal) db.activityDailyDao().recentReal(7)
else db.activityDailyDao().recent(7)).filter { it.date >= sevenDaysAgo }
val stepsWeek = activityWeek.map { it.steps.toDouble() }
val daysAvailable = activityWeek.count { it.steps > 0 }

// Resting HR: average of HR values in the last 24h while at rest (approximated as lowest 25th percentile)
val hr24h = db.measurementDao().range(MeasurementKind.HEART_RATE.name, now - 24 * 3600_000L, now)
val hr24h = if (db.measurementDao().hasReal(MeasurementKind.HEART_RATE.name))
db.measurementDao().rangeReal(MeasurementKind.HEART_RATE.name, now - 24 * 3600_000L, now)
else db.measurementDao().range(MeasurementKind.HEART_RATE.name, now - 24 * 3600_000L, now)
val restingHr = if (hr24h.isNotEmpty()) {
val sorted = hr24h.map { it.value }.sorted()
sorted[(sorted.size * 0.25).toInt().coerceAtMost(sorted.size - 1)]
} else null

// Sleep
val latestSleep = db.sleepSessionDao().recent(1).firstOrNull()
val sleepHasReal = db.sleepSessionDao().hasReal()
val latestSleep = (if (sleepHasReal) db.sleepSessionDao().recentReal(1)
else db.sleepSessionDao().recent(1)).firstOrNull()

val completeness = profileCompleteness(profile)
val warnings = DataQualityAnalyzer.warnings(
Expand All @@ -55,7 +63,12 @@ object CoachContextBuilder {
daysAvailable = daysAvailable,
hasSleep = latestSleep != null,
lastSyncAt = device?.lastSyncAt,
isDemo = false,
// Hardcoded false meant the "This is demo/sample data, not live readings from the
// ring." warning could never be emitted. Everything above falls back to seeded
// rows exactly when the ring has synced nothing — which is what demo mode IS.
isDemo = !activityHasReal && !sleepHasReal &&
!db.measurementDao().hasReal(MeasurementKind.HEART_RATE.name) &&
(activityWeek.isNotEmpty() || latestSleep != null || hr24h.isNotEmpty()),
),
now = now,
)
Expand Down
32 changes: 32 additions & 0 deletions app/src/main/java/com/pulseloop/data/DemoDataPolicy.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.pulseloop.data

/**
* How readers treat seeded demo rows now that a connect deletes nothing (PR #52).
*
* Before #52, `EventPersistenceSubscriber` purged demo rows on every CONNECTED transition, so
* "demo rows and a paired ring's real rows in the same table" was a state that only existed for
* the few seconds between a reseed and the next reconnect. Removing that purge — correctly, it
* was wiping seeded data every ~5 minutes under a paired R10 — makes the mixed state permanent,
* and "Reseed Demo Data" ships in Settings → Privacy & Data, so any user can reach it.
*
* The rule, mirroring iOS (which detects the mix rather than cleaning it up): **real data wins.**
* A reader surfaces demo rows only while the corresponding real series is empty. As soon as a ring
* has synced anything for that series, demo rows are excluded rather than blended in — blending
* is what produced 13-hour "nights" (demo night + real night collapsed onto one date), demo-fed
* resting-HR baselines, and demo-inflated calorie estimates written onto real rows.
*
* Derived values that are persisted or exported (`hrRestingBaseline`, `estimatedActiveCalories`,
* Health Connect records) go further: they read the `*Real` queries unconditionally, since a
* demo-derived number outlives the demo data that produced it.
*/
object DemoDataPolicy {
/** Every `source` / `sourceRaw` value that marks a row as seeded rather than synced. */
val DEMO_SOURCES = setOf("demo", "mock")

/**
* The seeder writes `"demo"`; `"mock"` is the iOS spelling and is accepted alongside it.
* Checking only one of the two is how `MetricsService.isDemo` silently reported seeded data
* as live.
*/
fun isDemo(source: String?): Boolean = source != null && source in DEMO_SOURCES
}
27 changes: 27 additions & 0 deletions app/src/main/java/com/pulseloop/data/dao/Daos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ interface MeasurementDao {
@Query("SELECT EXISTS(SELECT 1 FROM measurements WHERE kindRaw = :kind AND sourceRaw = 'demo')")
suspend fun hasDemo(kind: String): Boolean

/** Whether the ring has ever synced this kind. The demo/real chooser (`DemoDataPolicy`):
* once this is true a reader takes the `*Real` query and stops charting seeded rows, instead
* of interleaving the two into one series. */
@Query("SELECT EXISTS(SELECT 1 FROM measurements WHERE kindRaw = :kind AND sourceRaw NOT IN ('demo','mock'))")
suspend fun hasReal(kind: String): Boolean

@Insert
suspend fun insert(measurement: MeasurementEntity)

Expand Down Expand Up @@ -129,6 +135,14 @@ interface ActivityDailyDao {
@Query("SELECT * FROM activity_daily ORDER BY date DESC LIMIT :limit")
fun recentFlow(limit: Int = 7): Flow<List<ActivityDailyEntity>>

/** Synced days only — the trend leg of the demo/real chooser (`DemoDataPolicy`). */
@Query("SELECT * FROM activity_daily WHERE source NOT IN ('demo','mock') ORDER BY date DESC LIMIT :limit")
suspend fun recentReal(limit: Int = 7): List<ActivityDailyEntity>

/** Whether the ring has ever synced a day. */
@Query("SELECT EXISTS(SELECT 1 FROM activity_daily WHERE source NOT IN ('demo','mock'))")
suspend fun hasReal(): Boolean

@Upsert
suspend fun upsert(entry: ActivityDailyEntity)

Expand Down Expand Up @@ -290,6 +304,19 @@ interface SleepSessionDao {
@Query("SELECT * FROM sleep_sessions WHERE date BETWEEN :start AND :end ORDER BY date ASC")
suspend fun inRange(start: Long, end: Long): List<SleepSessionEntity>

/** Synced sessions in a window — the aggregate leg of the demo/real chooser
* (`DemoDataPolicy`). Blending demo and ring nights across a week skews every average, and
* two sessions on the SAME date collapse into one impossible ~13h night. */
@Query("SELECT * FROM sleep_sessions WHERE date BETWEEN :start AND :end AND sourceRaw NOT IN ('demo','mock') ORDER BY date ASC")
suspend fun inRangeReal(start: Long, end: Long): List<SleepSessionEntity>

@Query("SELECT * FROM sleep_sessions WHERE sourceRaw NOT IN ('demo','mock') ORDER BY date DESC LIMIT :limit")
suspend fun recentReal(limit: Int = 7): List<SleepSessionEntity>

/** Whether the ring has ever synced a night. */
@Query("SELECT EXISTS(SELECT 1 FROM sleep_sessions WHERE sourceRaw NOT IN ('demo','mock'))")
suspend fun hasReal(): Boolean

/**
* Health Connect export selection (Phase 2): sessions committed after [watermark], by
* `updatedAt` — sleep is a mutable group (plan §3): a re-synced night must re-upsert the SAME
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,11 @@ object DailyCalorieEstimator {
val row = db.activityDailyDao().byDay(dayStart) ?: return
val dayEnd = dayStart + DAY_MS

val hrSamples = db.measurementDao().range(MeasurementKind.HEART_RATE.name, dayStart, dayEnd)
// `rangeReal`: the estimate is written back onto the day's REAL `activity_daily` row
// below, where it drives the calorie goal ring and is eligible for Health Connect export
// (that export filters on the row's own source, which is not demo). Seeded HR for today
// would inflate a number that outlives the demo data behind it (`DemoDataPolicy`).
val hrSamples = db.measurementDao().rangeReal(MeasurementKind.HEART_RATE.name, dayStart, dayEnd)
val buckets = db.activityBucketDao().byDay(dayStart)
val workouts = db.activitySessionDao().recent(WORKOUT_SCAN_LIMIT).filter {
it.statusRaw == "finished" && it.endedAt != null &&
Expand Down
Loading