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) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-09 20:53:31 +02:00
parent 223b57eb87
commit 6641927dbe
15 changed files with 765 additions and 48 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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",
}
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = []

View File

@ -0,0 +1,2 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Runnable demos of the registry; not part of the installed package."""

View File

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