tools(spo2): account for @82's duty cycle, screen for variance, and classify a strap that never emits it#860
Conversation
…lassify a strap that never emits it validate_spo2_candidate.py (#814, extended by #819) decides whether spo2_candidate_82 graduates to a real spo2Pct. Three things about @82 make its current numbers unreliable, all established on an 18,650-record v18 corpus — 18,602 of them from @digitalerdude's public PacketLogger capture of an official-app overnight sync on #103, plus 48 from a NOOP sync. 1. @82 IS DUTY-CYCLED AND THE HARNESS DID NOT KNOW It is not sampled every second. Across the corpus it is nonzero in 450 records (2.41 %), carrying 24 distinct values in 1..160, and every one of those falls inside a window that repeats on a fixed period: 15 runs, EVERY ONE exactly 30 records long, every one starting at the same unix % 1200, zero phase variance. Outside the window the byte is identically 0x00. The consequence is the reason this matters: a capture (or a sampling scheme) not aligned to that phase reads all zeros and is indistinguishable from a strap with the feature switched off. Sub-sampling the corpus at one record per minute off-phase reproduces it exactly — 310 records, zero nonzero @82, 0/16 windows covered — which is a plausible contributor to the contradictory cross-device evidence on #103, where one device correlated +0.99 and another moved opposite. So the tool now measures the schedule rather than assuming one. detect_duty_cycle() reports period, phase, window length, window count and phase jitter, and classifies the capture as absent / continuous / duty_cycled / irregular; only duty_cycled changes how nights are aggregated, so a capture it cannot characterise is never silently reweighted. On the reference corpus it recovers period=1200s, window=30s, phase=337, 15 windows, jitter=0, all windows equal length — with nothing hard-coded. The phase offset is deliberately NOT a constant. The robust fact is that a periodic window exists and has a stable length; 337 is one strap's firmware and there is no evidence it generalises, so the tests plant 900/111/20 and 600/42/10 schedules and require the tool to find them. A hard-coded phase would fail there instead of quietly mis-scoring a stranger's strap. Two consequences for the arithmetic. The nightly aggregate is now the mean of the per-window means, not the mean of every second: a 30-second burst is one measurement the strap took, not 30 independent ones, and per-second averaging hands the night to whichever window the capture happened to sample most densely. And coverage (windows sampled / windows expected) is reported per night with a loud warning below --min-window-coverage, because a night built on 2 of 21 windows is not comparable to one built on all 21. 2. NO VARIANCE FLOOR "The value lands in 70-100" is not a specific screen. On a real subscription-free 5.0 strap six offsets are in-band on a majority of records — @17 (100 %), @33 (90 %), @59 (62 %), @69 (100 %), @71 (100 %), @107 (62 %) — and every one is either an already-decoded non-SpO₂ field (@33 cardiac_flags, @59 step_cadence, @69/@71 the two aux thermal channels) or near-constant: @17 is byte 2 of the u32 unix timestamp at @15, so it reads 99-100 for a day and a half of wall clock, and @107 has 4 distinct values. A nightly SpO₂ cannot have 2 distinct values. The specificity scan therefore requires an offset to show >=5 distinct in-band values and stdev >=0.5 before its correlation is ranked, and rejected offsets are reported rather than dropped silently. @82 is held to the same floor as a checklist gate. On the reference corpus @82 clears it (14 distinct, stdev 3.17) and @75 does not (1 value). The regression test plants a 2-valued byte at @84 that correlates with the export *better* than @82 does: without the floor the flag wins the scan and the tool reports "not specific to @82". 3. A FLAT-ZERO STRAP WAS BEING FAILED, NOT CLASSIFIED A subscription-free strap reads @82 = 0x00 on 100 % of its v18 records, including genuine deep sleep. That device has not failed a correlation — it has no data to correlate, and counting it as a FAIL biases the multi-device promote gate against a candidate for a reason that has nothing to do with the candidate. It is now classified feature_absent and counted as neither a PASS nor a FAIL, with an evidence bar (>=100 records and >=3 nominal periods of scored sleep) so that a short capture that merely missed the window is not dressed up as an absence claim — that stays a plain FAIL. The --postable block keeps its privacy property: the columns added are device timing (mode / period / window / phase / coverage) and a classification, never a health value. The phase is in fact the useful thing to share, since it is what another owner needs to align a capture. Tests: tools/linux-capture 178 -> 194 (193 passed, 1 skipped, 0 failed); the spo2 file goes 16 -> 32. New coverage: schedule recovery at two planted non-reference schedules, refusal to claim a period for scattered firings, aligned vs phase-misaligned coverage, per-window vs per-second aggregation, the variance floor rejecting a better-correlating near-constant byte, the floor applied to @82 itself, feature_absent classification and its evidence bar, and the postable block still carrying no raw values. Serves the #103 promote gate directly: spo2_candidate_82 -> spo2Pct needs >=2 devices that each PASS, and until now a device could fail for having captured the wrong 30 seconds of every 20 minutes. Related: #845 (decoded-but-unread v18 fields).
ryanbr
left a comment
There was a problem hiding this comment.
Findings 1 and 2 hold up under independent testing. Finding 3 has a gap that undercuts finding 1, and I think it should be closed before this merges — it is small.
What I verified
- Suite goes 178 → 194 and the validator file 16 → 32, all green on both branches (
unittest discover; pytest is not installed here). detect_duty_cyclereally is measuring, not assuming. I planted four schedules the PR never uses — includingphase > periodand a 2-minute period — and it recovered every one exactly.337and the corpus counts appear only in comments.- It refuses when it should: 2 windows, scattered firings, and phase jitter all fall through to
irregular, and the jitter tolerance scales with capture cadence rather than being fixed. Robust to 3 of 10 windows missing without the period collapsing to a multiple. --postablegenuinely carries no raw values. Ran it with distinctive per-night values 91/93/95/97/92/98; none appear. The new columns are timing only. (Note the new test asserts this on a flat-zero device, which cannot leak anyway — the older test at :213 is the one carrying the weight.)
The gap: feature_absent fires on capture aliasing, not just an absent feature
Finding 1 establishes that an off-phase capture reads all zeros and is indistinguishable from a strap with the feature switched off. Finding 3 then treats all-zero as evidence the feature is absent.
Same strap, same firmware, @82 duty-cycling normally at 1200/337/30, six nights of eight hours scored sleep. Only the capture cadence differs:
1 Hz -> duty_cycled fail
1/5min -> absent feature_absent
The evidence bar does not catch it because ABSENT_MIN_RECORDS and ABSENT_MIN_ASLEEP_SPAN_S both measure duration, and this is a phase problem. A 300 s cadence against a 1200 s period only ever occupies 4 residues mod the period, so it either always hits the window or never does — 576 records and 48 h of sleep clear both bars comfortably.
This matters more than a normal misclassification because it is asymmetric. feature_absent removes the device from eligible, so all_pass is computed without it. A device that would have blocked promotion instead silently drops out, and the bar is all_pass and ≥2 devices. It makes promoting easier, which is the wrong direction for a safety gate.
Suggested fix — the quantity is already there. record_interval_s is computed even when mode == "absent", so gate the claim on the capture being able to see a window at all:
feature_absent = (
duty["mode"] == "absent"
and len(records) >= ABSENT_MIN_RECORDS
and asleep_span >= ABSENT_MIN_ASLEEP_SPAN_S
and duty["record_interval_s"] <= NOMINAL_DUTY_WINDOW_S # else the capture could have skipped it
)with NOMINAL_DUTY_WINDOW_S = 30 carrying the same "reference strap, not a law" caveat the period constant already has. A coarser capture stays a plain FAIL, or gets its own inconclusive bucket if you would rather not conflate it. Worth a test planting a working strap sampled at a cadence that aliases with the period — that is the case the current tests do not have.
Everything else reads well, and the per-window aggregation argument is convincing on its own. Tools-only, no app code, no parity surface, so the risk here is confined to how much the harness can be trusted — which is exactly what finding 3 is for.
Maintainer decision: merging as-is. The feature_absent cadence gate lands as an immediate follow-up.
|
Merged. The cadence gate from my review is up as #868 — it also repoints the postable privacy test, which was asserting against the flat-zero fixture that has no values to leak. Findings 1 and 2 held up under independent testing: |
…e window (#868) Follow-up to #860, which added the feature_absent classification. Tools + docs only. Its evidence bar was ABSENT_MIN_RECORDS plus ABSENT_MIN_ASLEEP_SPAN_S — both durations. But missing a duty window is a PHASE problem, so neither caught an aliased capture. A cadence sharing a large factor with the period only ever occupies period/gcd residues; at 300 s against the reference 1200 s that is four, so the capture either always lands inside the window or never does. Six nights of eight hours scored sleep then read a flat 0x00 off a strap whose @82 duty-cycles perfectly. Same strap, same firmware, only the cadence differing: 1 Hz duty_cycled fail 1/5 min absent feature_absent <- before this change This is #860 finding 1 — an off-phase capture is indistinguishable from a disabled feature — applied to its own finding 3. WHY IT MATTERS MORE THAN A PLAIN MISCLASSIFICATION. It is asymmetric. feature_absent REMOVES a device from `eligible`, and all_pass is computed over what remains, so a device that would have blocked promotion silently drops out instead. Over-claiming absence loosens the gate that decides whether spo2_candidate_82 graduates. Three review passes each found a further route to the same wrong answer, one layer down from the last. All four are now closed and pinned by tests: • no cadence gate at all — an aliased capture read as absent; • the duration bar compared max-minus-min, which counts gaps, so a capture that ran densely for two minutes then logged one record eight hours later scored an 8 h span off 121 s of observation; • observed time counted RECORDS, so 200 asleep seconds re-delivered twenty times scored 4000 s — not hypothetical, whoop_sync.py re-delivers historical rows when it resumes; • the cadence came from the whole-capture median, which a dense awake stretch sets, hiding a sleep cadence that aliases. The invariant is now stated once and measured over the population it is about: OBSERVED DISTINCT ASLEEP SECONDS, AT THE ASLEEP CADENCE. An absence claim requires that observed time to clear ABSENT_MIN_ASLEEP_SPAN_S and that cadence to be at or below NOMINAL_DUTY_WINDOW_S, since a cadence at or below the window length cannot skip a window whatever the phase. The 30 s figure carries the same caveat as NOMINAL_DUTY_PERIOD_S: it is the reference strap window, used only as a minimum-evidence bar, never as a detector input. detect_duty_cycle is untouched — its schedule recovery still measures everything per capture, verified independently against four schedules the tests do not plant, including phase > period. asleep_interval_s and observed_asleep_s are reported beside the whole-capture figures, because the two diverging is itself the signal of a gappy or mixed-cadence capture. format_duty_line states why a claim was withheld and quotes the asleep cadence rather than the whole-capture one — quoting the number the gate deliberately does not trust would print a reassuring 1 s while refusing on 300 s. ALSO: the postable privacy test was passing for the wrong reason. It asserted no raw values against the flat-zero fixture, a device with no SpO2 readings to leak, so it passed whatever the formatter did with them. It now runs on six distinct per-night values and asserts they were paired first. The flat-zero case is kept as its own test. The property does hold — verified by hand before the test changed. TESTS. tools/linux-capture 194 -> 201, validator file 32 -> 39, all pre-existing tests unchanged and passing. Coverage for every route above, plus the two cases that must keep working: a genuine flat-zero strap at a 5 s cadence over 8 h, and the boundary at exactly the 30 s window length, so the gate cannot be quietly over-tightened later. SCOPE. No app code, no decoder, no schema, no Swift/Kotlin. spo2_candidate_82 remains instrumentation-only. Serves the #103 promote gate.
tools/linux-capture/validate_spo2_candidate.py(#814, extended by #819) is what decides whetherspo2_candidate_82graduates to a realspo2Pct. Three properties of@82make its current numbersunreliable — one of them badly enough to explain the split evidence on #103.
All three were established on an 18,650-record v18 corpus: 18,602 of them from
@digitalerdude's public PacketLogger capture of an official-app
overnight sync (posted on #103 — thank you, this analysis is not possible without it), plus 48 from a
NOOP sync on a subscription-free strap.
1.
@82is duty-cycled, and the harness did not knowIt is not sampled every second. Across the corpus it is nonzero in 450 records (2.41 %), carrying
24 distinct values in 1..160, and every one of those falls inside a window that repeats on a fixed
period:
@82 != 0unix % 1200 == 337on all 15 — zero variance0x00So
@82is a 30-second measurement taken once every 20 minutes.Why that breaks validation: a capture (or a sampling scheme) not aligned to that phase reads all
zeros and is indistinguishable from a strap with the feature switched off. Sub-sampling this very
corpus at one record per minute, off-phase, reproduces it exactly — 310 records, zero nonzero
@82, 0/16 windows covered. That is a plausible contributor to the contradictory cross-deviceevidence this issue is stuck on, where one device correlated +0.99 and another moved opposite:
the second device may not have disagreed so much as missed the window.
What this PR changes
detect_duty_cycle()measures the schedule per capture — period, phase, window length, windowcount, phase jitter — and classifies the capture
absent/continuous/duty_cycled/irregular. Onlyduty_cycledchanges how nights are aggregated, so a capture the tool cannotcharacterise is never silently reweighted. On the reference corpus it recovers
period=1200s window=30s phase=337 windows=15 jitter=0 equal_length_windows=True, with nothinghard-coded.
has a stable length;
337is one strap's firmware and there is no evidence it generalises. Thetests plant
900/111/20and600/42/10schedules and require the tool to find them — a hard-codedphase fails there, rather than quietly mis-scoring a stranger's strap.
30-second burst is one measurement the strap took, not 30 independent ones; per-second averaging
hands the night to whichever window the capture happened to sample most densely. (On the real
corpus the two differ: 95.92 per-second vs 93.70 per-window.)
--min-window-coverage(default 0.5), because a night built on 2 of 21 windows is not comparable toone built on all 21.
2. In-band-only screening is not specific
The harness screens candidate bytes by "value in 70–100". That is a numeric range, not a signature.
On a real subscription-free 5.0 strap, six offsets are in-band on a majority of records:
@17@15— reads 99–100 for a day and a half of wall clock@33cardiac_flags(already decoded)@59step_cadence(already decoded)@69temp_aux_1_raw(aux thermal channel)@71temp_aux_2_raw(aux thermal channel)@107Every one is either an already-decoded non-SpO₂ field or near-constant. A nightly SpO₂ cannot have
2 distinct values. The timestamp passing an SpO₂ screen is the clearest statement of the problem.
The specificity scan now requires an offset to show ≥5 distinct in-band values and stdev ≥0.5
before its correlation is ranked, rejected offsets are reported rather than dropped silently, and
@82is held to the same floor as a checklist gate. On the reference corpus@82clears it(14 distinct, stdev 3.17) and
@75does not (1 value).The regression test plants a 2-valued byte at
@84that correlates with the export better than@82does. Without the floor the flag wins the scan and the tool reports "not specific to@82" —exactly the wrong conclusion, from a byte that cannot be a measurement.
3. A strap that never emits
@82is now classified, not failedA subscription-free strap reads
@82 = 0x00on 100 % of its v18 records, including genuine deepsleep. That device has not failed a correlation — it has no data to correlate, and counting it as a
FAIL biases the multi-device promote gate against the candidate for a reason that has nothing to do
with the candidate.
It is now classified
feature_absentand counted as neither a PASS nor a FAIL:There is an evidence bar (≥100 records and ≥3 nominal periods of scored sleep) so that a short capture
which merely missed the window is not dressed up as an absence claim — that stays a plain FAIL. Given
finding 1, the distinction is the whole point.
Privacy
--postablekeeps its property of carrying no raw SpO₂ values. The added columns are devicetiming (
duty_mode,duty_period_s,duty_window_s,duty_phase_s,window_coverage) and aclassification. The phase is in fact the useful thing to share, since it is what another owner needs
in order to align a capture.
Tests
tools/linux-capture: 178 → 194 (193 passed, 1 skipped, 0 failed) —python3 -m pytest tools/linux-capture/ -q, andpython3 -m unittest discover(the runner the README documents) agreesat 194.
test_validate_spo2_candidate.pygoes 16 → 32; all 16 pre-existing tests are unchangedand still pass.
New coverage: schedule recovery at two planted non-reference schedules, refusal to claim a period
for scattered firings, aligned vs phase-misaligned coverage, per-window vs per-second aggregation, the
variance floor rejecting a better-correlating near-constant byte, the floor applied to
@82itself,feature_absentclassification and its evidence bar, and the postable block still carrying no rawvalues.
Also verified end-to-end against the real corpus through the actual CLI path, not only synthetic
fixtures.
Scope
Python capture tooling plus the two doc sections that enumerate the harness's gates. No app code, no
decoder, no schema, no Swift/Kotlin —
spo2_candidate_82remains instrumentation-only and this doesnot move it.
Serves the #103 promote gate directly:
spo2_candidate_82→spo2Pctneeds ≥2 devices that eachPASS, and until now a device could fail for having captured the wrong 30 seconds out of every 20
minutes. Related: #845 (decoded-but-unread v18 fields —
@82is one of them).Corpus credit again to @digitalerdude (#103).