Skip to content

ble: extract dataRangeOldestUnix into WhoopProtocol.DataRange + pin the aligned-scan protection (#286 follow-up) - #308

Merged
ryanbr merged 1 commit into
mainfrom
extract-datarange-oldest
Jul 12, 2026
Merged

ryanbr merged 1 commit into
mainfrom
extract-datarange-oldest

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Follow-up to #307. Second (and last) sibling in the GET_DATA_RANGE parser.

Why

Like dataRangeNewestUnix (extracted in #307), dataRangeOldestUnix was inline in app-target BLEManager/WhoopBleClient with no test on either platform — yet it's parity-critical: it reports the banked span = backlog DEPTH a deep oldest-first drain must cover (#364).

The re-review catch (why this isn't a mechanical copy of #307)

The oldest read must NOT be "made consistent" with the newest by scanning every offset. The minimum is fragile where the maximum is not. On a real WHOOP 4.0 frame:

aa100057305d22009968526a083900001d2e2263
any-offset plausible words: offset6 -> 1754857506 (2025-08-10)   <- spurious straddle
                            offset8 -> 1783785625 (2026-07-11)   <- true newest

What

  • Extract the aligned scan verbatim (no behaviour change) into WhoopProtocol.DataRange.oldestUnix (Swift) + com.noop.protocol.DataRange.oldestUnix (Kotlin), byte-identical twins.
  • App-target methods become thin wrappers — call sites unchanged.
  • Add Swift + Kotlin tests that pin the protection: real frame → nil (straddle skipped), grid-aligned words → min. So a future "consistency" refactor can't silently reintroduce the false-backlog bug. The asymmetry with newestUnix is documented in both sources.

Verification

  • WhoopProtocol swift test: 262/262 (+3 oldest) — run locally on Linux.
  • Android compileFullDebugKotlin + DataRangeScanTest green (wrapper → pure).
  • BLEManager compile: app-build.
  • No BLE behaviour change (pure verbatim extraction) — nothing new to test on hardware.

…he aligned-scan protection (#286 follow-up)

Second sibling in the GET_DATA_RANGE parser. Like dataRangeNewestUnix (extracted
in #307), dataRangeOldestUnix was inline in app-target BLEManager/WhoopBleClient
with NO test on either platform — a parity-critical decoder (it reports the banked
span = backlog DEPTH a deep oldest-first drain must cover, #364).

Re-review of #307 surfaced that the oldest read must NOT be "made consistent" with
the newest by scanning every offset. The minimum is fragile where the maximum is
not: a real WHOOP 4.0 frame (aa10..2263) carries a spurious plausible straddle at
byte offset 6 (1754857506, ~11 months BEFORE the true newest at offset 8). An
any-offset MIN would grab it and report a bogus ~11-month backlog; the aligned-
from-7 grid skips it. So this extracts the aligned scan VERBATIM (no behaviour
change) into WhoopProtocol.DataRange.oldestUnix + com.noop.protocol twin, keeps the
app-target methods as thin wrappers, and adds Swift+Kotlin tests that PIN the
protection (real frame -> nil; grid-aligned words -> min) so a future refactor
can't quietly reintroduce the false-backlog bug.

WhoopProtocol swift test 262/262 (+3 oldest); Android compileFullDebugKotlin +
DataRangeScanTest green. BLEManager compile: app-build.
@ryanbr
ryanbr merged commit b107e2a into main Jul 12, 2026
10 checks passed
@ryanbr
ryanbr deleted the extract-datarange-oldest branch July 12, 2026 04:48
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…he aligned-scan protection (ryanbr#286 follow-up) (ryanbr#308)

Second sibling in the GET_DATA_RANGE parser. Like dataRangeNewestUnix (extracted
in ryanbr#307), dataRangeOldestUnix was inline in app-target BLEManager/WhoopBleClient
with NO test on either platform — a parity-critical decoder (it reports the banked
span = backlog DEPTH a deep oldest-first drain must cover, ryanbr#364).

Re-review of ryanbr#307 surfaced that the oldest read must NOT be "made consistent" with
the newest by scanning every offset. The minimum is fragile where the maximum is
not: a real WHOOP 4.0 frame (aa10..2263) carries a spurious plausible straddle at
byte offset 6 (1754857506, ~11 months BEFORE the true newest at offset 8). An
any-offset MIN would grab it and report a bogus ~11-month backlog; the aligned-
from-7 grid skips it. So this extracts the aligned scan VERBATIM (no behaviour
change) into WhoopProtocol.DataRange.oldestUnix + com.noop.protocol twin, keeps the
app-target methods as thin wrappers, and adds Swift+Kotlin tests that PIN the
protection (real frame -> nil; grid-aligned words -> min) so a future refactor
can't quietly reintroduce the false-backlog bug.

WhoopProtocol swift test 262/262 (+3 oldest); Android compileFullDebugKotlin +
DataRangeScanTest green. BLEManager compile: app-build.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…he aligned-scan protection (ryanbr#286 follow-up) (ryanbr#308)

Second sibling in the GET_DATA_RANGE parser. Like dataRangeNewestUnix (extracted
in ryanbr#307), dataRangeOldestUnix was inline in app-target BLEManager/WhoopBleClient
with NO test on either platform — a parity-critical decoder (it reports the banked
span = backlog DEPTH a deep oldest-first drain must cover, ryanbr#364).

Re-review of ryanbr#307 surfaced that the oldest read must NOT be "made consistent" with
the newest by scanning every offset. The minimum is fragile where the maximum is
not: a real WHOOP 4.0 frame (aa10..2263) carries a spurious plausible straddle at
byte offset 6 (1754857506, ~11 months BEFORE the true newest at offset 8). An
any-offset MIN would grab it and report a bogus ~11-month backlog; the aligned-
from-7 grid skips it. So this extracts the aligned scan VERBATIM (no behaviour
change) into WhoopProtocol.DataRange.oldestUnix + com.noop.protocol twin, keeps the
app-target methods as thin wrappers, and adds Swift+Kotlin tests that PIN the
protection (real frame -> nil; grid-aligned words -> min) so a future refactor
can't quietly reintroduce the false-backlog bug.

WhoopProtocol swift test 262/262 (+3 oldest); Android compileFullDebugKotlin +
DataRangeScanTest green. BLEManager compile: app-build.
ryanbr added a commit that referenced this pull request Sep 7, 2026
A night whose sleep runs are all shorter than minSleepMin yields no session
at all, however much sleep they add up to. From the report in #1937:

  sleep-detect summary: sleepRuns=5 droppedMinSleep=5 kept=0
  detectedSpanMin=298 survivingSpanMin=0 sparse=false

The detector found 298 minutes of sleep across five runs and dropped every
one, because each run individually fell about a minute under the 60-minute
floor.

The bridge that exists to re-stitch fragments was switched off, because it
is gated on the night being sparse (#308, so a dense night kept its original
path byte-for-byte) and the night was dense: gravity coverage on the
reporting device was 99.9%. The rescue was declined precisely because the
data was good.

So the bridge now also runs when the night is fragmented to nothing. The
gate is deliberately "today's answer is zero": if any single run already
clears the floor the predicate is false, the sparse rule decides exactly as
before, and a night that currently scores cannot change. It can only turn
nothing into something.

Requiring at least two runs keeps one short fragment from qualifying, and
requiring their sum to clear the floor keeps a handful of brief stirs from
becoming a night. The bridge's own rules still apply underneath: a gap over
sparseBridgeGapMin, an intervening active run that is too long, or HR above
the sleep band all still refuse. This enables the attempt, it does not
weaken what the attempt checks.

The predicate is deliberately not passed to buildRuns, which takes its own
sparse flag for the HR-vouched gap rule. That decides how runs are formed,
so widening it would change the input to everything downstream including
nights that currently score.

The E10 trace line now names which gate opened the bridge, so a dense night
rescued by this path does not print sparse=true and contradict the summary
line beside it.

Swift and Kotlin twins with matching tests on both sides.
ryanbr added a commit that referenced this pull request Sep 7, 2026
A night whose sleep runs are all shorter than minSleepMin yields no session
at all, however much sleep they add up to. From the report in #1937:

  sleep-detect summary: sleepRuns=5 droppedMinSleep=5 kept=0
  detectedSpanMin=298 survivingSpanMin=0 sparse=false

The detector found 298 minutes of sleep across five runs and dropped every
one, because each run individually fell about a minute under the 60-minute
floor.

The bridge that exists to re-stitch fragments was switched off, because it
is gated on the night being sparse (#308, so a dense night kept its original
path byte-for-byte) and the night was dense: gravity coverage on the
reporting device was 99.9%. The rescue was declined precisely because the
data was good.

So the bridge now also runs when the night is fragmented to nothing. The
gate is deliberately "today's answer is zero": if any single run already
clears the floor the predicate is false, the sparse rule decides exactly as
before, and a night that currently scores cannot change. It can only turn
nothing into something.

Requiring at least two runs keeps one short fragment from qualifying, and
requiring their sum to clear the floor keeps a handful of brief stirs from
becoming a night. The bridge's own rules still apply underneath: a gap over
sparseBridgeGapMin, an intervening active run that is too long, or HR above
the sleep band all still refuse. This enables the attempt, it does not
weaken what the attempt checks.

The predicate is deliberately not passed to buildRuns, which takes its own
sparse flag for the HR-vouched gap rule. That decides how runs are formed,
so widening it would change the input to everything downstream including
nights that currently score.

The E10 trace line now carries both gates as their own k=v keys, sparse= and
fragmentedToNothing=, rather than one flag naming the winner. It used to
hardcode sparse=true, which since this change could contradict the
sparse=false on the summary line beside it. Emitting both keeps the key set
identical on every night, which is what a reader diffing two nights, and any
k=v parser of the detail field, needs.

Swift and Kotlin twins with matching tests on both sides, including an
end-to-end one that runs the reported shape (five 59-minute runs, dense
gravity, four stirs the bridge must chain through) all the way through
detectSleep and asserts both that a session appears and that the rescue is
what produced it.
ryanbr added a commit that referenced this pull request Sep 8, 2026
A night whose sleep runs are all shorter than minSleepMin yields no session
at all, however much sleep they add up to. From the report in #1937:

  sleep-detect summary: sleepRuns=5 droppedMinSleep=5 kept=0
  detectedSpanMin=298 survivingSpanMin=0 sparse=false

The detector found 298 minutes of sleep across five runs and dropped every
one, because each run individually fell about a minute under the 60-minute
floor.

The bridge that exists to re-stitch fragments was switched off, because it
is gated on the night being sparse (#308, so a dense night kept its original
path byte-for-byte) and the night was dense: gravity coverage on the
reporting device was 99.9%. The rescue was declined precisely because the
data was good.

So the bridge now also runs when the night is fragmented to nothing. The
gate is deliberately "today's answer is zero": if any single run already
clears the floor the predicate is false, the sparse rule decides exactly as
before, and a night that currently scores cannot change. It can only turn
nothing into something.

Requiring at least two runs keeps one short fragment from qualifying, and
requiring their sum to clear the floor keeps a handful of brief stirs from
becoming a night. The bridge's own rules still apply underneath: a gap over
sparseBridgeGapMin, an intervening active run that is too long, or HR above
the sleep band all still refuse. This enables the attempt, it does not
weaken what the attempt checks.

The predicate is deliberately not passed to buildRuns, which takes its own
sparse flag for the HR-vouched gap rule. That decides how runs are formed,
so widening it would change the input to everything downstream including
nights that currently score.

The E10 trace line now carries both gates as their own k=v keys, sparse= and
fragmentedToNothing=, rather than one flag naming the winner. It used to
hardcode sparse=true, which since this change could contradict the
sparse=false on the summary line beside it. Emitting both keeps the key set
identical on every night, which is what a reader diffing two nights, and any
k=v parser of the detail field, needs.

Swift and Kotlin twins with matching tests on both sides, including an
end-to-end one that runs the reported shape (five 59-minute runs, dense
gravity, four stirs the bridge must chain through) all the way through
detectSleep and asserts both that a session appears and that the rescue is
what produced it.
ryanbr added a commit that referenced this pull request Sep 8, 2026
A night whose sleep runs are all shorter than minSleepMin yielded no session
at all, however much sleep they added up to: five runs, 298 detected minutes,
every one about a minute under the floor, reported in #1937.

The bridge that re-stitches fragments was gated on the night being sparse
(#308), and the reporting device was 99.9% dense, so the rescue was declined
precisely because the data was good. It now also runs when the night is
fragmented to nothing, a gate that can only turn nothing into something: if
any single run already clears the floor the sparse rule decides exactly as
before.

Swift and Kotlin twins, 7 tests each, including an end-to-end pass over the
reported shape with an anti-vacuity assertion.
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