From aa2776a45e6d3a763a55db1ebf657ae2b6692e2f Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 1 Nov 2026 19:58:07 +0100 Subject: [PATCH] Cap live bonuses at the day's ceiling The by-minutes bonus path clamped at 24:00 (_MIDNIGHT) and relied on adjust_shutdown_schedule.sh's RESTORE_CEILING (23:00). It now clamps at earned_time.shutdown_ceiling_for(day) via the new _ceiling.ceiling, so a moved WAKE_MINUTES cannot be passed by a bonus. The shell's 1380 stays the hard upper bound; ceiling() is min()'d with it, and a test pins the Python constant to the script's value. _preview_sim and the invariant test reuse that constant instead of a second 23 * 60. Failure mode: if the earned_time lookup raises (ArithmeticError, LookupError, TypeError, ValueError), ceiling() falls back to 23:00 loudly (logger.warning), never silently and never above the helper's bound. That is fail-open relative to a lowered ceiling, bounded by the shell clamp; an earned_time without the API (0.3.0) uses its constant. Red first: new tests (21:00 ceiling, failing lookup, caps-at-ceiling) failed on c3a5d14, pass now. 14-day replay of the real ledgers (2026-10-18..31) through old and new code, plus an all-done-at-22:30 counterfactual: identical, every day 23:00; live files unchanged. TODO-cap-bonus-at-day-ceiling.md removed (done). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU --- TODO-cap-bonus-at-day-ceiling.md | 24 ------ screen_locker/_ceiling.py | 39 ++++++++++ screen_locker/_earned.py | 6 -- screen_locker/_late_credit.py | 3 +- screen_locker/_shutdown.py | 21 +++--- .../tests/test_daily_limits_wiring.py | 4 +- .../tests/test_shutdown_ceiling_invariant.py | 5 +- screen_locker/tests/test_shutdown_part4.py | 74 ++++++++++++++++++- scripts/_preview_sim.py | 2 +- 9 files changed, 127 insertions(+), 51 deletions(-) delete mode 100644 TODO-cap-bonus-at-day-ceiling.md create mode 100644 screen_locker/_ceiling.py diff --git a/TODO-cap-bonus-at-day-ceiling.md b/TODO-cap-bonus-at-day-ceiling.md deleted file mode 100644 index 0893fa3..0000000 --- a/TODO-cap-bonus-at-day-ceiling.md +++ /dev/null @@ -1,24 +0,0 @@ -# Cap every live shutdown bonus at the day's ceiling, not 24:00 / 23:00 - -Carried over from the 2026-10-10 tutor-cutover handoff (item 1a, optional). - -## what -The live bonus paths clamp at 24:00 (`_shutdown.py` `_MIDNIGHT`, ~:152) and -then at `adjust_shutdown_schedule.sh`'s fixed `RESTORE_CEILING=1380` (23:00). -If `WAKE_MINUTES` ever moves the earned_time ceiling below 23:00, a bonus could -push shutdown past the real ceiling. Cap them at -`earned_time.shutdown_ceiling_for(day)` instead (already wrapped in -`_earned.py` ~:167). - -## where -`screen_locker/_shutdown.py`, `screen_locker/adjust_shutdown_schedule.sh`, -`scripts/_preview_sim.py` (`HELPER_CEILING` mirrors `RESTORE_CEILING`). - -## must -- The 168-case "do every task => 23:00" invariant test (fc54534) stays green. -- must not: loosen anything — the shell ceiling remains a hard upper bound. - -## done -A test that sets a ceiling below 23:00 shows no bonus path exceeding it. - -REMOVE ME AFTER FINISH diff --git a/screen_locker/_ceiling.py b/screen_locker/_ceiling.py new file mode 100644 index 0000000..1b0a181 --- /dev/null +++ b/screen_locker/_ceiling.py @@ -0,0 +1,39 @@ +"""The latest shutdown a day can earn: ``earned_time``'s ceiling, bounded at 23:00.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import earned_time + +if TYPE_CHECKING: + from datetime import date + +_logger = logging.getLogger(__name__) + +# adjust_shutdown_schedule.sh's RESTORE_CEILING (23:00): the hard upper bound +# every --restore write is clamped to, and the answer when the lookup fails. +HARD_CEILING_MINUTES = 23 * 60 + + +def ceiling(day: date) -> int: + """The latest shutdown ``day`` can earn, minutes after its midnight. + + Never above :data:`HARD_CEILING_MINUTES`. A lookup that raises fails open + to that bound -- the same cap the helper enforces anyway -- and says so at + warning, so a broken registry cannot silently lift or lower the cap. + """ + fn = getattr(earned_time, "shutdown_ceiling_for", None) + if fn is None: + return min(HARD_CEILING_MINUTES, int(earned_time.SHUTDOWN_CEILING_MINUTES)) + try: + return min(HARD_CEILING_MINUTES, int(fn(day))) + except (ArithmeticError, LookupError, TypeError, ValueError) as err: + _logger.warning( + "shutdown ceiling for %s unavailable (%s); using %s", + day, + err, + f"{HARD_CEILING_MINUTES // 60:02d}:{HARD_CEILING_MINUTES % 60:02d}", + ) + return HARD_CEILING_MINUTES diff --git a/screen_locker/_earned.py b/screen_locker/_earned.py index 546b5c4..c041264 100644 --- a/screen_locker/_earned.py +++ b/screen_locker/_earned.py @@ -176,12 +176,6 @@ def extra_minutes(earner: earned_time.Earner, day: date) -> int: return earner.extra_shutdown_minutes if fn is None else int(fn(earner, day)) -def ceiling(day: date) -> int: - """The latest shutdown ``day`` can earn, minutes after its midnight.""" - fn = getattr(earned_time, "shutdown_ceiling_for", None) - return earned_time.SHUTDOWN_CEILING_MINUTES if fn is None else int(fn(day)) - - def first_credit_time(earner: earned_time.Earner, day: date) -> float | None: """Unix time of ``earner``'s first verified credit counting for ``day``. diff --git a/screen_locker/_late_credit.py b/screen_locker/_late_credit.py index 3a882c9..08a77ac 100644 --- a/screen_locker/_late_credit.py +++ b/screen_locker/_late_credit.py @@ -19,8 +19,9 @@ import logging import time from typing import TYPE_CHECKING +from screen_locker._ceiling import ceiling from screen_locker._day import today_str -from screen_locker._earned import ceiling, hhmm +from screen_locker._earned import hhmm from screen_locker._grace_floor import save_lift, tonight_minutes _logger = logging.getLogger(__name__) diff --git a/screen_locker/_shutdown.py b/screen_locker/_shutdown.py index 5623189..9d9c65a 100644 --- a/screen_locker/_shutdown.py +++ b/screen_locker/_shutdown.py @@ -9,12 +9,13 @@ from typing import TYPE_CHECKING import earned_time +from screen_locker._ceiling import ceiling from screen_locker._constants import ( ADJUST_SHUTDOWN_SCRIPT, SHUTDOWN_CONFIG_FILE, ) from screen_locker._day import today_str -from screen_locker._earned import ceiling, first_minutes, hhmm, span +from screen_locker._earned import first_minutes, hhmm, span from screen_locker._grace_floor import plan_absorb, save_lift from screen_locker._late_credit import LateCreditMixin from screen_locker._sick import is_sick @@ -31,9 +32,6 @@ _logger = logging.getLogger(__name__) _SHUTDOWN_CONFIG_NAMES = ("MON_WED", "THU_SUN", "MORNING_END") _MINUTES_PER_HOUR = 60 -# Extra bonuses may name midnight; the helper still clamps them to 23:00. -_MIDNIGHT = 24 * _MINUTES_PER_HOUR - def _parse_config(path: Path) -> dict[str, int]: """Every integer ``KEY=value`` line of *path*.""" @@ -107,16 +105,17 @@ class ShutdownMixin(SickFloorMixin, LateCreditMixin): def _adjust_shutdown_time_by( self, extra_minutes: int, at: float | None = None ) -> bool: - """Push shutdown later by *extra_minutes*, capped at 24:00 (midnight). + """Push shutdown later by *extra_minutes*, capped at the day's ceiling. - Used for earner and extra-workout bonuses. A cap of midnight works - because ``day-specific-shutdown-check.sh`` fires at 00:00 and catches it - via the morning-window condition; the helper clamps it to 23:00 anyway. + Used for earner and extra-workout bonuses. The cap is + ``earned_time.shutdown_ceiling_for(today)`` (23:00 today, lower if + ``WAKE_MINUTES`` ever moves); the helper's 23:00 stays the hard bound. ``at`` is when the credit was earned (default now): a late one counts from it (:mod:`._late_credit`). Returns True if successful. """ - if is_sick(date.fromisoformat(today_str())): + day = date.fromisoformat(today_str()) + if is_sick(day): return self._raise_on_sick_day(extra_minutes, at) late = self._late_write(extra_minutes, at) if late is not None: @@ -129,8 +128,8 @@ class ShutdownMixin(SickFloorMixin, LateCreditMixin): return False mw, ts, morning = config_values ok = self._write_shutdown_config( - min(_MIDNIGHT, mw + moved), - min(_MIDNIGHT, ts + moved), + min(ceiling(day), mw + moved), + min(ceiling(day), ts + moved), morning, restore=True, ) diff --git a/screen_locker/tests/test_daily_limits_wiring.py b/screen_locker/tests/test_daily_limits_wiring.py index 22cfc2b..af7928d 100644 --- a/screen_locker/tests/test_daily_limits_wiring.py +++ b/screen_locker/tests/test_daily_limits_wiring.py @@ -15,7 +15,7 @@ from unittest.mock import patch import earned_time import pytest -from screen_locker import _cli, _earned, _grace_floor, _runnerup_tcx, _workout_credit +from screen_locker import _ceiling, _cli, _earned, _grace_floor, _runnerup_tcx, _workout_credit from screen_locker._shutdown import ShutdownMixin from screen_locker._workout_credit import WorkoutCreditMixin @@ -129,7 +129,7 @@ class TestPreLadderEarnedTime: workout = earned_time.WORKOUT assert _earned.first_minutes(workout, day) == workout.shutdown_minutes assert _earned.extra_minutes(workout, day) == workout.extra_shutdown_minutes - assert _earned.ceiling(day) == earned_time.SHUTDOWN_CEILING_MINUTES + assert _ceiling.ceiling(day) == earned_time.SHUTDOWN_CEILING_MINUTES _earned._warn_no_first_credit.cache_clear() with caplog.at_level(logging.WARNING): assert _earned.first_credit_time(earned_time.ANKI, day) is None diff --git a/screen_locker/tests/test_shutdown_ceiling_invariant.py b/screen_locker/tests/test_shutdown_ceiling_invariant.py index 9a79111..624eb05 100644 --- a/screen_locker/tests/test_shutdown_ceiling_invariant.py +++ b/screen_locker/tests/test_shutdown_ceiling_invariant.py @@ -26,7 +26,8 @@ import earned_time import pytest from screen_locker import _gate_bonus, _grace_floor, _late_credit, _shutdown -from screen_locker._earned import ceiling, extra_minutes, first_minutes, is_gate +from screen_locker._ceiling import HARD_CEILING_MINUTES, ceiling +from screen_locker._earned import extra_minutes, first_minutes, is_gate from screen_locker._earned import registry as registry_for from screen_locker._gate_bonus import apply_counted_bonus from screen_locker._shutdown import ShutdownMixin @@ -40,7 +41,7 @@ _LAST = date(2026, 12, 31) _DAYS = tuple(_FIRST + timedelta(days=n) for n in range((_LAST - _FIRST).days + 1)) # adjust_shutdown_schedule.sh's RESTORE_CEILING: the helper clamps every # --restore write to 23:00, so the double does too. -_HELPER_CEILING = 23 * 60 +_HELPER_CEILING = HARD_CEILING_MINUTES _MORNING = 5 * 60 Credits = dict[str, date | None] | None diff --git a/screen_locker/tests/test_shutdown_part4.py b/screen_locker/tests/test_shutdown_part4.py index 87b3f02..945b542 100644 --- a/screen_locker/tests/test_shutdown_part4.py +++ b/screen_locker/tests/test_shutdown_part4.py @@ -2,13 +2,29 @@ from __future__ import annotations +import logging +from pathlib import Path +import re from typing import TYPE_CHECKING from unittest.mock import MagicMock +import earned_time + +from screen_locker._ceiling import HARD_CEILING_MINUTES from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: - from pathlib import Path + import pytest + + +def test_hard_ceiling_matches_the_helpers_restore_ceiling() -> None: + """The Python bound and adjust_shutdown_schedule.sh's must not drift.""" + script = Path(__file__).parents[1] / "adjust_shutdown_schedule.sh" + match = re.search( + r"^readonly RESTORE_CEILING=(\d+)", script.read_text(), re.MULTILINE + ) + assert match is not None + assert int(match.group(1)) == HARD_CEILING_MINUTES class TestAdjustShutdownTimeBy: @@ -33,13 +49,13 @@ class TestAdjustShutdownTimeBy: 1320, 1320, 300, restore=True ) - def test_caps_at_midnight( + def test_caps_at_the_day_ceiling( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: - """Times are capped at 1440 minutes (midnight-safe shutdown).""" + """Times are capped at the day's ceiling (23:00 today), not midnight.""" locker = create_locker(mock_tk, tmp_path) object.__setattr__( locker, "_read_shutdown_config", MagicMock(return_value=(1380, 1380, 300)) @@ -49,9 +65,59 @@ class TestAdjustShutdownTimeBy: ) locker._adjust_shutdown_time_by(120) locker._write_shutdown_config.assert_called_once_with( - 1440, 1440, 300, restore=True + 1380, 1380, 300, restore=True ) + def test_caps_at_a_ceiling_below_23( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """If WAKE_MINUTES moves the ceiling to 21:00 no bonus passes it.""" + monkeypatch.setattr(earned_time, "shutdown_ceiling_for", lambda _d: 21 * 60) + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, "_read_shutdown_config", MagicMock(return_value=(1200, 1200, 300)) + ) + object.__setattr__( + locker, "_write_shutdown_config", MagicMock(return_value=True) + ) + locker._adjust_shutdown_time_by(120) + locker._write_shutdown_config.assert_called_once_with( + 1260, 1260, 300, restore=True + ) + + def test_failed_ceiling_lookup_falls_back_to_23_loudly( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + ) -> None: + """earned_time raising must not lift the cap: 23:00, logged at warning.""" + + def boom(_day: object) -> int: + msg = "registry unreadable" + raise ValueError(msg) + + monkeypatch.setattr(earned_time, "shutdown_ceiling_for", boom) + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, "_read_shutdown_config", MagicMock(return_value=(1380, 1380, 300)) + ) + object.__setattr__( + locker, "_write_shutdown_config", MagicMock(return_value=True) + ) + with caplog.at_level(logging.WARNING): + locker._adjust_shutdown_time_by(120) + locker._write_shutdown_config.assert_called_once_with( + 1380, 1380, 300, restore=True + ) + assert "ceiling" in caplog.text + def test_returns_false_when_config_is_none( self, mock_tk: MagicMock, diff --git a/scripts/_preview_sim.py b/scripts/_preview_sim.py index e54d3ed..fad5c7e 100644 --- a/scripts/_preview_sim.py +++ b/scripts/_preview_sim.py @@ -14,6 +14,7 @@ from typing import TYPE_CHECKING import earned_time import sickdays +from screen_locker._ceiling import HARD_CEILING_MINUTES as HELPER_CEILING from screen_locker._earned import first_minutes from screen_locker._runnerup_verification import RunnerUpVerificationMixin from screen_locker._shutdown import ShutdownMixin @@ -23,7 +24,6 @@ if TYPE_CHECKING: from pathlib import Path _NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2" -HELPER_CEILING = 23 * 60 # adjust_shutdown_schedule.sh's RESTORE_CEILING def stamp(day: date, hhmm_text: str) -> float: