Skip to content

Effort funnel: report the longest and mean bridged run - #1603

Merged
ryanbr merged 3 commits into
mainfrom
diag/effort-funnel-run-lengths
Aug 25, 2026
Merged

ryanbr merged 3 commits into
mainfrom
diag/effort-funnel-run-lengths

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 24, 2026 •

Copy link
Copy Markdown
Owner

Diagnostic only. The detector is untouched and no number moves.

What a field log showed

A WHOOP 4.0 strap log, 22 days, every one kept=0:

candidate runs 2,211
after the #303 bridge 1,661
never reached minExerciseMin 1,271
cleared duration, failed intensity 390
kept 0

droppedShort is tested first and short-circuits, so that second row means sustained five-minute-plus efforts reached the intensity gate on all 22 days — roughly 18 a day. The detector was right to reject them (the reporter confirmed they had not trained; sustained walking is not a workout), and the funnel's arithmetic is self-consistent throughout.

I also cleared the plausible bugs: bridgeGapS is 300 s, and bridgeRuns already treats an empty gap-HR as a sensor dropout and bridges it, so 4.0 motion sparsity was not silently shattering bouts.

The gap

The counts say where candidates died. They cannot say how substantial the best survivor was — and that is the whole difference between:

  • a five-minute stroll rejected on intensity — routine, an honest zero; and
  • an hour-long effort rejected on intensity — a defect worth chasing.

Both print identical counts. Working the first case out from this log meant inferring run length by dividing active by runs, which gave the wrong answer (it suggested one-minute bursts, while lowIntensity=15 proved runs were clearing five minutes every day).

The change

DetectionFunnel gains longestRunS and meanRunS, measured on the bridged runs before any qualification gate:

… active=6021 runs=89 bridged=79 longestRunS=312 meanRunS=41 short=64 lowIntensity=15 kept=0

Verification

  • Byte-identical line on both platforms; field order checked in both emitters.
  • A test per side pins the case that matters: two fixtures with identical counts taken from this log, separated only by longestRunS — 312 s (a walk) against 3600 s (worth chasing). The counts alone cannot tell them apart, which is the point.
  • Kotlin verified locally: DetectionFunnelTest 10/10, full suite 4307 green, assembleFullDebug and doc lint clean.
  • StrandAnalytics cannot build on Linux (GRDB's CSQLite needs sqlite3.h, per CLAUDE.md), so the Swift twin rides swift-packages CI rather than a local run.

Note on the second commit

The first commit read the same log as one-minute bursts of daily life and wrote that into both the docs and the tests. That contradicted the counts printed beside it — droppedLowIntensity cannot be non-zero unless runs cleared the duration bar. The second commit corrects the reading and replaces both fixtures, which previously could be distinguished by their counts alone and so did not test what they claimed to.

ryanbr added 3 commits August 25, 2026 11:22
A field log showed 22 days of `kept=0` on a WHOOP 4.0 — 2211 candidate
runs, 1661 after bridging, 76% dropped as too short, and roughly two hours
a day of "active" time above resting+15.

The counts alone cannot say which of two things that is. Normal life
clearing the floor in one-minute bursts is an honest zero. A real session
shattered into sub-threshold fragments is a bug. Both produce exactly the
shape above, and the existing line reports every gate except the one
number that separates them.

Settling it took asking the reporter whether they had trained — the round
trip an always-on diagnostic exists to remove. They had not, so the
detector was right and nothing needed fixing; but that answer could not be
read off the line, and next time the answer may be the other one.

The funnel now carries the longest and mean BRIDGED run in seconds,
measured before any qualification gate. Against this log it reads
longestRunS=74 on a five-minute bar — daily life, unrescuable by any
threshold change, and visibly so. A 25-minute run reported alongside
kept=0 would be the reading worth chasing.

Both platforms, byte-identical, with a test per side pinning that pair of
readings. No behaviour change: the detector is untouched and no number
moves.

Kotlin verified locally (DetectionFunnelTest 10/10, suite 4307/0,
assembleFullDebug, doc lint). StrandAnalytics needs macOS — GRDB's CSQLite
won't build on Linux — so the Swift twin rides swift-packages CI.
Re-review of my own commit, and the error was in the analysis rather than
the code.

`droppedShort` is tested first and short-circuits, so `droppedLowIntensity`
only ever counts runs that ALREADY cleared the duration bar. The log I
justified this change with reports lowIntensity>0 on all 22 days — 390
runs total — which means sustained five-minute-plus efforts were reaching
the intensity gate every single day. The previous commit described those
same days as one-minute bursts of daily life that no threshold could
rescue. That was wrong, and it was wrong in the doc comments and in both
tests, which is the worst place for it: a fixture asserting 74 s as the
longest run contradicted the counts printed beside it.

The corrected reading: 1271 runs never reached minExerciseMin, and 390 did
and failed on intensity instead. The counts already said the duration gate
was not the whole story. What they cannot say is how substantial the
survivors were — and a five-minute stroll rejected on intensity is
routine, while an hour-long effort rejected on intensity is a defect.

That is what the field is for, so both tests now pin exactly that: two
fixtures with IDENTICAL counts taken from the log, separated only by
longestRunS. The old pair could be told apart by the counts alone, so they
did not test what they claimed to.

Detector still untouched; no number moves. Kotlin verified locally
(DetectionFunnelTest 10/10, suite 4307/0, assembleFullDebug, doc lint);
the Swift twin rides swift-packages CI.
Third re-review pass. The docs told a reader to judge longestRunS by
whether it "cleared the duration bar" and described that bar as five
minutes. The gate is `minDurS - motionSmoothS` — 290 s, not 300, because
the smoothing window costs a run its first samples.

Ten seconds sounds immaterial until it is the whole verdict: a 295 s run
cleared the gate, and a reader comparing against a round five minutes
concludes it did not. This change exists so a line can be read correctly,
so an interpretation guide that is itself off by 10 s defeats it.

Both docs now name 290 s and where it comes from, and both tests pin the
number against the two constants it is derived from, so tuning either one
cannot leave the documented figure silently stale.

Detector untouched; no number moves. Kotlin verified locally (suite
4307/0, assembleFullDebug); Swift twin rides swift-packages CI.
@ryanbr
ryanbr merged commit 7bcd68a into main Aug 25, 2026
14 checks passed
@ryanbr
ryanbr deleted the diag/effort-funnel-run-lengths branch August 25, 2026 00:04
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