Skip to content
Open
105 changes: 101 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -500,10 +500,39 @@ shape from the same side, and two records meeting with no gap read as one run: t
first. The reporter's ring closes one record and opens the next 33 seconds later, so whether the two
round to the same minute is a coin toss, and losing it costs a whole session. The trade is a stale
tail surviving a genuine shortening, which is minutes rather than hours and rarer than it was now
that a re-send reproduces the record's declared bounds instead of a drifted end. Carrying the
originating record's start on each block would allow both, and is the fix if the tail ever bites.

Still open, and a fair ask: showing a split night's two records **separately** as well as merged.
that a re-send reproduces the record's declared bounds instead of a drifted end.

**The block now carries its record's start, so the identity that was missing above exists**
(`SleepStageBlockEntity.recordStartAt`, v28, stamped by `buildStageBlocks` — see #68 below). It is
the fix this section predicted for the stale-tail trade, and `completeSessionSurvivors` can take it
whenever the tail actually bites.

## A record boundary is stored, never inferred from a gap (issue #68)

Showing a split night's individual records was the fair ask left open by #63. The card itself is
easy; the boundary is the whole problem, and it is the **third** time this one gap has cost a
feature.

**This firmware reopens a record roughly a minute after closing one** — 33 seconds on the #63
capture, `05:57 → 05:58` on the #68 reporter's Sept 9 — and blocks *within* one record are rounded
onto the same minute grid, so an ordinary rounding seam between two blocks of one record is the same
width as a genuine record boundary. That is not a threshold problem, it is the absence of the
information: `sessionId` names the *merged* row, shared by every record of the night, so from stored
data alone "next record" and "next block" are indistinguishable. `sleepRecordRuns` shipped with
`minGapMinutes = 2` and merged a genuinely split night (no card at all); 1 minute would have grown a
spurious second record on every unsplit night instead. Both answers are wrong because the question
was unanswerable.

So the import stamps it. `buildStageBlocks` is the one place that knows — it is handed the record's
declared start — and `sleepRecordRuns` groups by `recordStartAt` rather than measuring gaps. The gap
rule survives only as the fallback for rows written before the column, and it is **all-or-nothing
per night**: one stamped record beside a legacy block would read as two records whatever the truth.
`SleepRecordRunsTest` keeps the discriminating pair — the same one-minute gap asserted as a boundary
in one test and as a seam in the other — because that pair is what no threshold can satisfy and what
a future "simplify this to a gap check" would break.

**The general lesson:** when a merge destroys provenance, recover it at the point of the merge, not
at the point of display. Three separate fixes here tried to re-derive a record boundary downstream.

## Live workout HR on Colmi is a sport session, not an HR stream (issue #64)

Expand Down Expand Up @@ -588,6 +617,74 @@ not a history write) but never adopts the ring's copy. That is the same ±90 s a
**Known limit, worth stating when a user asks:** a reading already exported to Health Connect stays
there. The export doesn't retain HC record ids, so there is nothing to delete against.

## Deleting an activity bucket needs the tombstone *and* the day's deficit (issue #70)

`ActivityBucketDeletion` is the tombstone rule applied to intraday step blocks, and it has one more
thing to get right than `MeasurementDeletion` does. **A tombstone guards the history path; today's
number does not come from the history path.** The ring also pushes `PulseEvent.ActivityUpdate`
carrying its own *cumulative* count for the day — seconds apart, and again on every reconnect — and
`EventPersistenceSubscriber.upsertActivityDaily` ratchets the day up against it with `maxOf`. That
counter still includes the deleted block, so a restated 7,000 went back to 8,000 on the next frame:
the delete looked like it worked and then silently undid itself, which is the exact failure the
restate-without-the-ratchet rule exists to prevent.

So the day remembers what it removed. `ActivityDailyEntity.deletedSteps` / `deletedDistanceMeters`
(v27) are subtracted from every later cumulative reading before the ratchet
(`ActivityBucketDeletion.ratchetAgainstRing`, which lives with the deletion rules rather than in the
write path — a reader looking at the ratchet has no reason to suspect a deletion changed what the
ring's counter *means*). They ride the archive for the same reason the measurement tombstones do.
This is also why the feature is only offered on today: it is the only day whose counter is still
moving.

**Calories are dropped, not corrected.** A bucket carries steps and distance and no calorie field,
so there is nothing to subtract from the ring's own daily figure — and that figure demonstrably
counted the block the user removed. The day's `calories` is cleared instead, which makes
`DailyCalorieEstimator.deviceReportedCalories` fall through to the app's own estimate, recomputed
from the surviving buckets at deletion time rather than at the next completed sync. An estimate
consistent with the restated day beats a device figure known to be wrong. `activeMinutes` is not
touched: it is credited by `ActivityRollup` from workouts, not from step buckets.

## A derived metric must say it is derived (issue #67)

`DerivedStress` computes a stress figure from HRV for rings whose hardware never reports one — the
R100 answers neither the stress history query nor its monitor-state read-back (22 sends, 0 replies,
while every other state query on that ring answered), so stress there is absent rather than switched
off. The app advertised it anyway, because a capability list is a static per-family constant and not
something an individual ring confirmed, and the user got a card that could never fill.

**The rule that matters more than the formula: it is labelled wherever it is shown.** The card reads
"Estimated from HRV — your ring doesn't measure stress", and `VitalsState.stressIsDerived` carries
the fact so no future surface can render it as a measurement by accident. A derived figure presented
as measured would be worse than the empty card it replaces — a user comparing it against the vendor
app's number is entitled to know which of the two they are looking at.

It is scored **against that user's own recent HRV**, not a population: HRV varies several-fold
between individuals, so an absolute cutoff labels whole people permanently stressed or permanently
calm. Median and median-absolute-deviation rather than mean and standard deviation, because ring
HRV history is full of obvious outliers and one of them must not redefine the scale. It returns null
below twelve baseline readings rather than a default, for the same reason the battery estimate in
#65 refuses to answer: an unearned number on a health screen is read as a measurement.

Derived stress only fills in where the ring returned **no** stress at all. It never overwrites or
blends with hardware readings.

**"No stress at all" is a question about the ring, so ask it of the whole history.** Gating on the
24 h chart window instead meant an empty window decided it, and windows go empty for ordinary
reasons — the monitor switched off for a day, a ring re-paired this morning, a quiet night. A ring
that does measure stress then showed a derived number captioned "your ring doesn't measure stress",
which is a false statement about that user's hardware. The gate is
`measurementDao().hasReal(STRESS)`.

**Labelled wherever it is shown means the chart too.** The figure reaches three surfaces — the
Vitals card, the Today tile, and the `vitals/stress` detail chart — and the detail screen builds its
own series straight from Room rather than from `VitalsState`, so it needs the derivation wired in
separately (`VitalDetailViewModel.derivedStressIn`) and carries `DetailState.isDerived` into the
same amber disclaimer card BP and glucose use. Two related traps: a derived score has no "0 means
nothing measured" sentinel, so the card's `>= 10` floor must not be applied to it (a genuinely calm
day scores below 10), and the scores skip the first readings for want of a baseline — so they
cannot be zipped positionally onto the HRV series. `DerivedStress.scored` returns each score with
the index of the reading behind it for exactly that reason.

## Diagnostics masking keeps the routing header (issue #58)

`DiagnosticsRedactor.maskPacketHex` masks a health frame's payload but keeps the leading bytes that
Expand Down
126 changes: 126 additions & 0 deletions app/src/main/java/com/pulseloop/data/ActivityBucketDeletion.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.pulseloop.data

import androidx.room.withTransaction
import com.pulseloop.data.dao.MeasurementDeletionDao
import com.pulseloop.data.entity.ActivityBucketEntity
import com.pulseloop.data.entity.ActivityDailyEntity

/**
* Deleting an individual activity bucket (issue #70).
*
* The sibling of [MeasurementDeletion], and it exists for the same reason: a ring logs activity in
* intraday blocks, so a day's step total is the sum of a couple of dozen rows rather than one
* figure, and anything that inflates one of them — the ring carried rather than worn, a rough car
* journey — is stuck in that total for good. The vitals side got a delete in #60; this is the rest
* of the same request.
*
* Three rules make it stick, and all of them live here rather than in any caller:
*
* * **Tombstone it.** `activity_buckets` is keyed by the bucket's start time and upserted, so the
* next sync of that day writes the bucket straight back. `MeasurementDeletionDao.recordActivity`
* remembers it and [com.pulseloop.service.EventPersistenceSubscriber] checks before writing.
* * **Recompute the day without the ratchet.** A day's total is the sum of its buckets, but *today*
* is ratcheted against the existing row, because the live cumulative step count legitimately
* leads the bucket history and a plain recompute would make today's count visibly drop on every
* reconnect. A deletion is the one case where the total must be allowed to fall — ratcheting
* would delete the row and leave the number it contributed sitting in the headline, which reads
* as the delete having silently failed.
* * **Remember what it took with it.** The tombstone only guards the history path. The ring also
* pushes a *live* `ActivityUpdate` carrying its own cumulative count for the day — seconds
* apart, and again on every reconnect — which still includes the deleted block, and
* `EventPersistenceSubscriber.upsertActivityDaily` ratchets the day up against it. So the
* restated total survived for about as long as it took the next frame to arrive, which is the
* same silent failure the rule above exists to prevent. `ActivityDailyEntity.deletedSteps` /
* `deletedDistanceMeters` carry the day's deficit so every later cumulative reading can be
* corrected by it. This is also why the feature is only offered on today: it is the only day a
* live counter is still moving.
*
* **Calories are dropped rather than corrected.** A bucket carries steps and distance and no
* calorie field, so there is no figure to subtract from the ring's own daily total — and that total
* demonstrably includes the block the user removed. The day's device-reported calories are cleared
* instead, which makes [com.pulseloop.service.DailyCalorieEstimator.deviceReportedCalories] fall
* through to the app's own estimate, recomputed here from the buckets that remain. An estimate
* consistent with the restated day beats a device figure known to be wrong.
*/
object ActivityBucketDeletion {

/**
* Delete the bucket starting at [startEpoch], remember it, and restate its day.
*
* Returns true when a bucket was actually removed.
*/
suspend fun delete(db: PulseLoopDatabase, startEpoch: Long): Boolean {
val deleted = db.withTransaction {
val day = com.pulseloop.util.TimeUtil.startOfDayLocal(startEpoch)
val bucket = db.activityBucketDao().byDay(day).firstOrNull { it.startEpoch == startEpoch }
?: return@withTransaction null

db.measurementDeletionDao().recordActivity(listOf(startEpoch))
db.activityBucketDao().deleteByStart(startEpoch)
restateDay(db, day, bucket)
day
}
// Outside the transaction: the estimator reads a day's worth of HR samples and buckets of
// its own, and re-deriving the calorie figure is not part of what must be atomic about the
// deletion. Re-deriving it here rather than leaving it to the next completed sync is the
// point — the headline calories would otherwise keep the deleted block's contribution for
// however long that takes.
if (deleted != null) recomputeCalories(db, deleted)
return deleted != null
}

/**
* Rewrite [day]'s totals as the sum of the buckets it still has, and record what [removed] took
* with it.
*
* Deliberately unconditional — see the class note on the ratchet. A day whose every bucket has
* been deleted keeps its row at zero rather than being removed, so the day still reads as
* "synced, nothing recorded" rather than reverting to whatever a later partial sync writes.
*/
private suspend fun restateDay(db: PulseLoopDatabase, day: Long, removed: ActivityBucketEntity) {
val buckets = db.activityBucketDao().byDay(day)
val existing = db.activityDailyDao().byDay(day)
db.activityDailyDao().upsert(
(existing ?: ActivityDailyEntity(date = day, source = "ring_history")).copy(
steps = buckets.sumOf { it.steps },
distanceMeters = buckets.sumOf { it.distanceMeters },
deletedSteps = (existing?.deletedSteps ?: 0) + removed.steps,
deletedDistanceMeters = (existing?.deletedDistanceMeters ?: 0.0) + removed.distanceMeters,
// The ring's own figure counted the deleted block and can't be corrected for it —
// see the class note. Zero reads as "no device figure" to the estimator.
calories = 0.0,
updatedAt = System.currentTimeMillis(),
)
)
}

private suspend fun recomputeCalories(db: PulseLoopDatabase, day: Long) {
val profile = db.userProfileDao().get() ?: return
com.pulseloop.service.DailyCalorieEstimator.recompute(
day, db,
com.pulseloop.service.DailyCalorieEstimator.Profile(
sex = profile.sex, age = profile.age,
weightKg = profile.weightKg, heightCm = profile.heightCm,
),
)
}

/** The tombstone key for [startEpoch] — exposed so the write path and tests name it one way. */
fun tombstoneId(startEpoch: Long): String = MeasurementDeletionDao.activityBucketId(startEpoch)

/**
* What a day's stored total becomes when the ring pushes a cumulative [ringTotal] for it.
*
* Lives here rather than in [com.pulseloop.service.EventPersistenceSubscriber] because it is
* the deletion rule, not the write path's: a deleted bucket changes what the ring's own counter
* *means* for that day, and a reader who only sees the ratchet has no reason to suspect it.
* [stored] still wins where it leads, so the counter's normal behaviour — climbing ahead of the
* bucket history through the day — is unchanged on a day with nothing deleted.
*/
fun ratchetAgainstRing(stored: Int, ringTotal: Int, deletedSteps: Int): Int =
maxOf(stored, (ringTotal - deletedSteps).coerceAtLeast(0))

/** [ratchetAgainstRing] for distance. */
fun ratchetAgainstRing(stored: Double, ringTotal: Double, deletedMeters: Double): Double =
maxOf(stored, (ringTotal - deletedMeters).coerceAtLeast(0.0))
}
7 changes: 7 additions & 0 deletions app/src/main/java/com/pulseloop/data/DataArchive.kt
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ data class PulseArchive(
val distanceMeters: Double = 0.0, val activeMinutes: Int = 0,
val source: String = "mock", val syncedAt: Long? = null,
val createdAt: Long, val updatedAt: Long,
/** Issue #70. Rides the archive for the same reason the tombstones do: a restore wipes every
* table first, and without the deficit the ring's cumulative counter would walk the deleted
* buckets back into the day. */
val deletedSteps: Int = 0, val deletedDistanceMeters: Double = 0.0,
val estimatedActiveCalories: Double? = null,
)

Expand Down Expand Up @@ -138,6 +142,9 @@ data class PulseArchive(
@Serializable data class SleepStageBlockDTO(
val id: String, val sessionId: String, val startAt: Long, val startMinute: Int,
val durationMinutes: Int, val stageRaw: String,
/** Issue #68. 0 in an archive written before the column existed, which is the same "unknown"
* the migration backfills — [com.pulseloop.service.sleepRecordRuns] falls back for those. */
val recordStartAt: Long = 0L,
)

@Serializable data class CoachConversationDTO(
Expand Down
8 changes: 6 additions & 2 deletions app/src/main/java/com/pulseloop/data/DataArchiveService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ object DataArchiveService {
syncedAt = c.longOrNull("syncedAt"), createdAt = c.long("createdAt"),
updatedAt = c.long("updatedAt"),
estimatedActiveCalories = c.dblOrNull("estimatedActiveCalories"),
deletedSteps = c.int_("deletedSteps"),
deletedDistanceMeters = c.dbl("deletedDistanceMeters"),
)
},
activityBuckets = collect("activity_buckets") { c ->
Expand Down Expand Up @@ -167,7 +169,7 @@ object DataArchiveService {
SleepStageBlockDTO(
id = c.str("id"), sessionId = c.str("sessionId"), startAt = c.long("startAt"),
startMinute = c.int_("startMinute"), durationMinutes = c.int_("durationMinutes"),
stageRaw = c.str("stageRaw"),
stageRaw = c.str("stageRaw"), recordStartAt = c.long("recordStartAt"),
)
},
coachConversations = collect("coach_conversations") { c ->
Expand Down Expand Up @@ -395,6 +397,8 @@ object DataArchiveService {
source = a.source, syncedAt = a.syncedAt, createdAt = a.createdAt,
updatedAt = a.updatedAt,
estimatedActiveCalories = a.estimatedActiveCalories,
deletedSteps = a.deletedSteps,
deletedDistanceMeters = a.deletedDistanceMeters,
))
}
for (b in archive.activityBuckets) {
Expand Down Expand Up @@ -476,7 +480,7 @@ object DataArchiveService {
SleepStageBlockEntity(
id = block.id, sessionId = block.sessionId, startAt = block.startAt,
startMinute = block.startMinute, durationMinutes = block.durationMinutes,
stageRaw = block.stageRaw,
stageRaw = block.stageRaw, recordStartAt = block.recordStartAt,
)
}.groupBy { it.sessionId }
for (ss in archive.sleepSessions) {
Expand Down
Loading