Circadian: the input is heart rate, not motion — say so, and pin what the gate costs (#982) - #993
Conversation
… the gate costs (#982) ActivityBin.activity was documented as "the motion volume in that bin (e.g. StepsEstimateEngine.dayMotionIntensity per hour)". It has never been fed that. The only production caller pools per-hour MEAN HEART RATE in bpm (AppModel.swift, sums[hour] += b.bpm), which is the right choice on this hardware — WHOOP 4.0 motion is too sparse to stage sleep at all (#345) — but the doc has been telling readers the opposite since it was written. That matters because minRelativeAmplitude gates on amplitude / |mesor|, and HR arrives with a ~45-75 bpm DC offset a motion volume does not have. The effective bar is therefore an absolute 0.10 x mesor bpm. NOT re-tuned to an absolute floor, deliberately. #982 raised this as the gate penalising the fittest wearers, and that direction does not hold: because the bar scales WITH the mesor, a low-resting wearer faces a LOWER absolute requirement, not a higher one. The same 5 bpm swing is arrhythmic at a 65 bpm mesor and readable at 45. The concern only applies to someone whose amplitude is disproportionately small for their mesor, which is a single observation, and nobody is currently silenced by the gate. Shipping an invented bpm constant into everyone's analytics on that basis is exactly what the project's own rule about unvalidated tuning warns against. So: correct the record, and make the trade executable instead of arguable. A parity test pair pins what the gate costs at 65 and at 45 bpm, including the pair that shows the direction, so whoever does have the data to re-tune it starts from a fact. Both platforms; docs and tests only, no behaviour change.
|
Re-reviewed. Nothing found — the first pass today where that is the honest answer, so here is what was actually checked rather than a bare assertion. Executed the shipped engine, not my arithmetic. That last line is the finding this PR exists to record, confirmed empirically rather than derived: the identical 5 bpm swing is arrhythmic at a 65 bpm mesor and It also settles that the tests are not vacuous — the cosinor recovers the injected parameters to 1e-6, so each fixture really does land where the ratio says, and every case sits well clear of the 0.10 boundary (0.077 / 0.089 / 0.111 / 0.123), so no float wobble can flip one. The Swift twin is not executable here, but the two engines are byte-parity twins with existing tests pinning that, and the fixtures are identical — so the Kotlin result carries. The "no behaviour change" claim is verified, not asserted. Stripping comment lines from the production diff leaves nothing: both Also clear: no stacked doc comments (lint 0 — worth checking, since I inserted a KDoc above an existing The one thing I would still call soft is judgement, not correctness: whether to leave the gate relative at all. I have argued not to re-tune it on n=1 evidence and pinned the cost so the decision is informed — but that is a position, not a proof, and @vishk23 has the wearer data I do not. |
Addresses the
CircadianEnginehalf of #982 (@vishk23). Docs and tests only — no behaviour change.The documentation was wrong
ActivityBin.activitywas documented as "the motion volume in that bin (e.g.StepsEstimateEngine.dayMotionIntensityper hour)". It has never been fed that. The only production caller pools per-hour mean heart rate in bpm:HR is the right input here — WHOOP 4.0 motion is documented as too sparse to even stage sleep (#345) — so the caller is fine and the doc was simply describing a different design. Corrected on both platforms, with the reason recorded.
Why it matters, and what the gate actually costs
minRelativeAmplitudegates onamplitude / |mesor|, and HR arrives with a ~45–75 bpm DC offset that a motion volume does not have. So the real bar is an absolute0.10 × mesorbpm — roughly 6.5 bpm at a 65 bpm mesor, 4.5 at 45.I did NOT re-tune it, and the reason is a correction to the issue
#982 raises this as the gate penalising the fittest wearers. That direction does not hold. Because the bar scales with the mesor, a low-resting wearer faces a lower absolute requirement, not a higher one:
The same 5 bpm swing is arrhythmic at 65 and readable at 45. The concern only applies to someone whose amplitude is disproportionately small for their mesor — which is a single wearer’s observation, not an established pattern — and by @vishk23’s own account nobody is currently silenced by the gate.
Given that, swapping in an invented bpm constant would put unvalidated tuning into everyone’s analytics on n=1 evidence, which is exactly what
CLAUDE.md’s rule about deriving signals warns against. The shape of the gate is still arguably wrong for this domain; the case for any particular replacement value has not been made.So the deliverable is: correct the record, make the trade executable
A parity test pair pins what the gate costs at each mesor, including the pair that demonstrates the direction:
Whoever does have the data to re-tune this now starts from a measured fact rather than an argument — and if someone changes the constant, these fail and say what changed.
Verification
test (StrandAnalytics), Androidbuild-and-test).goodDaysForFityielding.solidon the readable path, so the non-unreadableassertions are meaningful rather than vacuous.swiftc -parseclean; doc-comment lint and i18n audit both 0.Not in scope
The other two #982 items — the unsurfaced
chargeConfidence/effortConfidence/restConfidencetier, and the cadence test pinningsolidEffortReadings/minHourHRSamples/maximumContinuousGapSeconds— are separate and both worth doing. I verified the confidence tier is genuinely unread onmain(declaration, init, computation, one test assertion, zero production readers).Worth noting for anyone reading #982 against this tree: four fields it names (
highStressMinutes,hrOnlyFallback,activityMaskedHours,daysBelowBand) do not exist onmain— they are added by #988, so that sweep was run against a branch, notmain.