screen-locker/screen_locker/_late_credit.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

105 lines
4.0 KiB
Python

"""A credit at or after tonight's shutdown counts from its own time.
Shutting down must never make a finished task impossible to submit (kuhy,
2026-10-17). The config is read-add-write, so a credit landing after the
shutdown used to add its minutes to a time already past: LeetCode at 19:00
after an 18:00 shutdown made 19:00, and the machine stayed locked. Now such
a credit moves tonight to ``its own time + its reward``, capped at the
ceiling -- only the ceiling (23:00) is hard.
The anchor is the credit's own time (a run's end, a ledger row's), never the
moment a sync applied it, so a late upload does not buy extra time. Past the
shutdown the grace and sick lifts are spent: the caller clears the lift.
"""
from __future__ import annotations
from datetime import date, datetime
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 hhmm
from screen_locker._grace_floor import save_lift, tonight_minutes
_logger = logging.getLogger(__name__)
def _now() -> float:
"""The clock a credit without its own time is stamped with."""
return time.time()
def late_tonight(
config: tuple[int, int, int], day: date, at: float, minutes: int
) -> int | None:
"""Tonight's new shutdown for a late credit, or ``None`` if it is not late.
Late means timed on ``day`` at or after tonight's shutdown: the check
script locks at ``now >= shutdown``, so a credit exactly on it is late.
"""
moment = datetime.fromtimestamp(at).astimezone()
if moment.date() != day:
return None
done = moment.hour * 60 + moment.minute
if done < tonight_minutes(config, day):
return None
return min(ceiling(day), done + minutes)
class LateCreditMixin:
"""Late credits and sick-day credits; composed into ShutdownMixin."""
if TYPE_CHECKING:
def _read_shutdown_config(self) -> tuple[int, int, int] | None: ...
def _write_shutdown_config(
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = ...
) -> bool: ...
def _apply_sick_floor(self) -> bool: ...
def _raise_on_sick_day(self, minutes: int, at: float | None) -> bool:
"""A sick day's credit: raise to the derived target, never add.
The credit's row is already in the target (``_sick_floor``), so adding
its minutes on top would pay it twice; a late one still counts from
its own time. True even when nothing moved (capped, or already there):
the credit is accounted for, and a failed write is retried by the next
tick's sick pass.
"""
self._apply_sick_floor()
late = self._late_write(minutes, at)
return True if late is None else late
def _late_write(self, minutes: int, at: float | None) -> bool | None:
"""Write a late credit's shutdown; ``None`` when it is not late."""
day = date.fromisoformat(today_str())
try:
config = self._read_shutdown_config()
except (OSError, ValueError) as exc:
# The add path reads it again inside its own guard and fails there.
_logger.warning("Late credit: cannot read shutdown config (%s)", exc)
return None
if config is None:
return None # the add path reads it again and reports it
target = late_tonight(config, day, _now() if at is None else at, minutes)
if target is None:
return None
mon_wed, thu_sun, morning = config
if not self._write_shutdown_config(
max(mon_wed, target), max(thu_sun, target), morning, restore=True
):
_logger.warning("Late credit: failed to write shutdown config")
return False
save_lift(0) # past the shutdown, the floors' lift is spent
_logger.info(
"Late credit: shutdown %s -> %s (earned at or after tonight's).",
hhmm(tonight_minutes(config, day)),
hhmm(target),
)
return True