tools(spo2): an absence claim needs a capture that could have seen the window#868
Merged
Conversation
…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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #860, which added the
feature_absentclassification. Tools + docs only.Its evidence bar is
ABSENT_MIN_RECORDSplusABSENT_MIN_ASLEEP_SPAN_S— both durations. Butmissing 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 ÷ gcdresidues. At 300 sagainst 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
0x00off a strap whose@82is duty-cycling perfectly. Same strap, same firmware, only the cadence differing:
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_absentremoves a device fromeligible, andall_passis computedover 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_82graduates.The change
An absence claim now also requires
record_interval_s <= NOMINAL_DUTY_WINDOW_S. A cadence at orbelow 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'swindow, used only as a minimum-evidence bar, never as a detector input. Nothing in
detect_duty_cyclechanges — its schedule recovery is untouched and still measures everything percapture.
format_duty_linenow states why the claim was withheld, rather than leaving an unexplained FAIL —same principle as #860 reporting rejected offsets instead of dropping them silently:
Second hole, same class, found re-reviewing this PR
ABSENT_MIN_ASLEEP_SPAN_Swas compared againstmax(asleep) - min(asleep), which counts the gaps.A capture that ran densely for two minutes and then logged a single record eight hours later:
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_valuesassertedassertNotIn("96.00", blob)against theflat-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-capture194 → 199, validator file 32 → 37, all pre-existing tests unchanged andpassing (
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_82remainsinstrumentation-only. Serves the #103 promote gate.