From f84bd5ab64956512167325ab0a01e7befc147560 Mon Sep 17 00:00:00 2001 From: Chance Newkirk <40219357+cnewkirk@users.noreply.github.com> Date: Mon, 6 Jul 2026 07:37:36 -0700 Subject: [PATCH] fix: hold DAYLIGHT through the morning instead of cascading to NIGHT The DAYLIGHT branch of the rhythm phase engine compared the noon-shifted clock (`_noon_shifted(minute) = (minute - 720) % 1440`) against the evening thresholds with no morning guard. `_noon_shifted` maps morning minutes (00:00-11:59) to 720-1439, above every evening threshold, so the moment the phase reached DAYLIGHT before noon -- every morning when MORNING elapses at ~08:00, and on any daemon restart that seeds DAYLIGHT mid-morning -- the next tick fell DAYLIGHT -> WIND_DOWN -> NIGHT. In continuous operation the NIGHT morning-escape then bounced it back to MORNING, so the engine flapped hourly until ~noon and re-recorded a bogus ~11:00 wake anchor on each loop, corrupting the learned wake time. With no wake evidence -- e.g. a restart while presence has never been judged human -- it instead stuck in NIGHT for the whole day. Gate the DAYLIGHT branch's evening-ward transitions on `minute >= 720`, the same noon boundary the seed branch already uses. EVENING is left unguarded on purpose: it is never entered before noon in normal forward progression, and a post-midnight `bed_target` legitimately winds down from EVENING in the morning half, so guarding it would strand that config in EVENING forever. Tests: three regression tests reproduce the cascade on the MORNING->DAYLIGHT path, the restart-seed path, and a full-day walk; one guards the post-midnight `bed_target=02:00` path so the fix is not over-applied to EVENING. Co-Authored-By: Claude Opus 4.8 (1M context) --- hueman/rhythm_control.py | 17 ++++++--- tests/test_rhythm_engine.py | 71 +++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/hueman/rhythm_control.py b/hueman/rhythm_control.py index 801e19b..ae9c2e9 100644 --- a/hueman/rhythm_control.py +++ b/hueman/rhythm_control.py @@ -369,10 +369,19 @@ def _decide( return self.MORNING, "hold" if self._phase == self.DAYLIGHT: - if m_shift >= wind_down_start: - return self.WIND_DOWN, "wind-down-lead" - if m_shift >= _noon_shifted(signals.sunset_min): - return self.EVENING, "sunset" + # Only advance toward evening in the afternoon half (minute >= 720), + # the same noon boundary the seed branch uses. In the morning half + # _noon_shifted maps the clock (e.g. 08:00 -> 1200) above every + # evening threshold, so an unguarded comparison would cascade + # DAYLIGHT straight to WIND_DOWN/NIGHT the moment MORNING elapses. + # EVENING needs no such guard: it is never entered before noon, and + # for a post-midnight bed target it must wind down in the morning + # half, so gating it here would strand it. + if minute >= 720: + if m_shift >= wind_down_start: + return self.WIND_DOWN, "wind-down-lead" + if m_shift >= _noon_shifted(signals.sunset_min): + return self.EVENING, "sunset" return self.DAYLIGHT, "hold" if self._phase == self.EVENING: diff --git a/tests/test_rhythm_engine.py b/tests/test_rhythm_engine.py index 1f855cb..62709c1 100644 --- a/tests/test_rhythm_engine.py +++ b/tests/test_rhythm_engine.py @@ -261,3 +261,74 @@ def test_snapshot_is_json_serialisable_and_carries_anchors(): json.dumps(snap) assert snap["phase"] == RhythmEngine.DAYLIGHT assert "bed_anchor_min" in snap and "wake_anchor_min" in snap + + +def test_daylight_holds_through_morning_after_wake_no_cascade(): + """DAYLIGHT reached before noon must HOLD, not cascade toward NIGHT. + + Regression for the morning phase cascade: once MORNING elapsed to DAYLIGHT + at ~08:00 (minute < 720), the noon-shifted clock sat above every evening + threshold, so the DAYLIGHT branch fell DAYLIGHT->WIND_DOWN->NIGHT on the + very next tick and then flapped back via the NIGHT morning-escape, + re-recording a bogus late wake anchor each loop. + """ + eng = RhythmEngine(_spec(), AnchorStore(), tz=TZ) + eng.tick(_epoch(1, 23, 1), ACTIVE, _sig()) # NIGHT (seed) + eng.tick(_epoch(1, 23, 50), QUIET, _sig(zone_on=False)) # -> SLEEP + eng.tick(_epoch(2, 6, 50), BEDROOM_BURST, _sig(zone_on=False)) # -> MORNING + d = eng.tick(_epoch(2, 7, 55), ACTIVE, _sig()) # -> DAYLIGHT + assert d.phase == RhythmEngine.DAYLIGHT and d.reason == "morning-elapsed" + d = eng.tick(_epoch(2, 9, 0), ACTIVE, _sig()) # morning half: HOLD + assert d.phase == RhythmEngine.DAYLIGHT + assert d.changed is False and d.reason == "hold" + d = eng.tick(_epoch(2, 11, 30), ACTIVE, _sig()) # still morning: HOLD + assert d.phase == RhythmEngine.DAYLIGHT + + +def test_seeded_daylight_does_not_cascade_before_noon(): + """A mid-morning restart seeds DAYLIGHT and must stay there, not strand. + + test_morning_restart_seeds_daylight_not_night only checks the seed tick; + the cascade struck on the *next* tick. + """ + eng = RhythmEngine(_spec(), AnchorStore(), tz=TZ) + d = eng.tick(_epoch(1, 9, 30), ACTIVE, _sig()) + assert d.phase == RhythmEngine.DAYLIGHT and d.reason == "seed" + d = eng.tick(_epoch(1, 10, 0), ACTIVE, _sig()) + assert d.phase == RhythmEngine.DAYLIGHT + assert d.changed is False and d.reason == "hold" + + +def test_continuous_day_holds_daylight_then_progresses_to_night(): + """A full simulated day: DAYLIGHT holds morning + afternoon, then the + normal evening progression still fires (the fix must not over-gate).""" + eng = RhythmEngine(_spec(), AnchorStore(), tz=TZ) + eng.tick(_epoch(1, 23, 1), ACTIVE, _sig()) # NIGHT (seed) + eng.tick(_epoch(1, 23, 50), QUIET, _sig(zone_on=False)) # -> SLEEP + eng.tick(_epoch(2, 6, 50), BEDROOM_BURST, _sig(zone_on=False)) # -> MORNING + assert eng.tick(_epoch(2, 7, 55), ACTIVE, _sig()).phase == RhythmEngine.DAYLIGHT + for hh, mm in [(8, 0), (10, 0), (12, 30), (17, 0), (20, 0)]: + d = eng.tick(_epoch(2, hh, mm), ACTIVE, _sig()) + assert d.phase == RhythmEngine.DAYLIGHT, f"{hh:02d}:{mm:02d} -> {d.phase}" + assert eng.tick(_epoch(2, 20, 31), ACTIVE, _sig()).phase == RhythmEngine.EVENING + assert eng.tick(_epoch(2, 21, 31), ACTIVE, _sig()).phase == RhythmEngine.WIND_DOWN + assert eng.tick(_epoch(2, 23, 1), ACTIVE, _sig()).phase == RhythmEngine.NIGHT + + +def test_late_bed_after_midnight_reaches_night(): + """A post-midnight bedtime must still wind down and reach NIGHT. + + Guards against an over-broad morning fix: EVENING is legitimately active + just past midnight for a post-midnight bed_target, and its wind-down fires + in the morning half (minute < 720). Gating EVENING on ``minute >= 720`` + would strand it here forever, so the fix must guard DAYLIGHT only. + """ + eng = RhythmEngine(_spec(bed_target="02:00"), AnchorStore(), tz=TZ) + d = eng.tick(_epoch(1, 21, 0), ACTIVE, _sig()) # -> EVENING (seed) + assert d.phase == RhythmEngine.EVENING + d = eng.tick(_epoch(1, 23, 59), ACTIVE, _sig()) # before its wind-down + assert d.phase == RhythmEngine.EVENING + d = eng.tick(_epoch(2, 0, 30), ACTIVE, _sig()) # 00:30 = bed 02:00 - 90m lead + assert d.phase == RhythmEngine.WIND_DOWN and d.reason == "wind-down-lead" + d = eng.tick(_epoch(2, 2, 0), ACTIVE, _sig()) # bed anchor + assert d.phase == RhythmEngine.NIGHT and d.reason == "bed-anchor"