NOOP import: recover the band step counter after schema drift (#160) - #176
Conversation
NOOP shipped a schema change that added a `steps` stream and inserted a `band_sleep_state` column at index 15, shifting event_kind/event_payload to 16/17. Reading columns by NAME meant nothing misparsed — but `steps` fell into the importer's default branch, so every imported day reported 0 steps while the band's own counter had measured them. The real export attached to #160 carries 2,572 steps over its 3.5 h of data; all of them were being dropped. The counter is now differenced into contiguous runs and banked into `live_coverage`, the same table the live 100 Hz pedometer writes. That seam needs no change to Substrate or the derivation engine: `liveStepsForDay` picks up the real count and `coverageWindowsOverlapping` keeps the 1 Hz estimate from double-counting those minutes, so imported and live days are counted identically. `stepRuns` is pure and unit-tested: - splits on gaps > 60 s, so the export's 20.5 h hole never becomes one window claiming to cover the day - sums only POSITIVE deltas (a negative delta is a reboot reset, not -24,000 steps) - drops deltas ACROSS a run boundary rather than attributing steps to a window we have no samples for - emits nothing for a 0-step run, which would suppress a real 1 Hz estimate while contributing nothing `_flushStepCoverage` is idempotent via `hasLiveCoverageWindow` — `live_coverage` is an append-only SUM with no uniqueness on the window, so re-importing the same export would otherwise double every step. Deliberately NOT consumed, and documented in the file header: `band_sleep_state` (constant 0 across all 12,663 rows in the real export, and a second sleep source would contradict segmentSleep) and `ppghr` (151 samples where `hr` already covers every second; PPG-derived HR is not trusted as a substitute). Also noted there: `spo2` and `resp` rows are no longer emitted at all, though their columns survive in the header. kAlgoVersion 53 -> 54. Only the steps/active_min block of IMPORTED days changes; no live-sync output moves. The bump does not retro-fix an existing import — imported days are force-finalized snapshots with no stored raw to recompute from, so an already-imported day needs a re-import. Adds test/noop_schema_drift_test.dart (11 tests) pinning the CURRENT header end to end, including re-import idempotency, an export with no `steps` stream, and an unknown future stream. There was previously no test that parsed a real NOOP CSV at all — only the pure decideRow ordering contract — which is why the drift shipped unnoticed. Does NOT address the FormatException in #160 itself: that is a .noopbak (a ZIP) fed to the CSV importer, whose DOS mod-time low byte at offset 10 fails UTF-8 decoding byte-for-byte as reported. Tracked separately.
|
Warning Review limit reached
Next review available in: 39 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThe NOOP importer now reads cumulative step counters, converts valid counter runs into real-step coverage, and persists them idempotently before derivation. Schema handling and regression tests cover reordered columns, missing streams, unknown streams, resets, gaps, and repeated imports. ChangesNOOP real-step import
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant NOOPCSV
participant NoopImporter
participant live_coverage
participant DerivationEngine
NOOPCSV->>NoopImporter: Provide steps rows
NoopImporter->>NoopImporter: Build contiguous StepRun values
NoopImporter->>live_coverage: Persist new step windows
NoopImporter->>DerivationEngine: Derive each imported date
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
PR Reviewer Guide 🔍(Review updated until commit b0d3526)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to b0d3526 Explore these optional code suggestions:
Previous suggestionsSuggestions up to commit 533ada3
Suggestions up to commit c16f992
|
An exact-window idempotency check only defends a byte-identical re-import. The realistic case defeats it: the user exports again later over a LONGER span covering the same session (09:00-09:20, then 09:00-09:40). The run boundary moves, no exact window matches, and the whole overlap is banked a second time. Measured before this change: two such imports banked 3,598 steps against a true 2,399 — a 50% inflation of the day's step count. `stepRuns` now takes the spans already present in `live_coverage` and skips any per-second delta whose interval intersects one, breaking the run at that point. Clipping is exact rather than pro-rated — the counter value is held at every second, so the uncovered sub-intervals are summed from real deltas. The exact window check is dropped, since covered-clipping strictly subsumes it (an identical re-import is fully covered and yields no runs). This also stops an imported span from double-counting against a LIVE 100 Hz pedometer window, which shares the same table. Verified on the real #160 export: still 2,572 steps on first import, 0 on re-import, one window, total equal to truth. Adds three pure `stepRuns` cases (covered prefix, fully covered, covered gap splitting into two runs) and an end-to-end overlapping-re-export regression pinning 2,399 rather than 3,598. Found by PR Agent review on #176. Its stated premise was wrong — `hasLiveCoverageWindow` is an exact match, not the range check it hypothesised — but the failure it predicted from that premise turned out to be real for a different reason, and reproduced on first attempt.
|
Went through every bot finding against the actual code rather than taking them at face value. One was real and is now fixed in ✅ Real — and worse than described (
|
|
Persistent review updated to latest commit 533ada3 |
Covered-clipping is keyed by TIME SPAN rather than by an exact window row, which makes the flush recoverable rather than atomic: a run interrupted before it landed is not covered, so the next import banks it — and only it. Verified by deleting a written run to simulate a crash between two runs, then re-importing: exactly the missing 599 steps come back, total returns to 1,198, no double-count. This is the property that makes a transaction around the flush unnecessary, so it is worth a test rather than a comment.
|
Follow-up on the post- ❌ "Make step coverage flush atomic via a transaction"The suggested API doesn't exist. The failure mode doesn't happen either. The claim is that an interrupted flush leaves That inverts how the clipping works. Runs are disjoint time spans. A run that never landed is, by definition, not covered — so the next import banks it. Tested by writing a two-run import, deleting the second row to simulate a crash between runs, then re-importing: Not just "not lost" — recovered exactly, with no double-count, because the surviving run is still covered and gets clipped. This is a direct consequence of the What I did take from itThe suggestion was wrong, but "what happens if the flush is interrupted?" is a fair question that the code only answered implicitly. Scoreboard across both bot passes: 1 real finding (the overlap double-count — genuinely valuable, would have hit users), 4 false, 1 acknowledged-but-not-worth-acting-on. The real one is fixed and the false ones are documented above rather than silently dropped. 17 tests in |
|
Persistent review updated to latest commit b0d3526 |
User description
Investigating #160 turned up three separate problems. This PR fixes one of them, and documents the other two rather than silently leaving them implied.
1. The reported crash is a
.noopbak, not a CSV — NOT fixed here.noopbakis a ZIP (wrapping SQLite). A ZIP local file header puts the DOS mod-time low byte at offset 10, which is ≥0x80in most files. Reproduced byte-exactly:That is character-for-character the
FormatException: Invalid UTF-8 byte (at offset 10)in the issue.import_screen.dartpicksFileType.anyandnoop_import.dartpipes it straight intoutf8.decoder, so any non-text file becomes a raw decoder exception. @MrUphill diagnosed this correctly in the thread.Deliberately out of scope here — it wants a magic-byte sniff plus a human-readable error (and arguably real
.noopbaksupport), which is a separate change from the data fix below.2. The schema drift — what this PR fixes
NOOP's current export (9.1/9.2):
band_sleep_statewas inserted at index 15, shiftingevent_kind/event_payloadto 16/17. Reading by NAME meant nothing misparsed — the existing defensive design held.steps,band_sleep_state,ppghr.spo2andresprows are no longer emitted at all, though their columns survive in the header.The real loss was
stepslanding in the importer'sdefault:branch. Every imported day reported 0 steps while the band's own counter had measured them — 2,572 over the 3.5 h in the export attached to the issue.The fix
The cumulative counter is differenced into contiguous runs and banked into
live_coverage— the same table the live 100 Hz pedometer writes. That seam needs no change toSubstrateor the derivation engine:liveStepsForDaypicks up the real count, andcoverageWindowsOverlappingkeeps the 1 Hz estimate from double-counting those minutes. Imported and live days are counted identically.stepRunsis pure and unit-tested:_flushStepCoverageis idempotent viahasLiveCoverageWindow:live_coverageis an append-only SUM with no uniqueness on the window, so re-importing the same export would otherwise double every step.Verified against the real file from the issue:
2,572 matches the counter range (24302 → 26874) measured independently, and the window covers only the 3.5 h block, not the hole.
Deliberately not consumed
band_sleep_state0across all 12,663 rowssegmentSleepppghrhralready covers all 12,692 seconds; PPG-derived HR isn't trusted as a substituteBoth are documented in the file header with the reasoning, so the next reader doesn't have to re-derive it.
3. Why the data still looks thin — no code change
Worth stating plainly for the reporters: the CSV was importing fine all along (
days=2 rows=66210 lateRows=0). It just doesn't contain much:One 3.5 h daytime block, then a 20.5 h hole. No overnight ⇒ no sleep ⇒ RMSSD/readiness/strain are legitimately null. That matches @Jenssaibe's "it is only the last 24 hours" — a NOOP export-scope limitation, not an edge bug. No warning UI added; the import reports honestly and the metrics abstain rather than fabricate.
Version
kAlgoVersion53 → 54. Only thesteps/active_minblock of imported days changes; no live-sync output moves.Tests
test/noop_schema_drift_test.dart— 11 tests. Seven purestepRunscases (counter reset, gap split, cross-boundary non-attribution, zero-step run, order independence) and four end-to-end against the current header, including re-import idempotency, an export with nostepsstream, and an unknown future stream.There was previously no test that parsed a real NOOP CSV at all — only the pure
decideRowordering contract. That is precisely why this drift shipped unnoticed, and the end-to-end cases are the part that would have caught it.Full suite: 1099 passing,
flutter analyzeclean.Note for reviewers
One cosmetic honesty gap left alone: an imported day's steps block reports
inputs_used: ["live_100hz_pedometer", ...]and the note "real 100 Hz count only". Accurate as "the real-count channel", inaccurate as provenance for a NOOP import. Fixing it means threading a source flag through_stepsAndEnergy/_DeriveInput, which felt like a poor trade against touching the shared derive path in this PR. Happy to do it if you'd rather.🤖 Generated with Claude Code
PR Type
Bug fix, Tests
Description
NOOP CSV importer now recovers real step counts from the
step_countercolumn instead of silently dropping them (2,572+ steps lost per import in issue Encoding error when important data from NOOP #160).stepRuns()differences the cumulative counter into contiguous windows, splits on gaps >60 s, ignores negative deltas (reboots), and banks results intolive_coverage— idempotent viahasLiveCoverageWindow.kAlgoVersionbumped from 53 → 54; only imported days'steps/active_minblock changes; no live-sync output moves.New regression test file pins the schema-drift fix,
stepRunspure logic, idempotent re-import, and graceful handling of unknown future streams.Diagram Walkthrough
File Walkthrough
derivation_engine.dart
Bump kAlgoVersion to 54 for NOOP step recoverylib/compute/derivation_engine.dart
kAlgoVersionfrom 53 to 54.steps/active_minblock changes; no live-sync output moves.steps (no stored raw to recompute from).
noop_import.dart
Recover real step counts from NOOP step_counter streamlib/import/noop_import.dart
band_sleep_statecolumn at index 15, new streamssteps/band_sleep_state/ppghr, absentspo2/resprows) and explains whatis consumed vs. deliberately ignored.
StepRunvalue class andstepRuns()pure static method thatdifferences the cumulative
step_counterinto contiguous windows,splits on gaps >60 s, ignores negative deltas (counter resets), and
emits nothing for zero-step runs.
_flushStepCoverage()which banksStepRuns intolive_coverageidempotently via
hasLiveCoverageWindow, returning the steps actuallybanked.
stepsByDatemap), flushes before each
deriveAndPrunecall, and handles the EOF andsteps-only-date edge cases; adds
stepsfield toNoopImportResult.noop_schema_drift_test.dart
Add regression tests for NOOP schema drift and step recoverytest/noop_schema_drift_test.dart
fix.
stepRuns(): positive deltas, gap splitting,counter reset, cross-boundary attribution, zero-step run,
empty/single-sample input, and order-independence.
banked correctly, re-import is idempotent (no double-counting),
shifted
event_kind/event_payloadcolumns do not misparse, missingstepsstream imports cleanly with 0 steps, and unknown future streamsare skipped without error.
Summary by CodeRabbit
New Features
Bug Fixes