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

242 lines
8.8 KiB
Python

"""Shutdown hours from the shared earner registry (``earned_time``, kuhyx/utils).
Every gate's reward -- the workout, the LeetCode hour, the reading hour and
whatever is registered next -- is defined once in ``earned_time``, which
steam-backlog-enforcer reads for gaming time too. This module is the
screen-locker side of it: where the ledgers live, and how minutes are shown.
The gates publish facts only; none writes the shutdown config. Their ledgers
are HMAC-verified against :data:`HMAC_KEY_FILE` and *fail closed* -- an
unreadable ledger or key earns nothing, and says so.
Minutes end to end: the registry, ``/etc/shutdown-schedule.conf`` (its
``*_MINUTES`` keys) and every shutdown time in this package are minutes after
midnight, so a 30-minute earner is applied exactly.
"""
from __future__ import annotations
from datetime import UTC, date, datetime
import functools
import logging
from pathlib import Path
from typing import Final
import earned_time
from screen_locker._constants import HMAC_KEY_FILE
from screen_locker._day import today_str
from screen_locker._gate_off import gate_off
_logger: Final = logging.getLogger(__name__)
# Every earner's ``ledger`` is relative to this. Read-only from here; resolved
# at call time so the test suite's redirect (tests/_isolated_state.py) applies.
LEDGER_HOME: Final = Path.home()
_MINUTES_PER_HOUR: Final = 60
def hhmm(minutes: int) -> str:
"""A time of day, given in minutes after midnight, as ``HH:MM``."""
hours, rest = divmod(minutes, _MINUTES_PER_HOUR)
return f"{hours:02d}:{rest:02d}"
def span(minutes: int) -> str:
"""A duration in minutes as ``2h``, ``30m`` or ``1h30m``."""
hours, rest = divmod(minutes, _MINUTES_PER_HOUR)
if not rest:
return f"{hours}h"
if not hours:
return f"{rest}m"
return f"{hours}h{rest:02d}m"
def ledger_file(earner: earned_time.Earner) -> Path:
"""Where ``earner``'s gate keeps its ledger."""
if earner.ledger is None:
msg = f"earner {earner.name!r} has no ledger"
raise ValueError(msg)
return LEDGER_HOME / earner.ledger
def registry(day: date | None = None) -> tuple[earned_time.Earner, ...]:
"""The earners in force on ``day`` (default today).
``earners_for`` (earned_time >= 0.5) switches the registry on
``TUTOR_FROM``; an older earned_time has one static registry.
"""
fn = getattr(earned_time, "earners_for", None)
return tuple(earned_time.EARNERS if fn is None else fn(day))
def is_gate(earner: earned_time.Earner) -> bool:
"""A ledger-backed earner whose gate publishes credits (not the workout).
The workout has a ledger too, but screen-locker writes it and
``WorkoutCreditMixin`` applies it.
"""
return earner.ledger is not None and earner.name != earned_time.WORKOUT.name
def flat_earners(day: date | None = None) -> tuple[earned_time.Earner, ...]:
"""The once-per-day, ledger-backed earners in force on ``day``."""
return tuple(e for e in registry(day) if e.kind == "flat" and is_gate(e))
def gate_earners(day: date | None = None) -> tuple[earned_time.Earner, ...]:
"""Every gate earner in force on ``day``, flat and counted, in registry order."""
return tuple(e for e in registry(day) if is_gate(e))
def counted_gate_earners(day: date | None = None) -> tuple[earned_time.Earner, ...]:
"""Gate earners paid per unit on ``day`` (the Automation tutor's blocks)."""
return tuple(e for e in registry(day) if e.kind == "counted" and is_gate(e))
def watched_earners() -> tuple[earned_time.Earner, ...]:
"""Every gate earner of every registry, so a cutover misses no ledger."""
fn = getattr(earned_time, "all_earners", None)
pool = earned_time.EARNERS if fn is None else fn()
return tuple(e for e in pool if is_gate(e))
def earned_today(
earner: earned_time.Earner, now: datetime | None = None
) -> bool | None:
"""Whether ``earner``'s gate recorded today's credit; ``None`` if unknown."""
day = (now or datetime.now(tz=UTC)).astimezone().date()
return earned_time.done_today(
earner,
ledger_file(earner),
HMAC_KEY_FILE,
now=now,
gate_off=gate_off(earner, day),
)
def earned_units(earner: earned_time.Earner, day: date) -> int | None:
"""How many of a counted earner's units count for ``day``; ``None`` if unknown."""
return earned_time.credit_units(
earner,
ledger_file(earner),
HMAC_KEY_FILE,
day,
gate_off=gate_off(earner, day),
)
def gate_answers(day: date | None = None) -> dict[str, int | bool | None]:
"""Every gate earner's answer for ``day`` (default today), unknown logged.
Flat earners answer "done today"; counted ones (the tutor) their units.
On an earned_time without counted gates this is exactly
:func:`flat_answers`.
"""
target = day or datetime.now().astimezone().date()
answers: dict[str, int | bool | None] = dict(flat_answers())
for earner in counted_gate_earners(target):
units = earned_units(earner, target)
if units is None:
_warn_unknown(earner)
answers[earner.name] = units
return answers
def flat_answers() -> dict[str, bool | None]:
"""Today's answer from every flat earner, an unknown one logged."""
answers: dict[str, bool | None] = {}
for earner in flat_earners():
answer = earned_today(earner)
if answer is None:
_warn_unknown(earner)
answers[earner.name] = answer
return answers
def _warn_unknown(earner: earned_time.Earner) -> None:
_logger.warning(
"%s state could not be checked; no shutdown time for it", earner.label
)
# The day-aware ladder API (earned_time >= 0.4, from 2026-10-10). Looked up at
# call time so the installed 0.3.0 -- which has none of it -- still runs, on
# the raw fields it always used.
def first_minutes(earner: earned_time.Earner, day: date) -> int:
"""What ``earner``'s first unit pushes shutdown later by on ``day``."""
fn = getattr(earned_time, "shutdown_minutes_for", None)
return earner.shutdown_minutes if fn is None else int(fn(earner, day))
def extra_minutes(earner: earned_time.Earner, day: date) -> int:
"""What each further unit of a counted earner earns on ``day``."""
fn = getattr(earned_time, "extra_shutdown_minutes_for", None)
return earner.extra_shutdown_minutes if fn is None else int(fn(earner, day))
def first_credit_time(earner: earned_time.Earner, day: date) -> float | None:
"""Unix time of ``earner``'s first verified credit counting for ``day``.
``None`` when there is none, when it cannot be checked, and on an
``earned_time`` without ``first_credit_at`` (0.3.0) -- logged, because
then a late ledger credit counts from now, not from its row.
"""
fn = getattr(earned_time, "first_credit_at", None)
if fn is None:
_warn_no_first_credit()
return None
stamp: float | None = fn(
earner, ledger_file(earner), HMAC_KEY_FILE, day, gate_off=gate_off(earner, day)
)
return stamp
@functools.cache
def _warn_no_first_credit() -> None:
"""Once per process: the installed earned_time predates first_credit_at."""
_logger.warning(
"earned_time has no first_credit_at (needs >= 0.4); a late ledger "
"credit counts from now, not from its own time"
)
def first_credits(
earners: tuple[earned_time.Earner, ...], day: date
) -> dict[str, date | None] | None:
"""Each penalised ledger earner's first real credit up to ``day``, for resolve.
``earned_time.resolve`` / ``base_for`` take it as ``first_credits=`` (>= 0.6)
and then start a penalty no earlier than the day after the gate first
paid out. Keyed by name over ``earners`` -- the registry the answers came
from, so the two ``automation`` earners (either side of ``TUTOR_FROM``)
never collide. An earner without ``penalty_from``, a ledger or a matcher
is left out (``resolve`` falls back to its ``confirmed_on``).
Returns:
The map; ``None`` for a day before today -- history keeps the shutdown
it was enforced at, never re-resolved -- and on an earned_time
without ``maturity`` (< 0.6, logged), where callers resolve as before.
"""
if day < date.fromisoformat(today_str()):
return None
fn = getattr(earned_time, "maturity", None)
if fn is None:
_warn_no_maturity()
return None
return {
e.name: fn(e, ledger_file(e), HMAC_KEY_FILE, day).first_credit
for e in earners
if e.penalty_from is not None and e.ledger is not None and e.match is not None
}
@functools.cache
def _warn_no_maturity() -> None:
"""Once per process: the installed earned_time predates maturity."""
_logger.warning(
"earned_time has no maturity (needs >= 0.6); every penalty starts on "
"its penalty_from, even for a gate that never paid out"
)