Skip to content

fix(gen5): SET_CLOCK revision byte + honest v18 decode - #188

Open
Brackyt wants to merge 9 commits into
OpenStrap:feat/multiband-whoop5from
Brackyt:fix/whoop5-v18-lenient-decode
Open

fix(gen5): SET_CLOCK revision byte + honest v18 decode#188
Brackyt wants to merge 9 commits into
OpenStrap:feat/multiband-whoop5from
Brackyt:fix/whoop5-v18-lenient-decode

Conversation

@Brackyt

@Brackyt Brackyt commented Aug 4, 2026

Copy link
Copy Markdown

Summary

Hardware bring-up against a real WHOOP 5 (fw 50.40.1.0, Android) from discussion #178.

Scope: v18 decode + clock + db persistence only. PPG/v26 ingest is deferred to draft follow-up PRs (analytics #37 + edge #190) — real-hardware validation of the new PPG algorithm failed (see below).

What landed (WHOOP 4 clock path unchanged):

  • Gen5 SET_CLOCK / GET_CLOCK prepend revision1 so the strap no longer treats the epoch low byte as the command revision (Invalid revision on console).
  • Lenient v18 decode recovers HR when gravity/dyn gates fail, but only with a plausible unix@7 — no misaligned unix@6 fallback.
  • decoded_onehz persistence fix: gen5 v18 samples now fall back to the BLE-preferred Sample when R24 decode fails (excluding gen4 R10-lite hr-only records). This was the root cause of empty substrate on device.
  • Unix gate aligned with RecordGate: gen5V18UnixFromInner now uses isPlausibleUnix(unix, wallNow) — implausible timestamps abstain at decode → raw_archive, not silent post-decode drop.
  • Analytics pin stays cbbe06a on OpenStrap/analytics; pubspec.lock is git-sourced (no path: override).

Hardware evidence (fw 50.40.1.0, ~12.3 min wear)

Table Before After
decoded_onehz 9 741 (density 1.0, continuous)
decoded_rr 0 167

No sleep/readiness claim from ~14 minutes of wear. Lenient v18 null acceleration is currently stored as (0,0,0) in decoded_onehz — correcting that requires an absence representation, derivation handling, and an algo-version bump (tracked separately; this PR does not claim sleep/motion correctness).

Deferred (draft research PRs — do not merge)

Test plan

  • flutter test test/gen5_v18_hardware_lenient_test.dart test/gen5_decoded_onehz_persistence_test.dart test/gen5_sample_mapping_test.dart
  • dart analyze on changed files
  • pubspec.lockopenstrap_analytics is source: git + OpenStrap URL + cbbe06a
  • On-device: decoded_onehz 9→741, decoded_rr 0→167 over ~12.3 min
  • On-device: SET_CLOCK logs 9B, rev=1; reconnect showed Clock correlated … drift=0
  • Fresh install / reconnect on another WHOOP 5: confirm no Invalid revision for 0x92 after HELLO
  • Confirm WHOOP 4 still SET_CLOCKs with the 8-byte body (no revision prefix)

Summary by CodeRabbit

  • New Features

    • Improved Gen5 v18 clock synchronization with generation-specific clock handling.
    • Added support for recovering historical heart-rate and RR interval data when some sensor readings are incomplete.
    • Historical timestamps are now validated for plausibility before records are accepted.
  • Bug Fixes

    • Improved handling of malformed or unsupported one-second records.
    • Prevented invalid gravity data from affecting otherwise usable samples.
    • Preserved compatibility with existing Gen4 historical records and storage behavior.

Brackyt added 2 commits August 4, 2026 19:19
Protocol's strict gravity gate rejected every v18 record in a hardware
export (fw 50.40.1.0) while v20/v26 deep buffers dominated offload. Recover
HR/RR via a hardware fallback with alternate unix offset and honest absent
accel when gravity fails validation.
Drop the misaligned unix@6 fallback that could invent timestamps, and
prepend revision1 on gen5 SET_CLOCK/GET_CLOCK only (WHOOP 4 path unchanged).
Hardware showed Invalid revision when body[0] was the epoch low byte; a
later connect with the 9-byte form correlated with drift=0.
Copilot AI lite review requested due to automatic review settings August 4, 2026 18:39
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a02bfd8b-37f3-4066-b79b-536b40627b9a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Gen5 v18 historical records now support lenient timestamp, heart-rate, RR, and gravity decoding. Gen5 clock commands use revision-prefixed payloads. One-Hz persistence rejects unsupported R10-lite records and preserves valid Gen4 and Gen5 samples.

Changes

Gen5 v18 BLE and persistence support

Layer / File(s) Summary
Gen5 v18 historical decoding
lib/ble/ble_engine.dart, test/gen5_v18_hardware_lenient_test.dart
The BLE engine adds strict-then-lenient Gen5 v18 decoding, plausible timestamp extraction, conditional gravity decoding, and shared wall-clock handling. Tests cover invalid timestamps, invalid gravity, and recovery of heart-rate and RR values.
Generation-specific clock commands
lib/ble/ble_engine.dart, test/gen5_v18_hardware_lenient_test.dart
Gen5 clock commands now use revision-prefixed payloads and generation-specific opcodes. Gen4 payload behavior remains unchanged.
One-Hz decoding and persistence
lib/data/db.dart, test/gen5_decoded_onehz_persistence_test.dart
One-Hz decoding defensively parses raw hex, excludes Gen4 R10-lite records, uses firmware-aware decoding, and preserves valid preferred samples as fallback. Persistence tests cover Gen5 RR data, absent acceleration, R10-lite exclusion, and Gen4 R24 compatibility.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Gen5HistoricalIngestion
  participant decodeGen5HistoricalSample
  participant StrictDecoder
  participant sampleFromGen5V18Lenient
  Gen5HistoricalIngestion->>decodeGen5HistoricalSample: historical record and wall-clock timestamp
  decodeGen5HistoricalSample->>StrictDecoder: strict historical decode
  StrictDecoder-->>decodeGen5HistoricalSample: sample or failure
  decodeGen5HistoricalSample->>sampleFromGen5V18Lenient: revision 18 fallback
  sampleFromGen5V18Lenient-->>Gen5HistoricalIngestion: lenient sample or null
Loading

Possibly related PRs

  • OpenStrap/edge#97: Modifies the same BLE engine areas for Gen5 historical decoding and clock handling.
  • OpenStrap/edge#158: Modifies BLE historical offload and clock handling.

Suggested labels: Review effort 3/5

Suggested reviewers: abdulsaheel, dannymcc, localhoop

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes to Gen5 clock payloads and v18 decoding.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates the Gen5 (WHOOP 5) BLE clock and v18 historical decode paths in edge’s BLE ingestion layer to match observed hardware behavior and avoid timestamp fabrication, while keeping Gen4 behavior unchanged.

Changes:

  • Add Gen5 SET_CLOCK / GET_CLOCK payload builders that prepend revision1, and use them in BleEngine.setClock() / getClock().
  • Add a “honest” lenient v18 historical decode path that only recovers HR/RR when unix@7 is plausible, and never falls back to a misaligned timestamp read.
  • Add unit tests covering the revision-prefixed clock payloads and the lenient v18 decode behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
lib/ble/ble_engine.dart Adds Gen5 clock revision-prefixed payloads and a lenient-but-honest v18 decode fallback, wired into Gen5 historical ingestion.
test/gen5_v18_hardware_lenient_test.dart Adds tests pinning the Gen5 clock payload revision byte and v18 timestamp/lenient-decode behavior against captured evidence.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/ble/ble_engine.dart`:
- Around line 87-90: Replace the fixed 2030 upper bound in gen5V18UnixPlausible
with a rolling wall-time-aware future limit while preserving the existing lower
bound. Add or update boundary coverage to verify the current-time-based cutoff
without relying on a date that will become invalid after January 1, 2030.

In `@test/gen5_v18_hardware_lenient_test.dart`:
- Around line 65-91: Update the success fixture in the test around
sampleFromGen5V18Lenient to exercise production ingestion through
decodeGen5HistoricalSample instead, asserting the expected recovered sample. Set
a nonzero valid RR interval, and adjust the sensor values so the fixture is
rejected specifically by the dynamic-acceleration gate while remaining otherwise
valid; preserve the expected timestamp, heart rate, and null acceleration
assertions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b4208874-445f-4946-a211-acddbd916641

📥 Commits

Reviewing files that changed from the base of the PR and between 82b094c and 638c16d.

📒 Files selected for processing (2)
  • lib/ble/ble_engine.dart
  • test/gen5_v18_hardware_lenient_test.dart

Comment thread lib/ble/ble_engine.dart Outdated
Comment thread test/gen5_v18_hardware_lenient_test.dart Outdated
@abdulsaheel

Copy link
Copy Markdown
Collaborator

@Brackyt Nice work on the hardware bring-up — the v18 unix@7 call and the clock fix are both solid. Couple of things before this can land:

Could you move the v26 PPG→HR into its own PR? The v18 + clock commits were clean and green on their own, and this needs an analytics PR first anyway — f7f6842d doesn't exist on OpenStrap/analytics and deriveHrFromGen5PpgWaveform has 0 hits org-wide, so nothing builds right now.

Build:

  • pubspec.lock got rewritten to path: "../analytics" by pubspec_overrides.yaml. CI catches it, fix is in the error message.

For the PPG→HR PR, when you get to it:

  • Gen5PpgBurstBuffer.add() never checks bursts are adjacent in time — unix and burstIndex are right there.
  • 4 bursts = 4s. Under 4 beats at the 40–55 bpm you're targeting.
  • Derived HR can overwrite a measured v18 row in decoded_onehz (counter-PK + UNIQUE(rec_ts) REPLACE, and _counterFromInner u32-reads v26 into the same key space). _queueOrphanGuard takes that row's RR beats with it. No provenance column either.
  • v26 records stop reaching raw_archive.
  • The two "maps to null" tests got deleted.
  • ACF test is a noiseless contiguous 120 bpm sine.

Still open from last round:

  • db.dart untouched — _decodeOneHzSample's hasDecodedOneHz check sends every gen5 sample through the gen4 R24 decoder, returns null, writes nothing. Verified against the real v18 fixture: decodes fine, zero rows. So v52's "once v26-backed onehz rows land" can't happen. Worth fixing first.
  • Lenient v18 records skip raw_archive and land in samples, which gets pruned.
  • gen5V18UnixPlausible's 2020–2030 window is wider than isPlausibleUnix — anything past tomorrow decodes, then RecordGate drops it, so it's neither stored nor archived.

Happy to help with any of these if useful.

Gen5 v18/lenient samples lack gen4 optics so R24 decode fails and
decoded_onehz stayed empty — fall back to the BLE-preferred Sample
when tsEpoch > 0, excluding gen4 R10-lite hr-only records.

Align gen5V18UnixFromInner with RecordGate's isPlausibleUnix(wallNow)
so implausible timestamps archive instead of silently dropping after
decode. Restore git-sourced analytics lock (cbbe06a).
@Brackyt
Brackyt force-pushed the fix/whoop5-v18-lenient-decode branch from d692d07 to 0ab4e4a Compare August 4, 2026 19:58
@Brackyt

Brackyt commented Aug 4, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough review — agreed on the split. Updated the branch:

This PR (#188) now contains only:

  • SET_CLOCK revision byte + honest v18 unix@7
  • Lenient v18 decode (gravity gate bypass with good unix)
  • _decodeOneHzSample fix — gen5 v18 samples now persist to decoded_onehz via preferred-sample fallback (R10-lite hr-only still excluded). Regression tests in test/gen5_decoded_onehz_persistence_test.dart.
  • Unix gate alignedgen5V18UnixFromInner uses isPlausibleUnix(unix, wallNow) (same floor/ceiling as RecordGate); implausible → null sample → raw_archive.
  • Analytics pin back to cbbe06a; lockfile regenerated without pubspec_overrides.yaml (source: git, not path:).

Stripped from this branch (was 0268c44 / d692d07):

  • v26 PPG→HR ingest, algo v52 bump, unreachable f7f6842 analytics pin

Follow-up PRs (opening separately):

  1. OpenStrap/analyticsderiveHrFromGen5PpgWaveform: raise min window for low HR (≥4 beats), expand ACF tests (noisy sine, low-HR, flatline)
  2. OpenStrap/edge — v26 burst buffer with adjacency checks, u16@3 counter for v26, always archive v26, never clobber measured v18 at same rec_ts, restore v20/v21 null-mapping tests

Will note in the edge PPG PR that analytics must merge first (pin to OpenStrap/analytics SHA — CI won't resolve until then).

Happy to iterate on any of the deferred items.

@Brackyt

Brackyt commented Aug 4, 2026

Copy link
Copy Markdown
Author

I kinda let my AI agent do the 3 MRs and didn't have time to check it all. Might have some issue. I'm currently testing all of it and fix issues I find

Brackyt added 4 commits August 5, 2026 00:35
Parse raw hex and gate Gen4 R10-lite records before accepting a complete
preferred sample into decoded_onehz. Exercise lenient v18 recovery through
decodeGen5HistoricalSample with HR+RR in hardware tests.
Cover strict dynamic-accel rejection with valid gravity through the
production decodeGen5HistoricalSample path.
Strict parseGen5Historical accepts high dynamic accel when gravity is
valid; lenient fallback is exercised via the gravity-gate case only.
Prove _decodeOneHzSample rejects Gen4 R10-lite bytes before the
hasDecodedOneHz early return, not only when preferred is HR-only.
@Brackyt

Brackyt commented Aug 4, 2026

Copy link
Copy Markdown
Author

So the v26 PPG stuff was created because I thought the whoop wasn't giving usable data. That was dumb. Either way, we have these 2 other MRs that could be revisited later if needed (I don't think so, but i kept them in draft in case).

I'll test tonight if the v18 stuff works great for sleep/readiness and give results.
The current implementation is correct, it just needs real testing.

Known remaining areas:

  • Steps aren’t wired
  • On strap alarm not working
  • Missing acceleration is stored as zero, potentially affecting sleep/motion
  • Overnight sleep/readiness still needs real validation
  • v20/v21 raw buffers remain archived, not interpreted
  • RR coverage may or may not be sufficient overnight

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/gen5_decoded_onehz_persistence_test.dart`:
- Line 46: Update the assignment to inner[15] so the clamped rrMs.length value
is explicitly converted to an int before assigning it as a byte.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ee64f30b-3974-42d8-9a64-50e030304c77

📥 Commits

Reviewing files that changed from the base of the PR and between 638c16d and ab8a386.

📒 Files selected for processing (4)
  • lib/ble/ble_engine.dart
  • lib/data/db.dart
  • test/gen5_decoded_onehz_persistence_test.dart
  • test/gen5_v18_hardware_lenient_test.dart

Comment thread test/gen5_decoded_onehz_persistence_test.dart Outdated
R10-lite reject must parse hex first, but hexToBytes failure should fall
through to preferred/hasDecodedOneHz — not return null. Restores
decoded_onehz for fixture inserts with placeholder hex. Also fix
clamp→int in the gen5 persistence helper.
@Brackyt

Brackyt commented Aug 5, 2026

Copy link
Copy Markdown
Author

CI fix pushed: 8e36c3a

All 14 test job failures were one regression from the R10-lite ordering change: invalid/placeholder hex aborted _decodeOneHzSample before preferred Sample, so fixture inserts wrote 0 decoded_onehz rows.

  • R10-lite still rejected when hex parses
  • Preferred / gen5 v18 fallback restored when hex is unparseable
  • CodeRabbit clamp.toInt() addressed in the same commit

Gen5 suites were already green; the failing suites now pass locally. Waiting on Actions re-run.

P0. `sampleFromGen5V18Lenient` correctly abstains on a gravity vector that
fails the magnitude gate (ax/ay/az stay null) while keeping HR/RR -- but
`decoded_onehz.ax/ay/az` are REAL NOT NULL, so `_queueDecodedOneHz` writes
`decoded.ax ?? 0` and the substrate loader reads `?? 0` back. "We did not
measure this" becomes "the wrist was at exactly (0,0,0)".

That is not an inert default. `zAngle(0,0,0)` is exactly 0.0 in Dart --
atan2(0,0) is 0.0, not NaN -- so a run of absent seconds has a perfectly
CONSTANT z-angle, which is the van Hees immobility criterion satisfied
maximally. Measured against the pinned analytics: 8 h of (0,0,0) scores 28501
immobile seconds and `vanHeesSleepWindow.present == true`, i.e. a fabricated
~7.9 h night, fully staged, out of data that does not exist. This PR's own
commit message notes the strict gate rejected EVERY v18 record on fw
50.40.1.0, so for that firmware it is the ordinary case, not a corner.

Exact (0,0,0) is an unambiguous ABSENT marker rather than a reading: gravity
always has magnitude ~1 g and every decoder that emits a vector gates on
`magSq >= 0.25`. So `Substrate.accelPresentAt` / `accelPresentFraction` can
recover the distinction the NOT NULL column erased, with no schema change --
and it heals rows already persisted.

TWO OTHER SENTINELS WERE TRIED AND REJECTED, both measured rather than assumed:

  * NaN fails OPEN. The rule asks "did the angle change by >= threshold", and
    every comparison against NaN is false, so it never trips -- NaN scores the
    SAME 28501 immobile seconds as zeros. Pinned in a test so nobody "fixes"
    this that way later.
  * Omitting the seconds does not work either: `immobilityMask` is a pure
    index-wise angle rule with no timestamp/gap awareness (unlike `nap.dart`'s
    `stillAt`, which does check `absAt(k) - absAt(k-1) == 1`), so it just joins
    across the hole.

Since analytics has no validity input to be told any of this, the honest move
at this layer is not to let absent accel ANCHOR a window: below
`kMinAccelCoverageForVanHees` the accel-led path is skipped entirely and the
day falls through to the EXISTING HR-led fallback, which is already the
low-confidence degraded mode for exactly this situation. Half rather than
something tiny, because van Hees picks the LONGEST immobile block and absent
seconds are maximally immobile, so a mostly-absent window would reliably hand
the answer to the missing data.

The complete fix belongs upstream -- `immobilityMask` should take an optional
validity mask and mark invalid seconds via the `immobileUnknown` machinery it
already has. Noted for analytics; this keeps the P0 off the field meanwhile.

P1, same commit: lenient records bypassed `raw_archive`. A partial decode is
not a full decode -- HR/RR are kept but the gravity bytes the strict gate
rejected were discarded the moment we ACK'd the trim, with `raw_records` gone
and `decoded_onehz` having nowhere to put a null accel. They are now archived
as well as sampled, in the same safe-trim transaction, so a future decoder can
recover what this one could not. Extracted `_archiveHistoricalFrame` so both
the undecodable and partial paths share one implementation.

7 tests added; the 6 pre-existing failures in notification_dedupe_test also
reproduce on origin/main unmodified and are unrelated.
@abdulsaheel

Copy link
Copy Markdown
Collaborator

Reviewed this and pushed one commit (748f6dc). The lenient decode itself is right — it abstains on gravity instead of inventing it, which is the correct instinct. The problem is what happens to that abstention two layers down.

P0 — absent gravity is read back as perfect stillness

sampleFromGen5V18Lenient leaves ax/ay/az null. But decoded_onehz.ax/ay/az are REAL NOT NULL, so _queueDecodedOneHz writes decoded.ax ?? 0 (db.dart:1977) and the substrate loader reads ?? 0 back (derive_prepare.dart:405). "We did not measure this" becomes "the wrist was at exactly (0,0,0)".

That is not an inert default:

zAngle(0,0,0) = 0.0          # atan2(0,0) is 0.0 in Dart, NOT NaN

A constant z-angle is the van Hees immobility criterion satisfied maximally. Measured against the pinned analytics — 8 h of (0,0,0):

immobile seconds: 28501 / 28800
vanHeesSleepWindow present: true
  -> 7.92 h of "sleep"

A fabricated, fully-staged night out of data that does not exist. Your own commit message notes the strict gate rejected every v18 record on fw 50.40.1.0 — so on that firmware this is the ordinary case, not a corner. test/gen5_decoded_onehz_persistence_test.dart:121 currently pins the fabrication as intended behaviour.

Two fixes I tried and rejected — measured, not assumed

Worth recording, because both look obviously correct on paper:

attempt result
NaN sentinel Fails open. The rule asks "did the angle change by ≥ threshold", and every comparison against NaN is false, so it never trips — NaN scores the same 28,501 immobile seconds as zeros.
Omit the seconds immobilityMask is a pure index-wise angle rule with no gap awareness (unlike nap.dart's stillAt, which does check absAt(k) - absAt(k-1) == 1). It just joins across the hole.

Both are pinned in tests so nobody "fixes" it that way later.

What I did instead

Exact (0,0,0) is an unambiguous absent marker, not a reading — gravity always has magnitude ~1 g and every decoder that emits a vector gates on magSq >= 0.25. So Substrate.accelPresentAt / accelPresentFraction recover the distinction the NOT NULL column erased, with no schema change, and it heals rows already persisted.

Since analytics has no validity input to be told any of this, the honest move at this layer is not to let absent accel anchor a window: below kMinAccelCoverageForVanHees the accel-led path is skipped and the day falls through to the existing HR-led fallback — already the low-confidence degraded mode for exactly this situation. Set at a half rather than something tiny because van Hees picks the longest immobile block, and absent seconds are maximally immobile, so a mostly-absent window would reliably hand the answer to the missing data.

The complete fix belongs upstream: immobilityMask should take an optional validity mask and mark those seconds through the immobileUnknown machinery it already has. I'd rather do that in analytics than paper over it here — but this keeps the P0 off the field meanwhile, without a repin.

P1 — lenient records bypassed raw_archive

A partial decode is not a full decode. HR/RR were kept, but the gravity bytes the strict gate rejected were discarded the instant we ACK'd the trim — raw_records is gone and decoded_onehz has nowhere to put a null accel. They're now archived as well as sampled, inside the same safe-trim transaction, so a future decoder can recover what this one couldn't. Extracted _archiveHistoricalFrame so the undecodable and partial paths share one implementation rather than growing a second copy.

Refuted

The kAlgoVersion finding from the review pass — not real, no bump needed from this diff on its own.

Verification

7 tests added, all green. Suite: 1071 passing / 6 failing — the 6 are all notification_dedupe_test and reproduce on origin/main unmodified, so they're pre-existing and unrelated to this PR (worth a separate issue).

Happy to take the analytics-side immobilityMask change as a follow-up PR if you'd rather have the complete fix before this merges.

abdulsaheel added a commit to Brackyt/edge that referenced this pull request Aug 6, 2026
…ats)

P0. The measured-vs-derived guard was per-CONNECTION and evaporated at exactly
the moment it was needed.

`_gen5MeasuredRecTs` is an in-memory Set, cleared in `_teardownSession` and
never seeded from `decoded_onehz`. After a reconnect it is empty while the rows
are still on disk, so a re-delivered v26 burst derives an HR for a second that
already has a measured v18 row. `decoded_onehz` is INSERT-OR-REPLACE on
UNIQUE(rec_ts), so the derived row WINS -- and `_queueOrphanGuard` then deletes
the evicted counter's `decoded_rr` beats. A derived sample carries no beats of
its own, so the net trade is a measured HR plus a whole second of beat-to-beat
intervals for an inferred bpm. `decoded_rr` is the durable RR store; that is
irrecoverable.

Seeding the set from the DB on connect would not really fix it -- the set would
have to hold every second ever measured. Provenance belongs on the datum, so:

  * `Sample.derived` marks an INFERRED HR (today only the v26 PPG ACF path).
  * `_queueDecodedOneHz` honours it: derived rows use INSERT OR IGNORE and SKIP
    the orphan guard, so an existing row for that second simply stands. Nothing
    is evicted, so nothing is stranded.

Measured-vs-measured is deliberately untouched -- "newest wins" is still right
there (the strap counter resets on reboot), and a test pins that.

This also de-fangs the u16-index-as-global-PK concern: a v26 burst index that
collides with a real counter now loses the insert instead of overwriting a
measured row.

The vacuous test, confirmed and replaced. `decodeGen5HistoricalSample --
measured v18 clobber guard` passed with the guard line DELETED, because no
`ppgBuf` was supplied so the derived path abstained for want of samples no
matter what the guard did. It now primes a buffer with an ACF-resolvable
waveform and asserts BOTH directions: the derived path is genuinely reachable
for that fixture when the second is unclaimed, and abstains when it is claimed.
Re-ran the mutation afterwards -- it now fails, as it should.

Not addressed here, deliberately: absent gravity persisted as `?? 0`. That is
the same seam as OpenStrap#188 and is fixed there (`Substrate.accelPresentAt`); doing it
again here would just conflict.

5 tests added/reworked, each mutation-verified. Suite 1076 passing; the 6
failures in notification_dedupe_test are pre-existing and reproduce on
origin/main unmodified.
@Brackyt

Brackyt commented Aug 6, 2026

Copy link
Copy Markdown
Author

Great find and fix

@abdulsaheel

Copy link
Copy Markdown
Collaborator

Thanks @Brackyt. One thing worth calling out from your own list of known issues:

Missing acceleration is stored as zero, potentially affecting sleep/motion

You had this exactly right, and it's more serious than "potentially". Measured against the pinned analytics, 8 h of (0,0,0) scores 28,501 immobile seconds and yields a fabricated 7.92 h staged sleep window — because zAngle(0,0,0) is exactly 0.0, and a perfectly constant z-angle is the van Hees criterion satisfied maximally. Since the strict gate rejected every v18 record on fw 50.40.1.0, that was the ordinary case for your firmware, not an edge case.

That's 748f6dc. Your instinct to list it was right; it just deserved a P0 rather than a footnote.

On the rest of your list, for whatever it's worth:

  • Steps aren't wired — worth knowing the 1 Hz step estimator was rebuilt in fix: close data-loss, privacy and absent-vs-zero defects across the app #158 around a multi-day personal floor, and it abstains without history rather than guessing. So "no steps yet" may be correct behaviour on a new install rather than a wiring gap.
  • On-strap alarm not working — the WHOOP 4 alarm needs the 20-byte rich haptic form (0x42 with [0x04][index][u32 epoch][u16 subsec][12B haptic]); the 7-byte simple form ACKs but never fires. If gen5 mirrors that, a simple-form payload would look exactly like "ACKs, nothing happens".
  • RR coverage overnight — that's the one I'd measure before trusting readiness on gen5, since HRV is the input with the least tolerance for gaps.

CodeRabbit's two findings on this PR (the 2030 cutoff, and exercising the strict→lenient path through decodeGen5HistoricalSample) both look addressed by your 8e36c3a / the isPlausibleUnix switch.

@abdulsaheel

Copy link
Copy Markdown
Collaborator

Correction to my earlier note on this PR, and it's good news.

I described the 6 notification_dedupe_test failures as "pre-existing, reproduce on origin/main unmodified" and suggested they deserved their own issue. The first half was right; the framing was wrong, and I've now root-caused them.

They are a time bomb, not a standing breakage. The suite builds date-prefixed dedupe keys from a hardcoded 2026-07-23, and FiredKeyStore prunes dated flags older than retentionDays (14). While that date was recent the keys stayed inside the window; once it aged past 14 days, every key was pruned the instant it was written, so repeat emits fired again:

Expected: <1>   Actual: <3>

main passed CI on 2026-08-04 when the date was 12 days old, and has been failing since the window closed — same commit, no code change.

Proved it by substituting today's date into the unmodified file on origin/main: all 15 turn green. Fixed in #207 (test-only, no lib/ change); the full suite is 1201 passing, 0 failing with it.

Practical impact here: this PR's CI cannot go green until #207 merges, regardless of its own content. Sorry for the noise — "pre-existing and unrelated" was accurate but undersold that it was actively blocking you.

@Brackyt

Brackyt commented Aug 6, 2026

Copy link
Copy Markdown
Author

Thanks @abdulsaheel, agree on the zero-accel → van Hees fabrication; 748f6dc is the right P0 for that.

Quick update on the other items from that earlier known-issues list (they’ve moved to separate PRs, so “not wired” is outdated on those):

  • Steps (gen5 live): the wiring gap was real on Whoop 5 — toggleImuMode needed the revision byte, and live IMU is 0x2B rec 0x15 (not gen4 0x33). That’s #195. You’re right that the 1 Hz estimator also abstains without a personal floor (fix: close data-loss, privacy and absent-vs-zero defects across the app #158); that doesn’t replace the live-IMU fix, and all-day totals are still a separate product path (see also #182).
  • On-strap alarm: gen5 needed official slot index 1 (slot 00xb), not the WHOOP 4 rich-form tip — that’s #194.
  • RR overnight: still measuring; readiness stayed honest/ early on while baselines were thin, which matched what we wanted.

On the notification_dedupe_test note — thanks for the correction and for #207. Happy to wait on that so this PR’s CI can go green cleanly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants