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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
This commit is contained in:
Krzysztof Rudnicki 2026-11-01 19:58:07 +01:00
parent 1e6f17257c
commit aa2776a45e
9 changed files with 127 additions and 51 deletions

View File

@ -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

39
screen_locker/_ceiling.py Normal file
View File

@ -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

View File

@ -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)) 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: def first_credit_time(earner: earned_time.Earner, day: date) -> float | None:
"""Unix time of ``earner``'s first verified credit counting for ``day``. """Unix time of ``earner``'s first verified credit counting for ``day``.

View File

@ -19,8 +19,9 @@ import logging
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from screen_locker._ceiling import ceiling
from screen_locker._day import today_str 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 from screen_locker._grace_floor import save_lift, tonight_minutes
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)

View File

@ -9,12 +9,13 @@ from typing import TYPE_CHECKING
import earned_time import earned_time
from screen_locker._ceiling import ceiling
from screen_locker._constants import ( from screen_locker._constants import (
ADJUST_SHUTDOWN_SCRIPT, ADJUST_SHUTDOWN_SCRIPT,
SHUTDOWN_CONFIG_FILE, SHUTDOWN_CONFIG_FILE,
) )
from screen_locker._day import today_str 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._grace_floor import plan_absorb, save_lift
from screen_locker._late_credit import LateCreditMixin from screen_locker._late_credit import LateCreditMixin
from screen_locker._sick import is_sick from screen_locker._sick import is_sick
@ -31,9 +32,6 @@ _logger = logging.getLogger(__name__)
_SHUTDOWN_CONFIG_NAMES = ("MON_WED", "THU_SUN", "MORNING_END") _SHUTDOWN_CONFIG_NAMES = ("MON_WED", "THU_SUN", "MORNING_END")
_MINUTES_PER_HOUR = 60 _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]: def _parse_config(path: Path) -> dict[str, int]:
"""Every integer ``KEY=value`` line of *path*.""" """Every integer ``KEY=value`` line of *path*."""
@ -107,16 +105,17 @@ class ShutdownMixin(SickFloorMixin, LateCreditMixin):
def _adjust_shutdown_time_by( def _adjust_shutdown_time_by(
self, extra_minutes: int, at: float | None = None self, extra_minutes: int, at: float | None = None
) -> bool: ) -> 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 Used for earner and extra-workout bonuses. The cap is
because ``day-specific-shutdown-check.sh`` fires at 00:00 and catches it ``earned_time.shutdown_ceiling_for(today)`` (23:00 today, lower if
via the morning-window condition; the helper clamps it to 23:00 anyway. ``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 ``at`` is when the credit was earned (default now): a late one counts
from it (:mod:`._late_credit`). Returns True if successful. 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) return self._raise_on_sick_day(extra_minutes, at)
late = self._late_write(extra_minutes, at) late = self._late_write(extra_minutes, at)
if late is not None: if late is not None:
@ -129,8 +128,8 @@ class ShutdownMixin(SickFloorMixin, LateCreditMixin):
return False return False
mw, ts, morning = config_values mw, ts, morning = config_values
ok = self._write_shutdown_config( ok = self._write_shutdown_config(
min(_MIDNIGHT, mw + moved), min(ceiling(day), mw + moved),
min(_MIDNIGHT, ts + moved), min(ceiling(day), ts + moved),
morning, morning,
restore=True, restore=True,
) )

View File

@ -15,7 +15,7 @@ from unittest.mock import patch
import earned_time import earned_time
import pytest 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._shutdown import ShutdownMixin
from screen_locker._workout_credit import WorkoutCreditMixin from screen_locker._workout_credit import WorkoutCreditMixin
@ -129,7 +129,7 @@ class TestPreLadderEarnedTime:
workout = earned_time.WORKOUT workout = earned_time.WORKOUT
assert _earned.first_minutes(workout, day) == workout.shutdown_minutes assert _earned.first_minutes(workout, day) == workout.shutdown_minutes
assert _earned.extra_minutes(workout, day) == workout.extra_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() _earned._warn_no_first_credit.cache_clear()
with caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING):
assert _earned.first_credit_time(earned_time.ANKI, day) is None assert _earned.first_credit_time(earned_time.ANKI, day) is None

View File

@ -26,7 +26,8 @@ import earned_time
import pytest import pytest
from screen_locker import _gate_bonus, _grace_floor, _late_credit, _shutdown 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._earned import registry as registry_for
from screen_locker._gate_bonus import apply_counted_bonus from screen_locker._gate_bonus import apply_counted_bonus
from screen_locker._shutdown import ShutdownMixin 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)) _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 # adjust_shutdown_schedule.sh's RESTORE_CEILING: the helper clamps every
# --restore write to 23:00, so the double does too. # --restore write to 23:00, so the double does too.
_HELPER_CEILING = 23 * 60 _HELPER_CEILING = HARD_CEILING_MINUTES
_MORNING = 5 * 60 _MORNING = 5 * 60
Credits = dict[str, date | None] | None Credits = dict[str, date | None] | None

View File

@ -2,13 +2,29 @@
from __future__ import annotations from __future__ import annotations
import logging
from pathlib import Path
import re
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import MagicMock from unittest.mock import MagicMock
import earned_time
from screen_locker._ceiling import HARD_CEILING_MINUTES
from screen_locker.tests.conftest import create_locker from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING: 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: class TestAdjustShutdownTimeBy:
@ -33,13 +49,13 @@ class TestAdjustShutdownTimeBy:
1320, 1320, 300, restore=True 1320, 1320, 300, restore=True
) )
def test_caps_at_midnight( def test_caps_at_the_day_ceiling(
self, self,
mock_tk: MagicMock, mock_tk: MagicMock,
mock_sys_exit: MagicMock, mock_sys_exit: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> 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) locker = create_locker(mock_tk, tmp_path)
object.__setattr__( object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(1380, 1380, 300)) locker, "_read_shutdown_config", MagicMock(return_value=(1380, 1380, 300))
@ -49,9 +65,59 @@ class TestAdjustShutdownTimeBy:
) )
locker._adjust_shutdown_time_by(120) locker._adjust_shutdown_time_by(120)
locker._write_shutdown_config.assert_called_once_with( 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( def test_returns_false_when_config_is_none(
self, self,
mock_tk: MagicMock, mock_tk: MagicMock,

View File

@ -14,6 +14,7 @@ from typing import TYPE_CHECKING
import earned_time import earned_time
import sickdays import sickdays
from screen_locker._ceiling import HARD_CEILING_MINUTES as HELPER_CEILING
from screen_locker._earned import first_minutes from screen_locker._earned import first_minutes
from screen_locker._runnerup_verification import RunnerUpVerificationMixin from screen_locker._runnerup_verification import RunnerUpVerificationMixin
from screen_locker._shutdown import ShutdownMixin from screen_locker._shutdown import ShutdownMixin
@ -23,7 +24,6 @@ if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2" _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: def stamp(day: date, hhmm_text: str) -> float: