Skip to content

Let the SpO2 promote gate read a NOOP app store, so the app can answer its own question (#845, #103) - #942

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
vishk23:feat/spo2-candidate-app-db
Jul 29, 2026
Merged

Let the SpO2 promote gate read a NOOP app store, so the app can answer its own question (#845, #103)#942
ryanbr merged 1 commit into
ryanbr:mainfrom
vishk23:feat/spo2-candidate-app-db

Conversation

@vishk23

@vishk23 vishk23 commented Jul 29, 2026

Copy link
Copy Markdown

Closes the spo2_candidate_82 item from #845 — the one asked for twice and called "the strongest item here". Unblocks #103.

What the ask turned out to be, precisely

The framing in #845 was "the app itself cannot currently contribute the data needed to resolve its own open question, only the offline tooling can." That was two problems, and only one of them is still open.

The persistence half is already done. #848 (merged 2026-07-27T05:50:10Z) banks the raw @82 byte into v18AuxSample. The value is retained — the strap trimming its history no longer destroys it. That landed 89 minutes after the "still open" note on #845, so the note was accurate when written and has quietly stopped being so.

The read-back half was not. validate_spo2_candidate.py could read a capture.json or the capture tooling's frame store, and nothing else. Its own docstring says why:

NOTE: this is the CAPTURE TOOLING's database, not the app's. Neither shipped app has a frames table — Android uses Room, iOS/macOS uses GRDB — so this cannot be pointed at a phone's store or a .noopbak.

So a NOOP user who synced a 5/MG was holding the evidence #103 has been waiting on with no way to run the gate over it. Contributing still required running linux-capture. That is the actual blocker, and it is what this PR removes.

What it does

A third input kind: the app's own SQLite, read from v18AuxSample joined to sleepStateSample.state for the asleep gate.

python3 validate_spo2_candidate.py noop.sqlite my_whoop_data/ --device strap-a
  • An app store is told apart from a capture store by the v18AuxSample table, not by extension or a flag — a user does not have to know which kind of file they were handed.
  • Opened mode=ro&immutable=1, so no WAL recovery write is attempted against a file pulled off a phone (the reasoning Tools/SleepBench already documents).
  • --app-device picks a strap. Required when the store holds several: pooling straps would average away the very per-device difference the promote gate exists to test, so it refuses rather than guessing.

The one thing an app store cannot do — reported, not papered over

The app banks decoded slots, not frame bytes, so there is no neighbour byte to rank @82 against and the offset specificity scan cannot run.

Recording offset_82_wins: false there would fail the gate for a reason that is about the input format rather than the data — and "@82 did not win the scan" is precisely the wrong thing to record about a byte that was never ranked. It is n/a, and source / specificity_scan name which of the two happened so a batch pooling both kinds can tell them apart without reopening the files. A capture is still required to settle specificity. Correlation, MAE, bias, duty-cycle detection and window coverage all work from either.

Related: byte_at_offset returns None, not 0, for an unknowable offset. 0 is a legitimate @82 reading outside the duty window, so "cannot know" read as "read zero" would manufacture out-of-band samples and silently depress coverage.

The codec is pinned against the shipped one, not against itself

unpack_v18_aux is a Python port of Swift V18AuxCodec.unpack. Round-tripping it against a Python packer would prove nothing, so both test fixtures are literal V18AuxCodec.pack output captured by running the real codec in Packages/WhoopStore:

02ffff0100070000000381803a3412ff28500531034204601f2180801f3ba9c0   # all 17 slots
020008000060                                                       # auxByte82 only

A wire-format change now fails the test rather than silently mis-slicing real rows into plausible-looking numbers. Absence stays first-class — a clear presence bit means "the strap did not report this", never 0.

Not relaxed

No card, no score, no spo2Pct, no change to any promote threshold, no schema change, no app change, nothing on the BLE path. This only widens who can produce the evidence.

Verified

tools/linux-capture: 50 tests, 0 failures (39 before, 11 new) — python3 -m unittest test_validate_spo2_candidate. The new tests cover the codec against the Swift fixtures, malformed/truncated blobs, the multi-strap refusal, out-of-band values, that only @82 is answerable without a frame, an end-to-end validate_device run over a synthetic app store, and — in the other direction — that a real capture still runs the specificity scan.

Corpus credit stays with @digitalerdude, whose capture is behind the duty-cycle figures this harness relies on.

…apture (#845, #103)

#845 asked for the `spo2_candidate_82` instrumentation because "the app itself
cannot currently contribute the data needed to resolve its own open question,
only the offline tooling can". Half of that is now fixed and half was not:
#848 banks the raw @82 byte into `v18AuxSample`, so the value IS retained — but
`validate_spo2_candidate.py` could only read a capture.json or the CAPTURE
TOOLING's frame store, and its own docstring said so:

    NOTE: this is the CAPTURE TOOLING's database, not the app's. Neither shipped
    app has a `frames` table [...] so this cannot be pointed at a phone's store.

So a NOOP user who synced a strap held the evidence #103 has been waiting on and
had no way to run the gate over it. Only someone running linux-capture could
contribute. This closes that.

The harness now accepts a third input kind: the app's own SQLite, read from
`v18AuxSample` joined to `sleepStateSample.state` for the asleep gate. An app
store is told apart from a capture store by the `v18AuxSample` table rather than
by extension or a flag, so a user does not need to know which file they hold.
Opened `mode=ro&immutable=1` — no WAL recovery write against a file pulled off a
phone, the same reasoning Tools/SleepBench documents.

`unpack_v18_aux` is a Python port of Swift `V18AuxCodec.unpack`. It is pinned
against the SHIPPED codec rather than round-tripped against itself: both test
fixtures are literal `V18AuxCodec.pack` output captured from
`Packages/WhoopStore`, so a wire-format change fails the test instead of
silently mis-slicing real rows into plausible-looking numbers.

ONE THING AN APP STORE CANNOT DO, reported rather than papered over. The app
banks decoded slots, not frame bytes, so there is no neighbour byte to rank @82
against and the OFFSET SPECIFICITY SCAN cannot run. Recording `offset_82_wins:
false` there would fail the gate for a reason that is about the input format,
and "@82 did not win" is precisely the wrong thing to record about a byte that
was never ranked — so it is n/a, with `source` and `specificity_scan` naming
which of the two happened. A capture is still required to settle specificity.
`byte_at_offset` returns None (not 0) for unknowable offsets, because 0 is a
legitimate @82 reading outside the duty window and "cannot know" read as "read
zero" would manufacture out-of-band samples and depress coverage.

Nothing about the promote gate is relaxed: no card, no score, no `spo2Pct`, and
the multi-device thresholds are untouched. This only widens who can produce the
evidence.

tools/linux-capture: 50 tests, 0 failures (39 before, 11 new).

@ryanbr ryanbr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Ran the suite rather than take the number on trust — 50 tests, all pass. Checked the file can't write to
anyone's phone database too: both opens are mode=ro&immutable=1, and there is no INSERT, UPDATE, DELETE
or commit anywhere in it. Refusing to guess when a store holds two straps is right, and so is reporting
the specificity scan as "not applicable" instead of "failed" — a byte that was never ranked didn't lose.

One thing, and it isn't yours: nothing in CI runs these tests. No workflow touches
tools/linux-capture, so those 50 tests only ever run when someone remembers to. Same gap as #935.
Not a reason to hold this up, but it's the second PR this week adding good tests that nothing will
execute again.

Approving.

@ryanbr
ryanbr merged commit 8fd42c7 into ryanbr:main Jul 29, 2026
2 checks passed
@vishk23
vishk23 deleted the feat/spo2-candidate-app-db branch July 30, 2026 19:59
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