Skip to content

ecg: the pure research modules — allow-list, stats and log - #1941

Merged
ryanbr merged 4 commits into
ryanbr:mainfrom
Zebsi235:feat/ecg-research-pure
Sep 6, 2026
Merged

ryanbr merged 4 commits into
ryanbr:mainfrom
Zebsi235:feat/ecg-research-pure

Conversation

@Zebsi235

@Zebsi235 Zebsi235 commented Sep 6, 2026

Copy link
Copy Markdown

Split 3 of the MG ECG research series; splits 1 (#1765) and 2 (#1727) are merged.

What this PR does

Adds three self-contained Kotlin modules under com.noop.protocol for the MG ECG research probe
(#891/#1100), with no caller yet:

module what
EcgResearchAllowList a positive allow-list of the opcodes a research probe may send — PROBE_OPCODES (123/124/125/139), GATE_OPCODES (119/121) — as pure predicates over raw opcode ints, plus a FORBIDDEN census of the opcodes it must never form (firmware load / DFU, wipe, reboot, high-frequency sync, AFE writes)
EcgResearchStats windowed scoring of a probe run: zero-byte fractions, changing byte positions, packets/s per characteristic, size and type histograms — and estimateBpm, see below
EcgResearchLog the capture record — typed TX / RX / command / event / write-status rows, row and byte caps, CSV formatting

Database-free, no Android framework types, no BLE. Fully unit-testable without a strap, which is what
makes them reviewable on their own and verifiable end to end by CI. Same shape as the pure
Whoop5EcgProbe.kt already on Android; opcodes come from Whoop5Ecg's constants, labels from #893's
CommandNames table — no literals.

Why it lands alone, unused. The allow-list is deliberately not wired to the send path here. Wiring it
is what widens what CommandNumber can express, and upstream excluded the three Labrador opcodes on
purpose ("Android has no ECG app layer and sends none of them"). Making that untrue deserves its own
review, with the allow-list and the deny-list census as the argument. So this PR keeps the surface to pure
logic and leaves the sensitive change to the PR that follows.

The heart-rate estimate, and why it is safe here. EcgResearchStats.estimateBpm autocorrelates a
sample buffer — the method behind the withdrawn PPG→HR estimate (#194), whose failure was that fixed-size
records are periodic at the record period (~59 bpm at 100 Hz / 101 samples) and manufacture a plausible
resting rate. This implementation refuses that lag band, requires a local maximum (shortest first, so the
fundamental beats the sub-harmonic), and returns null for "cannot tell" rather than a number. The cost is
stated: a genuine rate inside the excluded band is declined. It feeds nothing and sits behind no UI in
this PR. It is not validated against a real, moving heart rate; that is the #194 lesson and it stays
open.

The Swift twin is deliberately absent. Apple drives its probe through WhoopCommand and BLEManager
and has no consumer for these modules. EcgResearchLog's row/CSV shape is written to be the contract a
future Swift export would match byte-for-byte, per the parity rule, rather than something to reconcile
later.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

How it was tested

Android unit tests, full suite on f9ff5a21 (this head), base main @ 8c3ddaca: 5,466 tests, 0 failures,
0 errors
across 657 classes (6 skipped, pre-existing) — main's 5,430 plus these four suites:

  • EcgResearchAllowListTest 9/9 — the four probe opcodes, the two gate verbs, the FORBIDDEN census, and
    that no forbidden opcode is reachable through the probe cases.
  • EcgResearchLogTest 11/11 — CRC verdict as its own column with "unchecked" distinct from "failed",
    CRC-failed rows never plotted, zeros kept, the byte cap.
  • EcgResearchStatsTest 9/9 — the windowed statistics.
  • EcgBpmEstimatorTest 7/7 — recovers eight injected rates and is monotonic across five (one match
    proves nothing); refuses the tiled-record artefact that an unguarded estimator reports as 59 bpm; works
    either side of the excluded band; pins the declined-band cost; rejects noise, flatline and short buffers;
    pins the no-high-rate-bias case an unnormalised score fails.

Counted from the JUnit XML with the results directory cleared first. No new build warnings.

Compile alone: compileFullDebugKotlin is clean against main with no files beyond these seven —
the load-bearing check for "can land first": no dependency on the CommandNumber widening or on any UI.

Gates: Tools/doc_comment_lint.py clean (2,039 files, no new detached doc comments).
Tools/i18n_audit.py --ci upstream/main clean — no strings touched.

Not run on hardware — nothing here talks to a strap. Swift: n/a, no Swift touched.

What this does NOT claim

  • Nothing about strap behaviour. These modules send nothing and decode nothing from a live link.
  • The allow-list enforces nothing yet — nothing consults it in this PR.
  • The estimated heart rate is not validated. It refuses a known artefact and recovers synthetic rates;
    nobody has checked it against a real heart rate that moves.

Checklist

  • Swift package tests pass for any package I touched — n/a, no Swift touched
  • Android unit tests pass (./gradlew testFullDebugUnitTest, full suite)
  • No new build warnings introduced
  • UI changes use only StrandDesign tokens — n/a, no UI
  • No hardcoded hex frame bytes; protocol facts live in the schema / decoders — opcodes via Whoop5Ecg
    and DeviceConfigWriteGate constants
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output or any secrets/keystores

Related issues

#891, #1100 (the probe this serves) · follows #1765 and #1727. Does not close anything.

Zebsi235 and others added 4 commits September 6, 2026 17:00
… tests

Three self-contained Kotlin modules for the MG ECG research work, with no caller
yet. Database-free, no Android framework types, no BLE, and fully unit-tested
without a strap.

  - EcgResearchAllowList: a positive allow-list of the opcodes a research probe
    may send, as a pure predicate over raw opcode ints. Deliberately NOT wired to
    the send path in this change -- that is a separate, review-sensitive one
  - EcgResearchStats: windowed scoring of a probe run
  - EcgResearchLog: the capture record and its formatting

Unused on purpose. Landing them alone keeps the reviewable surface pure logic
that CI can verify end to end, and leaves the send-path question -- which widens
what the command sender can express -- to its own change with its own argument.

One guard is deliberately deferred rather than shipped here. The allow-list test
originally asserted that every dispatchable opcode is a constructible
CommandNumber. That is an invariant of the SEND PATH: send() forms a frame from a
CommandNumber, so an allow-listed opcode the enum cannot express could not be
dispatched at all. Opcodes 124/125/139 are absent from that enum on purpose --
upstream excluded them saying Android has no ECG app layer and sends none of
them -- so asserting their presence here fails for the correct reason. The guard
belongs with the wiring that makes it true. Its testable half, that no FORBIDDEN
opcode is reachable through the probe cases, is kept and passes.

The Swift twin is deliberately absent. Apple drives its ECG probe through
WhoopCommand and its own layer; there is no macOS/iOS consumer for these three,
so a twin now would be untested code with no caller on either platform. The
parity contract applies when a consumer exists.
…es split

EcgResearchStats.estimateBpm ships in this split, but the suite that defends it
did not. EcgResearchStatsTest covers the estimator with one injected rate plus
noise and a short buffer -- it does not cover the artefact that got NOOP's
earlier PPG->HR estimate withdrawn (ryanbr#194).

EcgBpmEstimatorTest is that cover: eight injected rates rather than one, because
a single match proves nothing and the method has to move with the input; the
tiled-record artefact that unguarded reports as a confident 59 bpm; both sides of
the notch; the declined-band cost stated rather than hidden; and the
no-high-rate-bias case the unnormalised version failed.

Recovered from the unsplit tree, where it was written. No source change -- it
targets EcgResearchStats.estimateBpm, which this branch already carries.
estimateBpm took excludeLag as an optional parameter defaulting to null, so the
shortest and most natural call, estimateBpm(samples, fs), silently selected the
unguarded form. Measured on a tiled 101-sample record at 100 Hz carrying no
rhythm whatsoever, that form returns 59, a believable resting heart rate
manufactured from nothing. It is the withdrawn PPG->HR failure exactly, which is
the failure this method's whole shape exists to avoid.

The guard itself is right and costs nothing: on the same buffer, supplying the
record period returns null, and a genuine 72 bpm sine reads 72 either way. So
the parameter simply loses its default. Passing the period switches the guard
on; passing null says this source is not record-tiled and accepts an unguarded
estimate. Both remain available, but only as something somebody chose.

The compiler then finds every caller that had not chosen: nine call sites across
the two test files, six of which had taken the unguarded path without saying so.
All now state excludeLag = null explicitly, which is the correct reading for a
synthetic non-tiled fixture and makes the choice visible where it is made.

No behavioural change. The existing artefact test already pins both sides, that
an unguarded call reports the artefact near 59 and a guarded one declines, so
the contract stays covered. Android suite 5466 green.
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.

2 participants