Skip to content

tools(spo2): an absence claim needs a capture that could have seen the window#868

Merged
ryanbr merged 3 commits into
mainfrom
tools/spo2-absence-cadence-gate
Jul 27, 2026
Merged

tools(spo2): an absence claim needs a capture that could have seen the window#868
ryanbr merged 3 commits into
mainfrom
tools/spo2-absence-cadence-gate

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Follow-up to #860, which added the feature_absent classification. Tools + docs only.

Its evidence bar is ABSENT_MIN_RECORDS plus ABSENT_MIN_ASLEEP_SPAN_S — both durations. But
missing a duty window is a phase problem, so neither catches 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
is duty-cycling 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's own finding 1 — an off-phase capture is indistinguishable from a disabled feature
— applied to its 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 — the wrong direction for the thing that decides whether
spo2_candidate_82 graduates.

The change

An absence claim now also requires record_interval_s <= NOMINAL_DUTY_WINDOW_S. A cadence at or
below the window length cannot skip a window whatever the phase; anything coarser stays a plain FAIL.

The 30 s figure carries the same caveat as NOMINAL_DUTY_PERIOD_S: it is the reference strap's
window, used only as a minimum-evidence bar, never as a detector input. Nothing in
detect_duty_cycle changes — its schedule recovery is untouched and still measures everything per
capture.

format_duty_line now states why the claim was withheld, rather than leaving an unexplained FAIL —
same principle as #860 reporting rejected offsets instead of dropping them silently:

@82 duty cycle: mode=absent  nonzero=0/576 (0.00%)  distinct_values=0
  NOT claiming feature_absent: 300s cadence is coarser than a nominal 30s window, so an aliased
  capture could read 0x00 off a working strap. Recapture at ≤30s to make an absence claim.

Second hole, same class, found re-reviewing this PR

ABSENT_MIN_ASLEEP_SPAN_S was compared against max(asleep) - min(asleep), which counts the gaps.
A capture that ran densely for two minutes and then logged a single record eight hours later:

span = 28800s   observed = 121s   -> feature_absent

121 records clears the count bar, the 8 h span clears the duration bar, and the 1 s median cadence
sails through the window gate above. Same wrong answer as the aliasing case, same underlying reason:
the bar was measuring something that is not observation. It now uses
len(asleep_stamps) * record_interval_s — what the capture actually watched.

Both figures are reported. Span and observed diverging is the signature of a gappy capture, so the
pair says more to whoever reads the output than either alone.

Checked against what must keep working: a genuine flat-zero strap at 5 s over 8 h still claims
absence, and so does the boundary at exactly the 30 s window length, so the gate is not
over-tightened.

Also: the postable privacy test was passing for the wrong reason

test_postable_still_carries_no_raw_spo2_values asserted assertNotIn("96.00", blob) against the
flat-zero fixture — a device with no SpO₂ readings to leak, so it passed whatever the formatter
did with them. It now runs on a device with six distinct per-night values (91/93/95/97/92/98) and
checks each, with an assertion that the values really were paired first. The flat-zero case is kept
as its own test. The property does hold — verified by hand before changing the test.

Tests

tools/linux-capture 194 → 199, validator file 32 → 37, all pre-existing tests unchanged and
passing (python3 -m unittest discover).

New coverage: a coarse all-zero capture refusing the claim; an aliased capture of a working strap
(the case the duration bars let through); and the boundary where a cadence exactly at the window
length still claims absence, so the gate cannot be quietly over-tightened in a later edit.

Scope

No app code, no decoder, no schema, no Swift/Kotlin. spo2_candidate_82 remains
instrumentation-only. Serves the #103 promote gate.

…e window

Follow-up to #860, which added the feature_absent classification. Its evidence bar is
ABSENT_MIN_RECORDS plus ABSENT_MIN_ASLEEP_SPAN_S — both durations — but missing a duty window is a
PHASE problem, so neither catches 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 is
duty-cycling perfectly, and the tool reported it as lacking the feature. 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's own finding 1 — an off-phase capture is indistinguishable from a disabled feature —
applied to its finding 3.

It matters more than a plain misclassification because 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.

So an absence claim now also requires record_interval_s <= NOMINAL_DUTY_WINDOW_S. A cadence at or
below the window length cannot skip a window whatever the phase; anything coarser stays a plain FAIL.
The 30 s figure carries the same caveat as NOMINAL_DUTY_PERIOD_S: it is the reference strap's window,
used only as a minimum-evidence bar, never as a detector input. Nothing in detect_duty_cycle changes.

format_duty_line now states WHY the claim was withheld rather than leaving an unexplained FAIL, the
same principle as reporting rejected offsets instead of dropping them.

Also repoints the postable privacy test. It asserted no raw values against the flat-zero fixture,
which has no readings to leak and so passed regardless of what the formatter did; it now runs on a
device with six distinct per-night values and checks each. The flat-zero case is kept as its own
test. Verified the property holds either way.

tools/linux-capture 194 -> 198, validator file 32 -> 36, all pre-existing tests unchanged and
passing. New coverage: a coarse all-zero capture refusing the claim, an aliased capture of a working
strap, and the boundary where a cadence exactly at the window length still claims absence, so the
gate cannot be quietly over-tightened.
ryanbr added 2 commits July 26, 2026 22:31
Re-reviewing my own change turned up a second hole of the same class, reached by a different route.

ABSENT_MIN_ASLEEP_SPAN_S was compared against max(asleep) - min(asleep), which counts the gaps. A
capture that ran densely for two minutes and then logged a single record eight hours later scores an
8 h "span" off 121 s of actual observation, clears the record-count bar with 121 records, and has a
1 s median cadence that sails through the window gate added in the previous commit:

  span = 28800s   observed = 121s   -> feature_absent

Same wrong answer as the aliasing case and for the same underlying reason: the bar was measuring
something that is not observation. Now compares len(asleep_stamps) * record_interval_s, which is what
the capture actually watched.

Both figures are reported. Span and observed diverging IS the signature of a gappy capture, so the
pair is more useful to whoever reads the output than either alone.

Checked against the cases that must keep working: a genuine flat-zero strap at a 5 s cadence over
8 h (observed 28800s) still claims absence, and the boundary case at exactly the 30 s window length
still claims it, so the gate is not over-tightened. The existing _flat_zero_device fixture is 851
records at 5 s = 4255 s observed and stays above the bar untouched.

One self-inflicted break on the way: removing the asleep_span binding orphaned its use in the result
payload, which took out 19 tests at once. It is restored and reported alongside the new figure.

tools/linux-capture 198 -> 199, validator file 36 -> 37.
A third re-review pass found two more routes to the same false absence, both from measuring the
wrong population rather than from the bars themselves.

The gates read duty.record_interval_s, the median over the WHOLE capture, and counted records rather
than distinct seconds. So:

  1 Hz awake + 300 s asleep     whole-capture median 1.0s -> window gate passes on a sleep cadence
                                that aliases against the duty period
  200 asleep seconds x20 dupes  4000 records x 1.0s = "4000s observed" off 200s of real data

The second is not hypothetical: whoop_sync.py resumes and reconnects, and re-delivers historical
rows when it does. _median_record_interval already dedupes with a set for exactly this reason; the
absence bars did not.

Both now derive from the asleep records only, over sorted distinct timestamps, with the cadence
measured on that same population. Reported as asleep_interval_s beside the whole-capture figure,
since the two diverging is itself the signal.

format_duty_line quotes the asleep cadence rather than the whole-capture one — quoting the number
the gate deliberately does not trust would have printed a reassuring 1 s while refusing the claim on
a 300 s cadence. It also now explains the observed-time refusal, which previously fell through
silently to an unexplained FAIL.

All four known routes to a false absence are covered by tests: aliased cadence, dense burst plus a
far record, duplicate timestamps, and a dense awake stretch hiding the sleep cadence. Both cases that
must keep working are pinned too: a genuine flat-zero strap at 5 s over 8 h, and the boundary at
exactly the 30 s window length.

tools/linux-capture 199 -> 201, validator file 37 -> 39.
@ryanbr
ryanbr merged commit 7d1f8dd into main Jul 27, 2026
2 checks passed
@ryanbr
ryanbr deleted the tools/spo2-absence-cadence-gate branch July 27, 2026 05:38
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.

1 participant