From 6641927dbe79227d2ec971881f450097e5b777ff Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 9 Oct 2026 20:53:31 +0200 Subject: [PATCH] earned_time 0.5.0: per-day registry, counted AUTOMATION tutor earner (TUTOR_FROM sentinel), Anki/Automation gap waiver from 2026-10-09 Co-Authored-By: Claude Opus 5 (1M context) --- earned_time/README.md | 38 ++++- earned_time/earned_time/__init__.py | 16 ++ earned_time/earned_time/_credits.py | 14 +- earned_time/earned_time/_ladder.py | 68 +++++++- earned_time/earned_time/_match.py | 16 ++ earned_time/earned_time/_policy.py | 72 +++++++-- earned_time/earned_time/_registry.py | 67 ++++++++ earned_time/earned_time/_resolve.py | 51 +++--- earned_time/earned_time/tests/test_ladder.py | 20 ++- earned_time/earned_time/tests/test_tutor.py | 146 ++++++++++++++++++ .../earned_time/tests/test_tutor_ledger.py | 111 +++++++++++++ earned_time/earned_time/tests/test_waiver.py | 67 ++++++++ earned_time/pyproject.toml | 2 +- earned_time/scripts/__init__.py | 2 + earned_time/scripts/demo_tutor_cutover.py | 123 +++++++++++++++ 15 files changed, 765 insertions(+), 48 deletions(-) create mode 100644 earned_time/earned_time/_registry.py create mode 100644 earned_time/earned_time/tests/test_tutor.py create mode 100644 earned_time/earned_time/tests/test_tutor_ledger.py create mode 100644 earned_time/earned_time/tests/test_waiver.py create mode 100644 earned_time/scripts/__init__.py create mode 100644 earned_time/scripts/demo_tutor_cutover.py diff --git a/earned_time/README.md b/earned_time/README.md index 4df2669..0f25073 100644 --- a/earned_time/README.md +++ b/earned_time/README.md @@ -46,6 +46,35 @@ never a same-day cut): The two floors are derived separately (`_resolve._shutdown_floor`), so the ladder cannot move a gaming minute. +**The tutor cutover (`TUTOR_FROM`, one constant in `_ladder.py`).** The +registry is per day: `earners_for(day)` is `EARNERS` before it and +`TUTOR_EARNERS` from it. From `TUTOR_FROM` Anki is retired (neither penalised +nor paid) and `automation` is `AUTOMATION_TUTOR`, a counted earner on the +Automation tutor's ledger (`~/.local/share/automation_tutor/ledger.json`, +one signed row per verified 15-minute block, `tutor_match` on +`detail.ended_at`), at most 4 blocks a day: + +| blocks | gaming | shutdown rung | +|---|---|---| +| each | +15 min | 13 / 13 / 12 / 12 | +| all 4 | +60 (Anki's 30 folded in) | +50 (Anki's 25 folded in) | + +The gaming base drops by the full 60 (`Earner.max_gaming_minutes`) and the +ladder floor by the full 50, so nothing done is still 3 h / 19:00 and +everything done is still 8 h / 23:00. Days before `TUTOR_FROM` resolve on +`EARNERS` exactly as before. Consumers must iterate `earners_for(day)`, never +`EARNERS`; `resolve`/`base_for` default to it, and ignore answers for an +earner of another day's registry. + +**The gap waiver (`ANKI_WAIVED_FROM`, 2026-10-09).** anki-guard never wrote a +credit, and the tutor is not confirmed yet, so from `ANKI_WAIVED_FROM` until +`TUTOR_FROM` the registry is `EARNERS` without `anki` and `automation`: +neither penalised nor paid. The base rises by exactly what the two could have +paid back (nothing done: 4 h / 19:50 on a ladder day; everything done: still +8 h / 23:00), so it can only ever raise time. `TUTOR_FROM` stays a far-future +sentinel (2099-01-01) until kuhy confirms a real tutor session credits a +block; it is then set to the day after, never the deploy day. + ## API - `resolve(answers, day=None, earners=EARNERS) -> Resolution` — the one sum. @@ -59,7 +88,12 @@ ladder cannot move a gaming minute. not the calendar day (Anki's rollover, a rest day declared the evening before). - `credit_units(earner, ledger, key_file, day) -> int | None` — how many - verified rows count for `day`: a counted earner's units. + verified rows count for `day`: a counted earner's units (a repeated + `entry_id` counts once; `max_units` is applied by `resolve`). +- `earners_for(day)`, `all_earners()`, `registries()` — the registry in force + on a day, and every earner of every registry (what a ledger watcher must + watch). They read `earned_time.EARNERS` through the package, so a consumer + test that patches it still registers its stand-in. - `shutdown_minutes_for(earner, day) -> int`, `extra_shutdown_minutes_for(earner, day) -> int`, `shutdown_ceiling_for(day) -> int`, `on_ladder(day) -> bool` — what a status @@ -128,7 +162,7 @@ on the day itself is logged and does not count. ## Install ```console -pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.4.0#subdirectory=earned_time" +pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.5.0#subdirectory=earned_time" ``` The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports diff --git a/earned_time/earned_time/__init__.py b/earned_time/earned_time/__init__.py index f529ae7..75463c6 100644 --- a/earned_time/earned_time/__init__.py +++ b/earned_time/earned_time/__init__.py @@ -34,12 +34,16 @@ from earned_time._credits import ( first_credit_at, ) from earned_time._ladder import ( + ANKI_WAIVED_FROM, LADDER, LADDER_FROM, SHUTDOWN_CEILING_MINUTES, + TUTOR_FROM, + TUTOR_LADDER, WAKE_MINUTES, Rung, extra_shutdown_minutes_for, + ladder_for, on_ladder, shutdown_ceiling_for, shutdown_minutes_for, @@ -48,21 +52,26 @@ from earned_time._ledger import done_today, entry_signature, today_window, verif from earned_time._policy import ( ANKI, AUTOMATION, + AUTOMATION_TUTOR, EARNERS, GAMING_BASE_MINUTES, GAMING_CEILING_MINUTES, LEETCODE, READING, SHUTDOWN_BASE_MINUTES, + TUTOR_EARNERS, WORKOUT, Earner, earner, ) +from earned_time._registry import all_earners, earners_for, registries from earned_time._resolve import Base, Resolution, Term, base_for, resolve __all__ = [ "ANKI", + "ANKI_WAIVED_FROM", "AUTOMATION", + "AUTOMATION_TUTOR", "EARNERS", "GAMING_BASE_MINUTES", "GAMING_CEILING_MINUTES", @@ -72,6 +81,9 @@ __all__ = [ "READING", "SHUTDOWN_BASE_MINUTES", "SHUTDOWN_CEILING_MINUTES", + "TUTOR_EARNERS", + "TUTOR_FROM", + "TUTOR_LADDER", "WAKE_MINUTES", "WORKOUT", "Base", @@ -79,15 +91,19 @@ __all__ = [ "Resolution", "Rung", "Term", + "all_earners", "base_for", "credit_units", "day_window", "done_today", "earner", + "earners_for", "entry_signature", "extra_shutdown_minutes_for", "first_credit_at", + "ladder_for", "on_ladder", + "registries", "resolve", "shutdown_ceiling_for", "shutdown_minutes_for", diff --git a/earned_time/earned_time/_credits.py b/earned_time/earned_time/_credits.py index e9977eb..27424db 100644 --- a/earned_time/earned_time/_credits.py +++ b/earned_time/earned_time/_credits.py @@ -96,7 +96,9 @@ def credit_units(earner: Earner, ledger: Path, key_file: Path, day: date) -> int Returns: The count (``0`` included), or ``None`` when the ledger or key could - not be read -- never "no units". + not be read -- never "no units". A row repeated under the same + ``entry_id`` (a replayed write) counts once. The earner's + ``max_units`` is applied by :func:`~earned_time.resolve`, not here. Raises: ValueError: ``earner`` has no ``match``; its answer is the consumer's. @@ -104,4 +106,12 @@ def credit_units(earner: Earner, ledger: Path, key_file: Path, day: date) -> int rows = counting_rows(earner, ledger, key_file, day_window(day)) if rows is None: return None - return sum(1 for _ in rows) + ids: set[object] = set() + units = 0 + for row in rows: + entry_id = row.get("entry_id") + if entry_id is not None and entry_id in ids: + continue + ids.add(entry_id) + units += 1 + return units diff --git a/earned_time/earned_time/_ladder.py b/earned_time/earned_time/_ladder.py index 9961ac8..694d6c6 100644 --- a/earned_time/earned_time/_ladder.py +++ b/earned_time/earned_time/_ladder.py @@ -7,6 +7,9 @@ doing everything lands exactly on the ceiling. Move the alarm and the whole ladder moves with it. Days before ``LADDER_FROM`` keep their own values and a frozen ceiling, so the switch never rewrites how an old day resolved. Gaming is not on the ladder. + +From ``TUTOR_FROM`` the rungs are :data:`TUTOR_LADDER`: Anki is retired and +its 25 minutes fold into the Automation tutor's, paid per 15-minute block. """ from __future__ import annotations @@ -31,6 +34,17 @@ LADDER_FROM: Final = date(2026, 10, 10) # Alarm time, minutes after midnight. The ladder's ceiling is eight hours of # sleep before it, so moving the alarm moves the whole ladder with it. WAKE_MINUTES: Final = 7 * 60 +# From this day the Anki earner is retired and Automation is paid per tutor +# block (``earned_time._policy.AUTOMATION_TUTOR``). A far-future sentinel +# until kuhy confirms a real tutor session earns a block; then the day after +# that confirmation -- never the deploy day itself (a same-day cut, 2026-09-26). +TUTOR_FROM: Final = date(2099, 1, 1) +# From this day until TUTOR_FROM the Anki and old Automation earners are +# waived: neither penalised nor paid, since both are being retired and the +# tutor that replaces them is not yet confirmed. Raise-only on every day (the +# floor rises by exactly what the two could have paid back), so it may land +# on its deploy day: 0.5.0 landed 2026-10-09. +ANKI_WAIVED_FROM: Final = date(2026, 10, 9) _SLEEP_MINUTES: Final = 8 * 60 _DAY_MINUTES: Final = 24 * 60 @@ -41,11 +55,27 @@ class Rung: Attributes: first: Shutdown minutes the first unit earns. - extra: Each further unit (counted earners); the ladder pays none. + extra: Each further unit (counted earners) once ``steps`` run out. + steps: What the second, third, ... unit earn, in order, when they are + not all the same (the tutor's 13/13/12/12 split). """ first: int extra: int = 0 + steps: tuple[int, ...] = () + + @property + def second(self) -> int: + """What the second unit earns.""" + return self.steps[0] if self.steps else self.extra + + def minutes(self, units: int) -> int: + """Shutdown minutes ``units`` earn on this rung (``0`` for none).""" + if units <= 0: + return 0 + further = units - 1 + listed = sum(self.steps[:further]) + return self.first + listed + max(0, further - len(self.steps)) * self.extra # The rungs, keyed by earner name: they sum to the gap between the floor and @@ -62,13 +92,33 @@ LADDER: Final[Mapping[str, Rung]] = MappingProxyType( ) +# From ``TUTOR_FROM``: Anki's 25 folded into Automation's, which is paid per +# 15-minute tutor block. Still sums to 240, so doing everything is 23:00. +TUTOR_LADDER: Final[Mapping[str, Rung]] = MappingProxyType( + { + "workout": Rung(110), + "leetcode": Rung(50), + "reading": Rung(30), + "automation": Rung(13, steps=(13, 12, 12)), + } +) + + def on_ladder(day: date) -> bool: """Whether ``day`` resolves shutdown on the sleep ladder.""" return day >= LADDER_FROM +def ladder_for(day: date) -> Mapping[str, Rung] | None: + """The rungs in force on ``day``; ``None`` before the ladder.""" + if not on_ladder(day): + return None + return TUTOR_LADDER if day >= TUTOR_FROM else LADDER + + def _rung(item: Earner, day: date) -> Rung | None: - return LADDER.get(item.name) if on_ladder(day) else None + rungs = ladder_for(day) + return None if rungs is None else rungs.get(item.name) def shutdown_minutes_for(item: Earner, day: date) -> int: @@ -82,9 +132,19 @@ def shutdown_minutes_for(item: Earner, day: date) -> int: def extra_shutdown_minutes_for(item: Earner, day: date) -> int: - """What each of ``item``'s further units earns on ``day`` (counted only).""" + """What ``item``'s second unit earns on ``day`` (counted only).""" rung = _rung(item, day) - return item.extra_shutdown_minutes if rung is None else rung.extra + return item.extra_shutdown_minutes if rung is None else rung.second + + +def shutdown_units_minutes(item: Earner, units: int, day: date) -> int: + """What ``units`` (already capped by the caller) earn on ``day``.""" + if units <= 0: + return 0 + rung = _rung(item, day) + if rung is not None: + return rung.minutes(units) + return item.shutdown_minutes + (units - 1) * item.extra_shutdown_minutes def shutdown_ceiling_for(day: date) -> int: diff --git a/earned_time/earned_time/_match.py b/earned_time/earned_time/_match.py index b225377..af1cacd 100644 --- a/earned_time/earned_time/_match.py +++ b/earned_time/earned_time/_match.py @@ -114,6 +114,21 @@ def workout_match(row: dict[str, object], window: Window) -> bool: return landed +def tutor_match(row: dict[str, object], window: Window) -> bool: + """A tutor block counts on the day it ended (``detail.ended_at``). + + The Automation tutor writes one row per finished 15-minute block; a + session that runs past midnight pays the day each block ended on. + """ + detail = row.get("detail") + raw = detail.get("ended_at") if isinstance(detail, dict) else None + landed = _within(raw, window) + if landed is None: + _logger.warning("tutor credit %r has no usable ended_at", row.get("entry_id")) + return False + return landed + + # The ``detail`` field each matcher decides on: the unix second the work # happened, which :func:`earned_time._credits.credit_time` reports. A matcher # without one (Anki's day key) falls back to the row's ``created_at``. @@ -122,5 +137,6 @@ CREDIT_STAMPS: Final[Mapping[CreditMatch, str]] = MappingProxyType( leetcode_match: "submitted_at", reading_match: "ended_at", workout_match: "completed_at", + tutor_match: "ended_at", } ) diff --git a/earned_time/earned_time/_policy.py b/earned_time/earned_time/_policy.py index ae719ac..4999b5c 100644 --- a/earned_time/earned_time/_policy.py +++ b/earned_time/earned_time/_policy.py @@ -16,6 +16,11 @@ unchanged: the base drops by what the new earner adds. From ``LADDER_FROM`` shutdown follows the sleep ladder instead (:mod:`earned_time._ladder`); gaming keeps the penalty derivation unchanged. + +**The registry is per day** (:func:`earners_for`). From ``TUTOR_FROM`` Anki +is retired -- neither penalised nor paid -- and Automation is the tutor's +counted earner; every earlier day resolves on :data:`EARNERS` exactly as +before, so the cutover never rewrites an old day. """ from __future__ import annotations @@ -24,11 +29,12 @@ from dataclasses import dataclass from datetime import UTC, date, datetime from typing import TYPE_CHECKING, Final, Literal -from earned_time._ladder import extra_shutdown_minutes_for, shutdown_minutes_for +from earned_time._ladder import TUTOR_FROM, shutdown_units_minutes from earned_time._match import ( anki_match, leetcode_match, reading_match, + tutor_match, workout_match, ) @@ -63,8 +69,12 @@ class Earner: ``"counted"`` earns per unit (the workout), and its live credit is applied by the consumer that counts the units. extra_shutdown_minutes: Each further unit, for ``"counted"`` earners. - penalty_from: From this day the base drops by ``gaming_minutes`` and - ``shutdown_minutes``. ``None`` means a pure bonus. + extra_gaming_minutes: Gaming time each further unit earns + (``"counted"``); the workout's are 0, the tutor's 15. + max_units: The most units one day can pay; ``None`` is unbounded. + penalty_from: From this day the gaming base drops by the most the + earner can pay (:attr:`max_gaming_minutes`). ``None`` means a pure + bonus. ledger: The gate's HMAC-signed ledger, relative to the home directory. ``None`` means no shared reader: the consumer supplies the answer. match: Which verified ``credit`` rows count for today. @@ -86,23 +96,34 @@ class Earner: ledger: str | None = None match: CreditMatch | None = None missing_ledger_is_no: bool = False + extra_gaming_minutes: int = 0 + max_units: int | None = None + + def capped(self, units: int) -> int: + """``units`` clamped to ``[0, max_units]``.""" + units = max(0, units) + return units if self.max_units is None else min(units, self.max_units) + + @property + def max_gaming_minutes(self) -> int: + """Gaming time a fully done day earns: what the penalty takes off.""" + return self.gaming_for(self.max_units or 1) def penalised_on(self, day: date) -> bool: """Whether this earner's base cut is in force on ``day``.""" return self.penalty_from is not None and day >= self.penalty_from def gaming_for(self, units: int) -> int: - """Gaming minutes ``units`` earn: the first unit only, never more.""" - return self.gaming_minutes if units > 0 else 0 + """Gaming minutes ``units`` earn, capped at ``max_units``.""" + count = self.capped(units) + if count == 0: + return 0 + return self.gaming_minutes + (count - 1) * self.extra_gaming_minutes def shutdown_for(self, units: int, day: date | None = None) -> int: """Shutdown minutes ``units`` earn on ``day`` (default today).""" - if units <= 0: - return 0 target = day or datetime.now(tz=UTC).astimezone().date() - return shutdown_minutes_for(self, target) + (units - 1) * ( - extra_shutdown_minutes_for(self, target) - ) + return shutdown_units_minutes(self, self.capped(units), target) WORKOUT: Final = Earner( @@ -148,6 +169,7 @@ ANKI: Final = Earner( ) # Same gate, same row shape: anki-guard's ``automation`` quota (the Automation # deck only) writes its own ledger, so the Anki day matcher applies unchanged. +# Days before ``TUTOR_FROM`` only; from then on see AUTOMATION_TUTOR. AUTOMATION: Final = Earner( name="automation", label="Automation", @@ -159,7 +181,28 @@ AUTOMATION: Final = Earner( missing_ledger_is_no=True, ) -# Order is the order of reason strings and of screen-locker's live pass. +# From ``TUTOR_FROM``: the Automation tutor pays per verified 15-minute block, +# up to four a day. Anki's 30 gaming minutes are folded in (4 x 15 = 60), so +# retiring Anki is not a free +30: the base drops by the full 60. +AUTOMATION_TUTOR: Final = Earner( + name="automation", + label="Automation", + gaming_minutes=15, + extra_gaming_minutes=15, + max_units=4, + # Pre-ladder values, unused (TUTOR_FROM is after LADDER_FROM); the ladder + # pays 13/13/12/12 (``TUTOR_LADDER``). + shutdown_minutes=13, + extra_shutdown_minutes=13, + kind="counted", + penalty_from=TUTOR_FROM, + ledger=".local/share/automation_tutor/ledger.json", + match=tutor_match, + missing_ledger_is_no=True, +) + +# The registry of every day before ``TUTOR_FROM``. Order is the order of +# reason strings and of screen-locker's live pass. EARNERS: Final[tuple[Earner, ...]] = ( WORKOUT, LEETCODE, @@ -167,6 +210,13 @@ EARNERS: Final[tuple[Earner, ...]] = ( ANKI, AUTOMATION, ) +# From ``TUTOR_FROM``: Anki retired, Automation paid per tutor block. +TUTOR_EARNERS: Final[tuple[Earner, ...]] = ( + WORKOUT, + LEETCODE, + READING, + AUTOMATION_TUTOR, +) def earner(name: str, earners: tuple[Earner, ...] = EARNERS) -> Earner: diff --git a/earned_time/earned_time/_registry.py b/earned_time/earned_time/_registry.py new file mode 100644 index 0000000..d1082ac --- /dev/null +++ b/earned_time/earned_time/_registry.py @@ -0,0 +1,67 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Which registry is in force on a day: the tutor cutover (``TUTOR_FROM``). + +Before ``TUTOR_FROM`` every day resolves on :data:`~earned_time.EARNERS`, +exactly as it always has; from it on :data:`~earned_time.TUTOR_EARNERS` +(Anki retired, Automation paid per tutor block). The switch is a date, so it +never rewrites how an old day resolved. +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +from functools import cache +import sys +from typing import TYPE_CHECKING, Final + +from earned_time._ladder import ANKI_WAIVED_FROM, TUTOR_FROM + +if TYPE_CHECKING: + from earned_time._policy import Earner + +# Waived from ``ANKI_WAIVED_FROM`` until ``TUTOR_FROM``. +_WAIVED: Final = frozenset({"anki", "automation"}) + + +def registries() -> tuple[tuple[Earner, ...], ...]: + """Every registry, oldest first. + + Read through the package, not this module: consumers' test suites patch + ``earned_time.EARNERS`` to register a stand-in earner, and the patch must + keep reaching :func:`earners_for` and :func:`all_earners`. + """ + package = sys.modules["earned_time"] # loaded: it imports this module + return (package.EARNERS, package.TUTOR_EARNERS) + + +def earners_for(day: date | None = None) -> tuple[Earner, ...]: + """The registry in force on ``day`` (default today). + + Between ``ANKI_WAIVED_FROM`` and ``TUTOR_FROM`` the old registry without + Anki and the anki-backed Automation: neither penalised nor paid. + """ + target = day or datetime.now(tz=UTC).astimezone().date() + before, tutor = registries() + if target >= TUTOR_FROM: + return tutor + if target >= ANKI_WAIVED_FROM: + return _without_waived(before) + return before + + +@cache +def _without_waived(registry: tuple[Earner, ...]) -> tuple[Earner, ...]: + """``registry`` minus the waived earners; one stable tuple per registry.""" + return tuple(e for e in registry if e.name not in _WAIVED) + + +def all_earners() -> tuple[Earner, ...]: + """Every earner of every registry, once each, oldest registry first. + + What a reader that must not miss a ledger across a cutover watches + (screen-locker's path unit). + """ + seen: dict[Earner, None] = {} + for registry in registries(): + seen.update(dict.fromkeys(registry)) + return tuple(seen) diff --git a/earned_time/earned_time/_resolve.py b/earned_time/earned_time/_resolve.py index 4934768..43f316e 100644 --- a/earned_time/earned_time/_resolve.py +++ b/earned_time/earned_time/_resolve.py @@ -19,14 +19,14 @@ from datetime import UTC, date, datetime import logging from typing import TYPE_CHECKING, Final -from earned_time._ladder import on_ladder, shutdown_ceiling_for, shutdown_minutes_for +from earned_time._ladder import on_ladder, shutdown_ceiling_for from earned_time._policy import ( - EARNERS, GAMING_BASE_MINUTES, GAMING_CEILING_MINUTES, SHUTDOWN_BASE_MINUTES, Earner, ) +from earned_time._registry import all_earners, earners_for if TYPE_CHECKING: from collections.abc import Mapping @@ -104,30 +104,34 @@ def _today() -> date: def _shutdown_floor(day: date, earners: tuple[Earner, ...]) -> int: """Shutdown of a day that earned nothing. - On the ladder: the ceiling minus every registered earner's first unit, - so doing everything lands exactly on the ceiling. Before it: the old base - minus the penalties in force. + On the ladder: the ceiling minus the most every registered earner can + pay (all of a capped earner's units), so doing everything lands exactly + on the ceiling. Before it: the old base minus the penalties in force. """ if on_ladder(day): return shutdown_ceiling_for(day) - sum( - shutdown_minutes_for(e, day) for e in earners + e.shutdown_for(e.max_units or 1, day) for e in earners ) penalised = [e for e in earners if e.penalised_on(day)] return SHUTDOWN_BASE_MINUTES - sum(e.shutdown_minutes for e in penalised) -def base_for(day: date | None = None, earners: tuple[Earner, ...] = EARNERS) -> Base: - """The floor for ``day`` (default today). +def base_for( + day: date | None = None, earners: tuple[Earner, ...] | None = None +) -> Base: + """The floor for ``day`` (default today) under ``earners`` (default its own). - Gaming: every penalty in force, taken off the gaming base. Shutdown: see - :func:`_shutdown_floor` -- the two are derived separately so the ladder - can never move a gaming minute. + Gaming: every penalty in force, the most each earner can pay, taken off + the gaming base. Shutdown: see :func:`_shutdown_floor` -- the two are + derived separately so the ladder can never move a gaming minute. """ target = day or _today() - penalised = [e for e in earners if e.penalised_on(target)] + registry = earners_for(target) if earners is None else earners + penalised = [e for e in registry if e.penalised_on(target)] return Base( - gaming_minutes=GAMING_BASE_MINUTES - sum(e.gaming_minutes for e in penalised), - shutdown_minutes=_shutdown_floor(target, earners), + gaming_minutes=GAMING_BASE_MINUTES + - sum(e.max_gaming_minutes for e in penalised), + shutdown_minutes=_shutdown_floor(target, registry), ) @@ -140,7 +144,7 @@ def _units(answer: Answer) -> int | None: def resolve( answers: Mapping[str, Answer], day: date | None = None, - earners: tuple[Earner, ...] = EARNERS, + earners: tuple[Earner, ...] | None = None, ) -> Resolution: """Sum the base and every earned term, each capped at its ceiling. @@ -148,9 +152,11 @@ def resolve( answers: Per earner name, a unit count or ``None`` ("could not check"). A registered earner missing from it earns nothing and is logged: the consumer forgot to ask, which must not pass as a "no". - day: The day to resolve (default today): it picks the base, the - ceiling, and (on the ladder) each earner's shutdown minutes. - earners: The registry; tests pass their own. + day: The day to resolve (default today): it picks the registry, the + base, the ceiling, and (on the ladder) each earner's minutes. + earners: The registry (default :func:`earners_for` the day); tests + pass their own. An answer for an earner registered on other days + only (Anki after ``TUTOR_FROM``) is ignored. Returns: The resolution, with a term for every registered earner. @@ -159,15 +165,16 @@ def resolve( KeyError: ``answers`` names an earner that is not registered -- a typo that would otherwise silently earn nothing. """ - known = {e.name for e in earners} + target = day or _today() + registry = earners_for(target) if earners is None else earners + known = {e.name for e in (*registry, *all_earners())} unknown = sorted(set(answers) - known) if unknown: msg = f"not registered earners: {', '.join(unknown)}" raise KeyError(msg) - target = day or _today() - base = base_for(target, earners) + base = base_for(target, registry) terms: list[Term] = [] - for item in earners: + for item in registry: if item.name not in answers: _logger.warning( "No answer for the %s earner; it earns nothing today", item.label diff --git a/earned_time/earned_time/tests/test_ladder.py b/earned_time/earned_time/tests/test_ladder.py index 73faf07..e89966f 100644 --- a/earned_time/earned_time/tests/test_ladder.py +++ b/earned_time/earned_time/tests/test_ladder.py @@ -36,12 +36,14 @@ NAMES = [e.name for e in EARNERS] def _shutdown(day: date, **done: int) -> str: answers = {name: done.get(name, 0) for name in NAMES} - minutes = resolve(answers, day).shutdown_minutes + minutes = resolve(answers, day, EARNERS).shutdown_minutes return f"{minutes // 60:02d}:{minutes % 60:02d}" def _gaming(day: date, **done: int) -> int: - return resolve({name: done.get(name, 0) for name in NAMES}, day).gaming_minutes + return resolve( + {name: done.get(name, 0) for name in NAMES}, day, EARNERS + ).gaming_minutes def test_switch_date_and_wake_anchor() -> None: @@ -61,9 +63,9 @@ def test_ceiling_follows_the_alarm_but_old_days_stay( ) -> None: monkeypatch.setattr(ladder, "WAKE_MINUTES", 6 * 60) assert shutdown_ceiling_for(FIRST) == 22 * 60 - assert base_for(FIRST).shutdown_minutes == 18 * 60 + assert base_for(FIRST, EARNERS).shutdown_minutes == 18 * 60 assert shutdown_ceiling_for(EVE) == 23 * 60 - assert base_for(EVE).shutdown_minutes == 18 * 60 + assert base_for(EVE, EARNERS).shutdown_minutes == 18 * 60 @pytest.mark.parametrize( @@ -89,7 +91,9 @@ def test_every_earner_has_a_rung() -> None: def test_first_units_fill_the_gap_exactly() -> None: total = sum(shutdown_minutes_for(e, FIRST) for e in EARNERS) assert total == 240 - assert base_for(FIRST).shutdown_minutes == shutdown_ceiling_for(FIRST) - total + assert ( + base_for(FIRST, EARNERS).shutdown_minutes == shutdown_ceiling_for(FIRST) - total + ) def test_extra_workouts_earn_nothing_on_the_ladder() -> None: @@ -136,4 +140,8 @@ def test_the_eve_resolves_the_old_way() -> None: @pytest.mark.parametrize("done", [{}, {"workout": 2}, dict.fromkeys(NAMES, 1)]) def test_gaming_is_the_same_on_both_sides_of_the_switch(done: dict[str, int]) -> None: assert _gaming(EVE, **done) == _gaming(FIRST, **done) - assert base_for(EVE).gaming_minutes == base_for(FIRST).gaming_minutes == 180 + assert ( + base_for(EVE, EARNERS).gaming_minutes + == base_for(FIRST, EARNERS).gaming_minutes + == 180 + ) diff --git a/earned_time/earned_time/tests/test_tutor.py b/earned_time/earned_time/tests/test_tutor.py new file mode 100644 index 0000000..1a0b7c3 --- /dev/null +++ b/earned_time/earned_time/tests/test_tutor.py @@ -0,0 +1,146 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The tutor cutover: Anki retired, Automation paid per 15-minute block.""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta + +import pytest + +from earned_time import ( + AUTOMATION, + AUTOMATION_TUTOR, + EARNERS, + LADDER, + TUTOR_EARNERS, + TUTOR_FROM, + TUTOR_LADDER, + WORKOUT, + Rung, + all_earners, + base_for, + earners_for, + extra_shutdown_minutes_for, + ladder_for, + resolve, +) + +EVE = TUTOR_FROM - timedelta(days=1) +OTHERS = {"workout": 1, "leetcode": 1, "reading": 1} + + +def _hhmm(minutes: int) -> str: + return f"{minutes // 60:02d}:{minutes % 60:02d}" + + +def _day(blocks: int, **others: int) -> tuple[int, str]: + day = resolve({**others, "automation": blocks}, TUTOR_FROM) + return day.gaming_minutes, _hhmm(day.shutdown_minutes) + + +def test_tutor_from_is_after_the_ladder_and_not_today() -> None: + assert date(2026, 10, 10) < TUTOR_FROM + assert date(2026, 10, 9) < TUTOR_FROM # never the day it was prepared + + +def test_registry_switches_on_tutor_from() -> None: + assert earners_for(EVE) is not TUTOR_EARNERS # the waiver (ANKI_WAIVED_FROM) + assert earners_for(None) is earners_for(datetime.now().astimezone().date()) + assert earners_for(TUTOR_FROM) is TUTOR_EARNERS + assert "anki" not in {e.name for e in TUTOR_EARNERS} + assert ladder_for(EVE) is LADDER + assert ladder_for(TUTOR_FROM) is TUTOR_LADDER + assert ladder_for(date(2026, 10, 1)) is None + + +def test_all_earners_lists_each_once() -> None: + names = [e.name for e in all_earners()] + assert names.count("workout") == 1 + assert AUTOMATION in all_earners() + assert AUTOMATION_TUTOR in all_earners() + + +def test_tutor_ladder_still_sums_to_the_old_one() -> None: + old = sum(r.minutes(4) for r in LADDER.values()) + new = sum(r.minutes(4) for r in TUTOR_LADDER.values()) + assert old == new == 240 + assert TUTOR_LADDER["automation"].minutes(4) == 50 + + +def test_nothing_done_is_three_hours_and_19() -> None: + assert _day(0) == (180, "19:00") + assert base_for(TUTOR_FROM).gaming_minutes == 180 + + +def test_everything_but_the_tutor() -> None: + assert _day(0, **OTHERS) == (420, "22:10") + + +@pytest.mark.parametrize( + ("blocks", "gaming", "shutdown"), + [(1, 435, "22:23"), (2, 450, "22:36"), (3, 465, "22:48"), (4, 480, "23:00")], +) +def test_each_block_pays_15_gaming_and_its_rung( + blocks: int, gaming: int, shutdown: str +) -> None: + assert _day(blocks, **OTHERS) == (gaming, shutdown) + + +def test_partial_credit_without_the_others() -> None: + assert _day(1) == (195, "19:13") + assert _day(2) == (210, "19:26") + assert _day(4) == (240, "19:50") + + +def test_a_fifth_block_is_clamped_to_four() -> None: + assert _day(5, **OTHERS) == _day(4, **OTHERS) == (480, "23:00") + term = resolve({"automation": 5}, TUTOR_FROM).term("automation") + assert (term.answer, term.gaming_minutes, term.shutdown_minutes) == (5, 60, 50) + + +def test_anki_is_neither_penalised_nor_paid_from_tutor_from() -> None: + with_anki = resolve({**OTHERS, "anki": 1, "automation": 4}, TUTOR_FROM) + assert (with_anki.gaming_minutes, with_anki.shutdown_minutes) == (480, 23 * 60) + assert "anki" not in {t.earner.name for t in with_anki.terms} + + +def test_the_eve_resolves_as_before() -> None: + eve = resolve({**OTHERS, "anki": 0, "automation": 0}, EVE, EARNERS) + assert eve.base.gaming_minutes == 180 + assert (eve.gaming_minutes, _hhmm(eve.shutdown_minutes)) == (420, "22:10") + done = resolve({**OTHERS, "anki": 1, "automation": 1}, EVE, EARNERS) + assert (done.gaming_minutes, _hhmm(done.shutdown_minutes)) == (480, "23:00") + assert done.term("automation").earner is AUTOMATION + + +def test_unknown_names_still_raise() -> None: + with pytest.raises(KeyError, match="piano"): + resolve({"piano": 1}, TUTOR_FROM) + + +def test_rung_steps_and_extras() -> None: + rung = Rung(13, steps=(13, 12, 12)) + assert [rung.minutes(n) for n in range(6)] == [0, 13, 26, 38, 50, 50] + assert rung.second == 13 + assert Rung(10, extra=5).minutes(3) == 20 + assert Rung(10, extra=5).second == 5 + assert extra_shutdown_minutes_for(AUTOMATION_TUTOR, TUTOR_FROM) == 13 + + +def test_gaming_for_and_max() -> None: + assert [AUTOMATION_TUTOR.gaming_for(n) for n in (-1, 0, 1, 4, 9)] == [ + 0, + 0, + 15, + 60, + 60, + ] + assert AUTOMATION_TUTOR.max_gaming_minutes == 60 + assert WORKOUT.max_gaming_minutes == WORKOUT.gaming_minutes + assert WORKOUT.gaming_for(3) == WORKOUT.gaming_minutes + + +def test_tutor_shutdown_before_the_ladder_uses_its_own_fields() -> None: + old = date(2026, 10, 1) + assert AUTOMATION_TUTOR.shutdown_for(2, old) == 26 + assert AUTOMATION_TUTOR.shutdown_for(0, old) == 0 diff --git a/earned_time/earned_time/tests/test_tutor_ledger.py b/earned_time/earned_time/tests/test_tutor_ledger.py new file mode 100644 index 0000000..6dc3dd5 --- /dev/null +++ b/earned_time/earned_time/tests/test_tutor_ledger.py @@ -0,0 +1,111 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The tutor ledger: which signed block rows count, and how many.""" + +from __future__ import annotations + +from datetime import date, datetime, time, timedelta +import json +from typing import TYPE_CHECKING + +import pytest + +from earned_time import ( + AUTOMATION_TUTOR, + credit_units, + day_window, + entry_signature, + first_credit_at, +) +from earned_time._match import tutor_match + +if TYPE_CHECKING: + from pathlib import Path + +KEY = b"test-key" +DAY = date(2026, 10, 12) +WINDOW = day_window(DAY) + + +def _at(hour: int, minute: int = 0, day: date = DAY) -> float: + return datetime.combine(day, time(hour, minute)).astimezone().timestamp() + + +def block(session: str, number: int, ended: object) -> dict[str, object]: + """One signed row in the contract the tutor writes.""" + row: dict[str, object] = { + "kind": "credit", + "entry_id": f"{session}-b{number}", + "day": DAY.isoformat(), + "created_at": _at(23, 59), + "detail": { + "session_id": session, + "block": number, + "active_seconds": 900, + "ended_at": ended, + "checks_passed": 3, + "checks_total": 3, + }, + } + return {**row, "hmac": entry_signature(row, KEY)} + + +@pytest.fixture +def key_file(tmp_path: Path) -> Path: + path = tmp_path / "hmac.key" + path.write_bytes(KEY) + return path + + +def _ledger(tmp_path: Path, rows: list[object]) -> Path: + path = tmp_path / "ledger.json" + path.write_text(json.dumps({"entries": rows}), encoding="utf-8") + return path + + +def test_match_by_ended_at() -> None: + assert tutor_match(block("s", 1, _at(18, 15)), WINDOW) + yesterday = _at(23, 50, DAY - timedelta(days=1)) + assert not tutor_match(block("s", 1, yesterday), WINDOW) + + +@pytest.mark.parametrize("ended", [None, "soon"]) +def test_unusable_ended_at_never_counts( + ended: object, caplog: pytest.LogCaptureFixture +) -> None: + assert not tutor_match(block("s", 1, ended), WINDOW) + assert "no usable ended_at" in caplog.text + + +def test_row_without_detail_never_counts() -> None: + assert not tutor_match({"kind": "credit", "entry_id": "x"}, WINDOW) + + +def test_units_count_blocks_once_and_skip_forgeries( + tmp_path: Path, key_file: Path +) -> None: + tampered = block("s1", 3, _at(19)) + tampered["detail"] = {**tampered["detail"], "active_seconds": 9000} # type: ignore[dict-item] + rows: list[object] = [ + block("s1", 1, _at(18, 15)), + block("s1", 2, _at(18, 30)), + block("s1", 2, _at(18, 30)), # the same block written twice + tampered, + block("s0", 1, _at(23, 0, DAY - timedelta(days=1))), + ] + path = _ledger(tmp_path, rows) + assert credit_units(AUTOMATION_TUTOR, path, key_file, DAY) == 2 + assert first_credit_at(AUTOMATION_TUTOR, path, key_file, DAY) == _at(18, 15) + + +def test_rows_without_entry_id_each_count(tmp_path: Path, key_file: Path) -> None: + rows: list[object] = [] + for number in (1, 2): + row = {k: v for k, v in block("s", number, _at(9)).items() if k != "hmac"} + del row["entry_id"] + rows.append({**row, "hmac": entry_signature(row, KEY)}) + path = _ledger(tmp_path, rows) + assert credit_units(AUTOMATION_TUTOR, path, key_file, DAY) == 2 + + +def test_missing_tutor_ledger_is_zero(tmp_path: Path, key_file: Path) -> None: + assert credit_units(AUTOMATION_TUTOR, tmp_path / "no.json", key_file, DAY) == 0 diff --git a/earned_time/earned_time/tests/test_waiver.py b/earned_time/earned_time/tests/test_waiver.py new file mode 100644 index 0000000..d855ca0 --- /dev/null +++ b/earned_time/earned_time/tests/test_waiver.py @@ -0,0 +1,67 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""The gap-day waiver: Anki and the old Automation, from ANKI_WAIVED_FROM.""" + +from __future__ import annotations + +from datetime import timedelta + +import pytest + +from earned_time import ( + ANKI_WAIVED_FROM, + EARNERS, + LADDER_FROM, + TUTOR_EARNERS, + TUTOR_FROM, + earners_for, + resolve, +) + +BEFORE = ANKI_WAIVED_FROM - timedelta(days=1) +NAMES = ("workout", "leetcode", "reading", "anki", "automation") + + +def test_the_waiver_sits_between_the_old_registry_and_the_tutor() -> None: + assert BEFORE < ANKI_WAIVED_FROM < TUTOR_FROM + assert earners_for(BEFORE) is EARNERS + assert [e.name for e in earners_for(ANKI_WAIVED_FROM)] == list(NAMES[:3]) + assert earners_for(TUTOR_FROM) is TUTOR_EARNERS + + +def test_nothing_done_is_raised_by_exactly_what_the_two_paid() -> None: + zero = dict.fromkeys(NAMES, 0) + # The first ladder day inside the waiver (the waiver began pre-ladder). + assert ANKI_WAIVED_FROM <= LADDER_FROM < TUTOR_FROM + gap = resolve(zero, LADDER_FROM) + assert gap.base.gaming_minutes == 240 + assert gap.gaming_minutes == 240 + assert gap.shutdown_minutes == 23 * 60 - 190 # 19:50: workout+leetcode+reading + + +@pytest.mark.parametrize("workout", [0, 1]) +@pytest.mark.parametrize("leetcode", [0, 1]) +@pytest.mark.parametrize("reading", [0, 1]) +@pytest.mark.parametrize("anki", [0, 1]) +def test_raise_only_on_every_combination( + workout: int, leetcode: int, reading: int, anki: int +) -> None: + answers = { + "workout": workout, + "leetcode": leetcode, + "reading": reading, + "anki": anki, + "automation": anki, + } + day = ANKI_WAIVED_FROM + waived = resolve(answers, day) + unwaived = resolve(answers, day, EARNERS) + assert waived.gaming_minutes >= unwaived.gaming_minutes + assert waived.shutdown_minutes >= unwaived.shutdown_minutes + if anki: + assert waived.gaming_minutes == unwaived.gaming_minutes + assert waived.shutdown_minutes == unwaived.shutdown_minutes + + +def test_all_done_still_8h_and_23() -> None: + done = resolve(dict.fromkeys(NAMES, 1), ANKI_WAIVED_FROM) + assert (done.gaming_minutes, done.shutdown_minutes) == (480, 23 * 60) diff --git a/earned_time/pyproject.toml b/earned_time/pyproject.toml index e34190e..8e76ba2 100644 --- a/earned_time/pyproject.toml +++ b/earned_time/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "earned-time" -version = "0.4.0" +version = "0.5.0" description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time" requires-python = ">=3.14" dependencies = [] diff --git a/earned_time/scripts/__init__.py b/earned_time/scripts/__init__.py new file mode 100644 index 0000000..68bcc38 --- /dev/null +++ b/earned_time/scripts/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Runnable demos of the registry; not part of the installed package.""" diff --git a/earned_time/scripts/demo_tutor_cutover.py b/earned_time/scripts/demo_tutor_cutover.py new file mode 100644 index 0000000..0bffe1d --- /dev/null +++ b/earned_time/scripts/demo_tutor_cutover.py @@ -0,0 +1,123 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""Demo: what the tutor cutover pays, read from a signed temp ledger. + +Writes tutor block rows in the exact contract the Automation tutor writes, +signs them with a throwaway key, counts them with the shared reader +(``credit_units``) and resolves the day. Prints gaming minutes and shutdown +for the day before ``TUTOR_FROM`` and for ``TUTOR_FROM`` itself with 0-5 +blocks and a tampered row. Touches no real ledger, key or schedule. + +Run: ``PYTHONPATH=earned_time python3 earned_time/scripts/demo_tutor_cutover.py`` +""" + +from __future__ import annotations + +from datetime import date, datetime, time, timedelta +import json +from pathlib import Path +import sys +import tempfile + +import earned_time +from earned_time import AUTOMATION_TUTOR, TUTOR_FROM, credit_units, entry_signature + +_KEY = b"demo-key-not-the-real-one" +_OTHERS = {"workout": 1, "leetcode": 1, "reading": 1} +_NONE = {"workout": 0, "leetcode": 0, "reading": 0} + + +def _say(line: str) -> None: + sys.stdout.write(line + "\n") + + +def _hhmm(minutes: int) -> str: + return f"{minutes // 60:02d}:{minutes % 60:02d}" + + +def _block(session: str, number: int, day: date) -> dict[str, object]: + start = datetime.combine(day, time(18)).astimezone() + ended = (start + timedelta(minutes=15 * number)).timestamp() + row: dict[str, object] = { + "kind": "credit", + "entry_id": f"{session}-b{number}", + "day": day.isoformat(), + "created_at": ended + 1, + "detail": { + "session_id": session, + "block": number, + "active_seconds": 900, + "ended_at": ended, + "checks_passed": 3, + "checks_total": 3, + }, + } + return {**row, "hmac": entry_signature(row, _KEY)} + + +def _units(folder: Path, rows: list[dict[str, object]], day: date) -> int: + ledger = folder / "ledger.json" + ledger.write_text(json.dumps({"entries": rows}), encoding="utf-8") + key = folder / "hmac.key" + key.write_bytes(_KEY) + units = credit_units(AUTOMATION_TUTOR, ledger, key, day) + if units is None: + msg = "demo ledger unreadable" + raise RuntimeError(msg) + return units + + +def _line(label: str, answers: dict[str, int], day: date) -> str: + res = earned_time.resolve(answers, day) + names = {t.earner.name for t in res.terms} + if "automation" in names: + term = res.term("automation") + paid = f"automation +{term.gaming_minutes}g/+{term.shutdown_minutes}s" + else: + paid = "automation waived" # ANKI_WAIVED_FROM..TUTOR_FROM + return ( + f"{label:<44} {res.gaming_minutes:>4} min ({res.gaming_minutes / 60:.2f} h)" + f" {_hhmm(res.shutdown_minutes)}" + f" [{paid}," + f" base {res.base.gaming_minutes}g/{_hhmm(res.base.shutdown_minutes)}]" + ) + + +def main() -> None: + """Print the table.""" + eve = TUTOR_FROM - timedelta(days=1) + _say(f"TUTOR_FROM = {TUTOR_FROM} (placeholder; set at go-live)") + _say(f"{'case':<44} {'gaming':>15} shutdown") + for done in (0, 1): + answers = {**_OTHERS, "anki": done, "automation": done} + _say(_line(f"{eve} eve: others + anki/automation={done}", answers, eve)) + nothing = {**_NONE, "anki": 0, "automation": 0} + _say(_line(f"{eve} eve: nothing done", nothing, eve)) + with tempfile.TemporaryDirectory() as raw: + folder = Path(raw) + for blocks in (0, 1, 2, 4, 5): + rows = [_block("s1", n, TUTOR_FROM) for n in range(1, blocks + 1)] + units = _units(folder, rows, TUTOR_FROM) + for others, tag in ((_OTHERS, "+ all others"), (_NONE, "tutor only")): + answers = {**others, "automation": units} + label = f"{TUTOR_FROM}: {blocks} rows -> {units} units, {tag}" + _say(_line(label, answers, TUTOR_FROM)) + rows = [_block("s2", n, TUTOR_FROM) for n in range(1, 5)] + detail = rows[2]["detail"] + if not isinstance(detail, dict): + msg = "demo row lost its detail" + raise TypeError(msg) + rows[2]["detail"] = {**detail, "active_seconds": 9000} # edited after signing + units = _units(folder, rows, TUTOR_FROM) + label = f"{TUTOR_FROM}: 4 rows, 1 tampered -> {units} units" + _say(_line(label, {**_OTHERS, "automation": units}, TUTOR_FROM)) + anki = {**_OTHERS, "anki": 1, "automation": 4} + _say( + _line( + f"{TUTOR_FROM}: all + a stale anki answer (ignored)", anki, TUTOR_FROM + ) + ) + _say(f"gaming ceiling {earned_time.GAMING_CEILING_MINUTES} min (8 h)") + + +if __name__ == "__main__": + main()