Skip to content

fix(day-cycle): recognize early bedtimes with the canonical sleep selector - #2649

Open
kavemang wants to merge 3 commits into
ryanbr:mainfrom
kavemang:agent/day-cycle-early-bedtime-2626
Open

kavemang wants to merge 3 commits into
ryanbr:mainfrom
kavemang:agent/day-cycle-early-bedtime-2626

Conversation

@kavemang

@kavemang kavemang commented Oct 3, 2026 •

Copy link
Copy Markdown

What this PR does

Main sleep starting at 19:00 or 19:45 can create its physiological-day boundary instead of being classified as a nap and extending the preceding cycle across another day. Both platforms retain the onset eligibility gate before ranking groups and lower the shared selection-band start from 20:00 to 19:00.

The original unclassified 13:00–19:00 nap fixture is restored on Android and covered by the same fixture on Swift: the 01:00–04:30 night remains MAIN and the six-hour afternoon block remains NAP. Explicit main/nap overrides remain authoritative. Learned timing ranks eligible groups; an unclassified daytime group still needs an explicit main override. Raw detector thresholds are unchanged. The selection band's cold-start center consequently moves from 03:30 to 03:00.

Verification

All 17 expected CI checks passed at daf0430a3d133c5d688c8cb732ff3c4775fa6281, including native macOS/iOS app builds, macOS StrandTests, Android APK/full unit suite, Swift packages/tools, Tools Python, i18n and source hygiene. The final exact-head check API reports the same 17-check roster with zero non-success results; the PR head was re-read to confirm it matches. A normal merge of current main completed without conflicts, and normal parity regeneration passed with 289 known findings and no generated diff. Local verification of the review fix before the main merge passed:

  • All 2,106 StrandAnalytics tests on Linux, including the restored long-nap regression and consecutive early-bedtime boundaries.
  • Twelve JVM tests compiled against the actual production PhysiologicalSteps/SleepStageTotals files and their day-string dependency, including the restored unclassified fixture and shared oracle. This focused harness does not compile the full Android app.
  • Optimized standalone production Swift oracle regenerated over 288 onset/offset combinations plus ten focused cases; stdout is pinned verbatim in both platforms' tests. The learned daytime case remains NAP under the retained gate.
  • Source-comment hygiene and whitespace checks.

Oracle reproduction: compile production SleepStageTotals.swift, a copy of PhysiologicalSteps.swift with its unused import WhoopProtocol removed, and a main.swift containing the test's classificationOracle() and print(classificationOracle()), using swiftc -O. The committed tests compare the same output. Run the Swift package with swift test --jobs 1; Android CI runs its complete APK build/unit suite.

No wearer database or held-out reference is available. No physiological accuracy improvement is claimed.

Validation protocol

The original ungated proposal was rejected by the maintainer's unclassified long-nap counterexample. The revised prediction was stated in the maintainer review before these reruns: retaining eligibility and moving its early edge to 19:00 admits the reported bedtimes while preserving the shorter-night/long-nap protection. Expected threshold: zero twin-oracle mismatches; the original unclassified afternoon fixture must remain NAP. Synthetic domain: UTC offsets −5h/0/+13h, 15-minute onset sweep, duration floor, explicit overrides, edited onset and bridged fragments. No synthetic exclusions.

  • R1 Revised prediction, threshold, exclusions and strata stated in the linked review before this run.
  • R2 No wearer database for the self-comparison audit.
  • R3 Reproduction instructions above; synthetic run 2026-10-15 UTC.
  • R4 Deterministic classification/window tests match the question; no stage-accuracy claim.
  • R5 No temporal held-out wearer reference; no accuracy improvement claimed.
  • R6 Eligibility mechanism exercised synthetically; no full-era wearer base-rate measurement available.
  • R7 298 synthetic classifier cases; exact kind/onset output regenerated on this run.
  • R8 Physiological accuracy and real-day recovery remain unmeasured.

Related issues

Refs #2626

@kavemang
kavemang marked this pull request as ready for review October 3, 2026 06:08
@ryanbr

ryanbr commented Oct 4, 2026

Copy link
Copy Markdown
Owner

Thanks @kavemang. The bug is real and your mechanism claim checks out: isOvernightOnset's own doc comment says the scored selector no longer gates on it and that it only feeds the cold-start alignment bonus, so PhysiologicalSteps was indeed the last site still treating it as eligibility. The pre-registered validation block, the 288-combination oracle pinned verbatim on both platforms, and the explicit "no accuracy improvement is claimed" are all the right shape.

Holding it, because removing the gate entirely reintroduces the regression the gate was added for, and I can put numbers on it.

The six-hour afternoon nap now wins

The comment this patch deletes states the hazard outright:

Eliminate nap-shaped GROUPS before choosing a winner. Otherwise a six-hour afternoon nap can win the generic duration scorer, fail the daytime guard, and hide a valid shorter night.

That is still true. mainNightIndex scores asleepMin + alignmentBonusMinutes(...), alignmentBonusMin is capped at 90, the full-bonus window is 2 h and it decays to zero by 5 h, and the cold-start target midsleep is 03:30 (the centre of the [20:00, 11:00) band). Run the exact fixture from longDaytimeNapCannotHideAShorterValidMainNight:

block duration midsleep distance to 03:30 bonus score
night 01:00 to 04:30 210 min 02:45 45 min, inside the full window +90 300
nap 13:00 to 19:00 360 min 16:00 690 min, past the zero point +0 360

360 beats 300, so the unclassified afternoon block becomes MAIN and the real night loses. Because the bonus is capped at 90, the general rule after this change is that any unclassified daytime sleep more than 90 minutes longer than the night becomes the main sleep. That moves the day-cycle boundary into the afternoon and spreads steps, Effort and calories across the wrong days, which is the same class of error this patch sets out to correct for early bedtimes.

The test change hid it rather than caught it

longDaytimeNapCannotHideAShorterValidMainNight fails under this change, and it fails for the right reason. Adding kind = NAP to its fixture and renaming it to explicitlyMarkedLongDaytimeNapCannotHideAShorterMainNight makes it pass, but explicit overrides were already authoritative on both platforms, so the assertion is now close to a tautology and the case it existed to guard, an UNCLASSIFIED long daytime block, is no longer covered anywhere. A test that goes red on a change is usually pinning the invariant rather than the implementation; the repin here moved it onto the new behaviour.

A much smaller fix for the reported bug

overnightStartHour is 20, so the 19:00 and 19:45 bedtimes in the report miss eligibility by between 15 and 60 minutes. Lowering that constant to 19, or 18, fixes the report exactly and keeps the gate that stops the afternoon case. It is also a single shared constant, so the two platforms cannot drift.

If shift schedules need covering too, two options that do not give up the protection:

  • gate on the learned habitualMidsleepSec when one exists, since that is precisely the signal that a daytime sleep is this wearer's real night, and fall back to the band when it does not;
  • or keep eligibility as is, and require a daytime winner to beat the night by more than the 90-minute bonus cap, so it cannot win on raw duration alone.

Either way I would want longDaytimeNapCannotHideAShorterValidMainNight restored with its original unclassified fixture, since that is the assertion that tells the next person which trade-off is deliberate.

Refs #2626

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.

2 participants