screen-locker/screen_locker/_ceiling.py
Krzysztof kuhy Rudnicki aa2776a45e 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
2026-11-01 20:04:14 +01:00

40 lines
1.3 KiB
Python

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