Files
samplez/rippr-src/docs/v2/07-aggregates.md
uhryniuk 280fd7f988 Add Rippr source snapshot and full-history bundle
Two copies for two jobs. rippr-src/ is a browsable git archive export of
the tracked tree at 2f76983 - no build outputs, no local.properties, no
nested .git - which is convenient to read in gitea but carries no history
and will drift.

rippr-full-history.bundle is the real backup: all 18 commits, verified as
"records a complete history" and test-cloned before committing. This
matters because ~/dojo/rippr has no git remote and otherwise exists only
on one machine.

rippr-src/SNAPSHOT.md explains the difference and how to restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 08:30:25 -05:00

114 lines
5.0 KiB
Markdown

# T07 — Live aggregate accumulation
**Phase** 3 · **Depends on** T04, T06 · **Status** Done
## Goal
Keep distance, moving time, elevation gain, and max speed current on the active `Trip`
row while recording, so the Record screen shows live numbers without rescanning the
point table. Done when those values update during a ride and are reconciled exactly on
completion.
## Context
v1's `observeStats()` recomputes aggregates in SQL on every emission. That worked for
`MAX`, `AVG`, and `COUNT` over one table, but v2 needs **distance** and **elevation
gain**, which require consecutive-row differences.
**SQLite 3.18 on minSdk 26 has no window functions** — no `LAG`, no `OVER`. There is no
SQL expression for "distance from the previous point". So the maths moves into Kotlin,
into the writer loop that is already touching every point as it lands.
`TrackingService` already batches writes every ~2 s (`FLUSH_INTERVAL_MS = 2000`,
`FLUSH_SIZE = 25`), so one extra `UPDATE trips …` per flush is negligible — roughly one
write every two seconds against a table with a single active row.
## Design
The writer loop keeps the **last point of the previous batch** in memory as the anchor
for cross-batch distance. Without it, distance resets at every flush boundary and
under-reports by roughly one inter-point hop per 25 points.
**Reuse `ElevationAccumulator` from T05 verbatim.** It is already streaming (fed one
altitude at a time) precisely so the recorder can share it. Do **not** reimplement the
hysteresis here — a naive version measured 1498 m of phantom climbing on a parked bike.
Remember to call `finish()` before persisting final values.
Implemented as `com.rippr.Accumulator` in `RideAccumulator.kt` rather than a private
class inside the service, so it is unit-testable without a device.
```kotlin
private data class Accumulator(
var lastPoint: TrackPoint? = null,
var distanceM: Double = 0.0,
var movingMillis: Long = 0,
var maxSpeedKmh: Float = 0f,
var elevationGainM: Double = 0.0,
var climbBuffer: Double = 0.0, // hysteresis state, see T05
var pointCount: Int = 0,
)
```
Rules, matching T05 exactly:
- Distance accumulates only **within** a segment. On resume, `lastPoint` resets to null
so the pause gap contributes nothing.
- Moving time accumulates `dt` only where `speedKmh >= Telemetry.SPEED_NOISE_FLOOR_KMH`,
with `dt` capped (10 s) so a tunnel dropout cannot inject phantom movement.
- Elevation gain uses the same 3 m hysteresis.
### Reconciliation on completion
Incremental accumulation can drift — a process kill mid-ride loses the in-memory
accumulator, and the restarted service resumes from zero while the DB row holds a stale
partial. So on `ACTION_STOP`, after the final drain, **recompute authoritatively** with
`RideStatistics.compute()` over all stored points and overwrite the row.
That is why T05 and T07 must agree: the live number is an estimate, the finished number
is computed by T05's code. Using the same functions for both keeps them consistent by
construction rather than by discipline.
## Implementation
1. Add `Accumulator` to `TrackingService`, owned by the writer coroutine.
2. After each batch insert, fold the batch into the accumulator and `UPDATE` the trip row
in the same transaction as the point insert — so a crash cannot commit points without
their aggregate contribution.
3. Reset `lastPoint` on segment change (pause/resume).
4. On restart-with-active-trip, seed the accumulator from the persisted trip row and set
`lastPoint` to the last stored point of the open segment.
5. On stop, recompute with `RideStatistics.compute()` and overwrite.
## Acceptance criteria
- [x] Distance updates live during recording
- [x] Distance does not jump across a pause
- [x] Distance is correct across flush boundaries (no per-batch reset)
- [x] Stationary noisy-altitude recording yields ~0 m elevation gain
- [x] Post-stop values exactly match `RideStatistics.compute()` over the stored points
- [x] Restart mid-ride resumes accumulation instead of restarting from zero
## Tests
Instrumented:
- Insert 100 synthetic points across four flushes; assert accumulated distance equals the
batch computation over the same points (the cross-batch anchor regression test)
- Pause/resume with a 5 km jump between segments; assert the gap is excluded
- Simulate restart: seed accumulator from DB, continue, assert continuity
Emulator: feed a synthetic ride and compare the live displayed distance against the
post-stop recomputation — they should differ by nothing.
## Risks / gotchas
- **The cross-batch anchor is the subtle bug here.** Losing `lastPoint` between flushes
silently under-reports distance by a few percent, which is exactly the kind of error
nobody notices until they compare against a bike odometer.
- **Same transaction as the insert.** Points and their aggregate contribution must commit
atomically or a crash leaves them disagreeing.
- **Do not let the accumulator's definitions drift from T05.** Share constants; do not
copy magic numbers.
## Out of scope
Displaying any of this (T09, T11).