Skip to content

feat(gen5): v26 PPG-derived HR ingest (algo v52) - #190

Closed
Brackyt wants to merge 7 commits into
OpenStrap:feat/multiband-whoop5from
Brackyt:feat/gen5-v26-ppg-hr
Closed

feat(gen5): v26 PPG-derived HR ingest (algo v52)#190
Brackyt wants to merge 7 commits into
OpenStrap:feat/multiband-whoop5from
Brackyt:feat/gen5-v26-ppg-hr

Conversation

@Brackyt

@Brackyt Brackyt commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Status: Draft — blocked on hardware validation

Do not merge. This PR depends on #188 (v18 persistence + unix gate) and a reworked/validated OpenStrap/analytics#37.

Real-hardware validation failure

Evaluated against export openstrap_export_1785880255630.db (fw 50.40.1.0):

Metric Result
Evaluable rolling windows 22 (10–12 s each)
Measured v18 HR 90–100 bpm
Current ACF-derived HR 29–160 bpm

Synthetic sine tests pass but are insufficient. PPG-derived HR must not ship until the algorithm is reworked and re-validated on hardware.

What this PR contains (research)

  • Wire gen5 v26 PPG bursts into Sample ingest via normalized ACF HR (empty RR — no HRV claim).
  • Gen5PpgBurstBuffer: capacity 12, adjacency gate (same-second or +1 s unix + monotonic burstIndex).
  • _counterFromInner: u16@3 for v26 (not u32-inflated into v18 key space).
  • Always archive v26 hex (reason: gen5_v26_ppg) even when a derived Sample is emitted.
  • Never emit PPG Sample when measured v18 already claimed that rec_ts (_gen5MeasuredRecTs).
  • kAlgoVersion 52 bump citing analytics pin b3e7b88624e4cbb6a0ab2dee6715446f19feb775.

Known follow-ups (valid reviewer feedback, deferred)

Test plan

  • flutter test test/gen5_sample_mapping_test.dart test/gen5_decoded_onehz_persistence_test.dart test/gen5_v18_hardware_lenient_test.dart
  • Re-validate PPG-derived HR on hardware after analytics algorithm rework
  • Merge fix(gen5): SET_CLOCK revision byte + honest v18 decode #188 first; repin analytics to validated SHA

Brackyt added 4 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.
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).
Wire gen5 v26 PPG bursts into Sample ingest via analytics ACF HR
(b3e7b88): adjacent 12-burst buffer, u16@3 counter for v26, always
archive v26 hex, never clobber measured v18 at the same rec_ts.
Copilot AI lite review requested due to automatic review settings August 4, 2026 20:10
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

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: d4b6fc51-eed2-40ce-a116-1277854563e6

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

The BLE engine adds Gen5 v18 lenient decoding, Gen5 v26 PPG-derived heart rate, generation-aware clock commands, archival, and connection-state cleanup. Database persistence excludes Gen4 R10-lite records from decoded_onehz. Analytics version 52 documents the new derivation path.

Changes

Gen5 historical decoding and derivation

Layer / File(s) Summary
Gen5 decoding and PPG derivation
lib/ble/ble_engine.dart, pubspec.yaml, lib/compute/derivation_engine.dart, test/gen5_sample_mapping_test.dart, test/gen5_v18_hardware_lenient_test.dart
Gen5 v18 records support validated lenient decoding. Gen5 v26 PPG bursts use buffered waveform derivation and return HR-only samples without RR data. Tests cover timestamps, gravity rejection, flatline abstention, burst buffering, and measured-sample protection.
Generation-aware clock and record identity
lib/ble/ble_engine.dart, test/gen5_v18_hardware_lenient_test.dart
Gen5 clock commands use revision-prefixed payloads and Gen5-specific opcodes. v26 record indexes use a u16 layout separate from v18 counters.
Historical ingestion, state, and archival
lib/ble/ble_engine.dart
Historical ingestion routes Gen5 records through the new decoder, archives v26 PPG data, tracks measured seconds, and clears Gen5 state on teardown.
Decoded-store persistence rules
lib/data/db.dart, test/gen5_decoded_onehz_persistence_test.dart, test/gen5_sample_mapping_test.dart
Gen4 R10-lite HR-only records remain out of decoded_onehz. Valid Gen5 v18 samples, RR intervals, lenient samples, and complete Gen4 R24 samples continue to persist.

Estimated code review effort: 5 (Critical) | ~90 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BLEHistoricalDrain
  participant BleEngine
  participant Analytics
  participant ArchiveSink
  participant Database
  BLEHistoricalDrain->>BleEngine: ingest Gen5 historical frame
  BleEngine->>Analytics: derive HR from concatenated v26 PPG samples
  Analytics-->>BleEngine: return HR or abstain
  BleEngine->>ArchiveSink: archive v26 PPG hex
  BleEngine->>Database: persist decoded sample
Loading

Possibly related PRs

  • OpenStrap/edge#97: Both changes modify Gen5 historical decoding and generation-specific clock handling.
  • OpenStrap/edge#158: Both changes modify BLE historical-drain, connection, and clock logic.

Suggested labels: Review effort 5/5

Suggested reviewers: copilot, abdulsaheel

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: Gen5 v26 PPG-derived heart-rate ingestion and analytics algorithm version 52.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch

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.

Regenerate pubspec.lock without pubspec_overrides so CI resolves
openstrap_analytics from git (b3e7b88), not path.

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.

🟡 Not ready to approve

A newly added test contains a Dart compile error, and the v18 clobber guard appears connection-scoped which can still allow v26-derived samples to overwrite existing measured rows in decoded_onehz across reconnections.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Adds Gen5 (WHOOP 5) support for deriving per-second HR from v26 PPG bursts and persisting the resulting 1 Hz substrate, while preserving raw archival and re-deriving analytics via an algo version bump.

Changes:

  • Wire Gen5 v26 PPG bursts into historical ingest with a rolling adjacency-gated burst buffer and ACF-based derived HR (RR intentionally empty).
  • Adjust decoded substrate persistence so Gen5 v18/lenient samples (no Gen4 optics) can still land in decoded_onehz, while keeping Gen4 R10-lite excluded.
  • Repin openstrap_analytics to the commit that adds deriveHrFromGen5PpgWaveform, bump kAlgoVersion to 52, and expand/restore Gen5 mapping + persistence tests.
File summaries
File Description
test/gen5_v18_hardware_lenient_test.dart Adds hardware-evidence regression tests for Gen5 clock payload revisions and strict-vs-lenient v18 unix handling.
test/gen5_sample_mapping_test.dart Expands mapping tests for Gen5 v20/v21 null mapping, v26 PPG→derived HR behavior, burst-buffer adjacency rules, and measured-v18 clobber guard.
test/gen5_decoded_onehz_persistence_test.dart Adds DB-level persistence tests ensuring Gen5 v18 samples persist into decoded_onehz via preferred fallback and keeping R10-lite excluded.
pubspec.yaml Repins openstrap_analytics to the commit that introduces v26 PPG HR derivation.
pubspec.lock Updates the resolved analytics git ref to match the new pin.
lib/data/db.dart Updates _decodeOneHzSample to allow preferred Gen5 v18/lenient samples to populate decoded_onehz even without full optics, while excluding Gen4 R10-lite.
lib/compute/derivation_engine.dart Bumps kAlgoVersion to 52 with changelog notes tied to the new Gen5 v26-derived HR substrate.
lib/ble/ble_engine.dart Implements Gen5 v26 PPG burst buffering + derived HR sample mapping, always-archive behavior for v26, and Gen5 clock payload revision handling.
Review details
  • Files reviewed: 7/8 changed files
  • Comments generated: 3
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment thread lib/ble/ble_engine.dart
Comment on lines +43 to +44
import 'package:openstrap_analytics/onehz.dart'
show deriveHrFromGen5PpgWaveform, kGen5PpgHrMinSamples;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 63c40f0 — the unused kGen5PpgHrMinSamples import was removed. This PR is now Draft pending hardware validation of the PPG algorithm.

Comment thread lib/ble/ble_engine.dart
Comment on lines +951 to +953
/// Seconds that already have a measured v18 sample this connection — PPG
/// derivation must not REPLACE those rows in `decoded_onehz`.
final Set<int> _gen5MeasuredRecTs = <int>{};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid future work. _gen5MeasuredRecTs is in-session only; durable measured-v18 precedence across reconnects needs a persisted guard (e.g. query decoded_onehz before emitting PPG Sample). Deferred — this PR is Draft until PPG HR passes hardware validation.

final raw = RawRecord(
counter: counter,
packetType: PacketType.historicalData,
hex: '2f18' '00' * 20,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not a compile error under this repo's SDK: '2f18' '00' * 20 is valid Dart string repetition and dart analyze passes on the test file. Stale reviewer claim.

Use the analytics min-window constant so the import is load-bearing and
thin burst concatenations abstain before ACF.

@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: 5

🤖 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 205-214: Update the burst adjacency logic in
lib/ble/ble_engine.dart lines 205-214 to accept only the expected next
burstIndex within the same second, while allowing only the protocol-defined
reset transition at the next second; clear the buffer for skipped or invalid
transitions. Add regression cases in test/gen5_sample_mapping_test.dart lines
156-178 covering a skipped index and an invalid next-second index transition.
- Around line 309-317: Preserve measured HR precedence across reconnects by
enforcing the measured-row check during durable persistence, not solely through
the connection-local set in the Gen5 decoder around sampleFromGen5PpgWaveform.
Update the persistence path around the teardown/set handling at
lib/ble/ble_engine.dart lines 951-953 so reconnects consult durable measured
records and never replace them with derived PPG HR. In
test/gen5_sample_mapping_test.dart lines 180-198, supply at least
kGen5PpgHrMinSamples, verify derivation succeeds without the measured timestamp,
then verify the guard abstains; add persisted-row/reconnect coverage including
idempotence and synchronization behavior.
- Around line 2179-2193: Update the isGen5V26 handling in the PPG derivation
flow so archive-only records use a separate historical ingestion method that
increments records and recordsThisOffload before returning when sample is null.
Keep onHistoricalArchive for v26 archives paired with a stored sample, and apply
the same distinction at the corresponding alternate flow.

In `@lib/data/db.dart`:
- Around line 1868-1902: Update _decodeOneHzSample to decode raw.hex before
checking preferred, returning null immediately when
_isGen4R10LiteHistorical(bytes) is true. Only then allow a complete preferred
sample to return, while preserving the existing decoder and fallback behavior
for non-R10-lite records. Add a regression test covering an R10-lite raw record
with a complete preferred sample and verifying it is rejected.

In `@test/gen5_decoded_onehz_persistence_test.dart`:
- Line 46: Update the assignment to inner[15] so it uses an integer-valued
expression instead of int.clamp, preserving the 0–4 bounds and satisfying
Uint8List’s integer element type.
🪄 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: 95503003-bd4a-4eef-8e9e-d2773faa6d1f

📥 Commits

Reviewing files that changed from the base of the PR and between 82b094c and 889dc18.

⛔ Files ignored due to path filters (1)
  • pubspec.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • lib/ble/ble_engine.dart
  • lib/compute/derivation_engine.dart
  • lib/data/db.dart
  • pubspec.yaml
  • test/gen5_decoded_onehz_persistence_test.dart
  • test/gen5_sample_mapping_test.dart
  • test/gen5_v18_hardware_lenient_test.dart

Comment thread lib/ble/ble_engine.dart
Comment thread lib/ble/ble_engine.dart
Comment on lines +309 to +317
if (parsed is Gen5PpgWaveform) {
ppgBuf?.add(
unix: parsed.unix,
burstIndex: parsed.burstIndex,
wave: parsed.ppgWaveform,
);
if (measuredRecTs?.contains(parsed.unix) ?? false) return null;
final samples = ppgBuf?.concatenated() ?? parsed.ppgWaveform;
return sampleFromGen5PpgWaveform(parsed, samples);

@coderabbitai coderabbitai Bot Aug 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve measured HR across connection boundaries.

_gen5MeasuredRecTs protects only measured samples decoded during the current connection. Teardown clears the set. A later connection can therefore persist derived PPG HR at a timestamp that already contains measured v18 HR. The decoded store uses replacement by rec_ts, so this can overwrite the measured row.

  • lib/ble/ble_engine.dart#L309-L317: enforce measured-over-derived precedence in durable persistence, not only in decoder memory.
  • lib/ble/ble_engine.dart#L951-L953: do not use a connection-local set as the only source of measured-row precedence.
  • test/gen5_sample_mapping_test.dart#L180-L198: provide at least kGen5PpgHrMinSamples through ppgBuf, first prove that derivation succeeds without the measured timestamp, and then prove that the guard causes abstention. Add a persisted-row or reconnect regression test.

As per coding guidelines, regression tests must cover idempotence and synchronization behavior.

📍 Affects 2 files
  • lib/ble/ble_engine.dart#L309-L317 (this comment)
  • lib/ble/ble_engine.dart#L951-L953
  • test/gen5_sample_mapping_test.dart#L180-L198
🤖 Prompt for 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.

In `@lib/ble/ble_engine.dart` around lines 309 - 317, Preserve measured HR
precedence across reconnects by enforcing the measured-row check during durable
persistence, not solely through the connection-local set in the Gen5 decoder
around sampleFromGen5PpgWaveform. Update the persistence path around the
teardown/set handling at lib/ble/ble_engine.dart lines 951-953 so reconnects
consult durable measured records and never replace them with derived PPG HR. In
test/gen5_sample_mapping_test.dart lines 180-198, supply at least
kGen5PpgHrMinSamples, verify derivation succeeds without the measured timestamp,
then verify the guard abstains; add persisted-row/reconnect coverage including
idempotence and synchronization behavior.

Source: Coding guidelines

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@Brackyt, acknowledged. The finding remains valid and is out of scope for this PR.

The draft body tracks the follow-up. #188 and validated analytics must unblock it before implementation. If you want separate issue tracking, I can create a GitHub issue with the reconnect and durable-persistence acceptance criteria.

You are interacting with an AI system.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid future work — agree measured HR should win durably, not just for the current BLE session. Tracked as a follow-up when/if PPG ingest is reworked and re-validated. PR converted to Draft.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@Brackyt, acknowledged. The durable measured-v18 precedence requirement remains valid and is tracked as future work.

The Draft status is appropriate while PPG ingestion and analytics validation remain pending. If you want, I can create a GitHub follow-up issue with reconnect, durable-persistence, idempotence, and synchronization acceptance criteria.

🐇

You are interacting with an AI system.

Comment thread lib/ble/ble_engine.dart
Comment on lines +2179 to +2193
if (isGen5V26) {
final archive = ArchiveRecord(
counter: counter,
hex: _innerHex(frame.inner),
packetType: frame.inner.isNotEmpty ? frame.inner[0] : 0,
capturedAt: DateTime.now().millisecondsSinceEpoch,
reason: 'gen5_v26_ppg',
);
final d = _drain;
if (d != null) {
d.onHistoricalArchive(archive);
} else {
unawaited(onArchiveRecord?.call(archive) ?? Future<void>.value());
}
if (sample == null) return;

@coderabbitai coderabbitai Bot Aug 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Count archive-only v26 records as historical records.

When PPG derivation abstains, Line 2193 returns without calling onHistoricalRecord. The preceding onHistoricalArchive call does not increment records or recordsThisOffload.

A drain that contains only abstaining v26 records can therefore appear empty. This can produce incorrect sync reports and continuation decisions.

Use a separate archive-only ingestion method that increments the historical record counters. Keep onHistoricalArchive for archives paired with a stored sample.

Proposed fix
-        if (d != null) {
-          d.onHistoricalArchive(archive);
+        if (d != null) {
+          if (sample == null) {
+            d.onArchiveOnlyHistoricalRecord(archive);
+          } else {
+            d.onHistoricalArchive(archive);
+          }
...
+  void onArchiveOnlyHistoricalRecord(ArchiveRecord a) {
+    records++;
+    recordsThisOffload++;
+    _lastProgressAt = DateTime.now();
+    onHistoricalArchive(a);
+  }

As per coding guidelines, maintain one historical ingestion path for each concern.

Also applies to: 3726-3735

🤖 Prompt for 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.

In `@lib/ble/ble_engine.dart` around lines 2179 - 2193, Update the isGen5V26
handling in the PPG derivation flow so archive-only records use a separate
historical ingestion method that increments records and recordsThisOffload
before returning when sample is null. Keep onHistoricalArchive for v26 archives
paired with a stored sample, and apply the same distinction at the corresponding
alternate flow.

Source: Coding guidelines

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@Brackyt, acknowledged. The finding remains valid and is deferred while this PR is a draft.

When this work resumes, archive-only v26 records should increment the historical offload counters before the early return.

You are interacting with an AI system.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid — archive-only v26 rows should count toward offload progress. Deferred with this PR's Draft status; not blocking #188.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@Brackyt, acknowledged. The archive-only v26 offload-accounting issue remains valid and is deferred. It does not block #188.

Would you like me to create a follow-up GitHub issue for this work?

You are interacting with an AI system.

Comment thread lib/data/db.dart
Comment thread test/gen5_decoded_onehz_persistence_test.dart
@Brackyt
Brackyt marked this pull request as draft August 4, 2026 22:35
…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.
@abdulsaheel

Copy link
Copy Markdown
Collaborator

Reviewed and pushed 6faa944.

P0 — the measured-vs-derived guard evaporates at exactly the moment it's needed

_gen5MeasuredRecTs is an in-memory Set, cleared in _teardownSession (ble_engine.dart:3454) and never seeded from decoded_onehz. Its own doc comment says "this connection" — but the hazard is specifically a reconnect, where the set is empty and 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 → an inferred bpm

decoded_rr is the durable RR store. That loss is permanent.

Note the neighbouring _counterRegression field already documents the right pattern — "Re-seeded from the durable counter_hw cursor on each connect". This one just never got the same treatment.

Why I didn't just seed the set

To be correct it would have to hold every second ever measured. Provenance belongs on the datum instead:

  • 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 correct there (the strap counter resets on reboot), and there's a test pinning that so this guard can't quietly regress it.

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

The clobber-guard test was vacuous — confirmed, not assumed

I mutated it before rewriting it. With the guard line deleted:

00:00 +13: All tests passed!

It passed because no ppgBuf was supplied, so the derived path abstained for want of samples regardless of what the guard did. It now primes a buffer with an ACF-resolvable waveform and asserts both directions — that the derived path is genuinely reachable for that fixture when the second is unclaimed, and that it abstains when claimed. Re-running the same mutation now fails, as it should.

The two new P0 tests are mutation-verified the same way: disabling the decoded.derived branch fails exactly the "doesn't replace the measured row" and "doesn't take its RR beats" tests, while the two control tests (derived still lands on an unclaimed second; measured-vs-measured still newest-wins) keep passing.

Not addressed here, deliberately

Absent gravity persisted as ?? 0 — that's the same seam as #188 and is fixed there via Substrate.accelPresentAt. Doing it again here would only conflict. Worth knowing it matters for this PR too: a derived PPG sample has no accel either, so it also writes (0,0,0) — which #188's change correctly reads as absent rather than as a perfectly still wrist.

Verification

Suite 1076 passing / 6 failing; the 6 are notification_dedupe_test and reproduce on origin/main unmodified — pre-existing and unrelated.

@Brackyt

Brackyt commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

I am not sure this PR is needed @abdulsaheel
It was made by mistake as I thought something else wasn't working. I don't know if it's worth working on ?

@abdulsaheel

Copy link
Copy Markdown
Collaborator

@Brackyt — thanks for saying so directly, and I think you're right. My honest recommendation is close this, and analytics#37 with it.

Three independent things point the same way:

1. Your own premise is gone. You said the v26 PPG work existed because you thought the strap wasn't giving usable data, and that turned out not to be the case. If v18 delivers measured HR and RR, then deriving a bpm from PPG autocorrelation buys an inferior number — no RR, so no HRV — for a lot of moving parts.

2. It doesn't work on real hardware. Your own validation on analytics#37: ACF returns 29–160 bpm where measured is 90–100, across 22 evaluable windows. I found and fixed a separate fabrication bug there (a decaying ACF was accepted at the range boundary and reported as a confident 206 bpm), but that fix does not close the 29–160 gap. That's a deeper signal-processing problem, and fixing it is real work with no clear payoff given point 1.

3. Both bots independently found the P0 I fixed here. Copilot and CodeRabbit each flagged that _gen5MeasuredRecTs is cleared on teardown and never seeded from storage, so a later connection can overwrite a measured v18 row. That's real, and it's now fixed durably via Sample.derived + INSERT OR IGNORE — but it's a guard on a feature you no longer want, which rather makes the point.

If you close it

Nothing is lost that matters. The one piece with value independent of v26 is the principle that a derived value must never evict a measured one — worth remembering if any inferred-HR source is ever added, but not worth keeping a branch open for.

Still-open bot findings, for the record

CodeRabbit's "enforce actual adjacency between buffered PPG bursts" is real and unaddressed (the buffer accepts any larger burstIndex in the same second and every index in the next second, so it can concatenate captures with missing bursts — which is plausibly part of why the ACF is wrong). Copilot's unused-import and String * int notes look like they were resolved in 63c40f0.

I'd rather not polish any of that on a branch you've said was a mistake. Your call — happy to close it out, or to keep going if you'd still like v26 working.

@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
Contributor Author

Same conclusion, closing this

@Brackyt Brackyt closed this Aug 6, 2026
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