# earned_time Penalty-then-reward for gate apps: **one** table of what every gate earns in gaming time and shutdown time, read by both consumers. | consumer | applies | via | |---|---|---| | steam-backlog-enforcer | `Resolution.gaming_minutes` | `_budget_resolve.resolve_budget` | | screen-locker | `Resolution.shutdown_minutes` | `_shutdown_base.reset_to_base_if_new_day` + live pass | A gate (leetcode-guard, book-guard, anki-guard, screen-locker's workout credit, ...) never touches either. It publishes a fact — HMAC-signed `credit` rows in its ledger — and is registered here once. ## The model **Gaming** (unchanged): a base of 5 h, minus every earner's `gaming_minutes` from its `penalty_from` date; each earner done adds its `gaming_minutes` back. Capped at 8 h. **Shutdown** has two regimes, switched by date (`LADDER_FROM`, 2026-10-10 — never a same-day cut): - **Before `LADDER_FROM`:** base 20:00 minus the penalties in force, plus each earner's `shutdown_minutes` (a second workout +60), capped at 23:00. - **From `LADDER_FROM` — the sleep ladder.** The ceiling is eight hours of sleep before the alarm: `WAKE_MINUTES` (07:00) − 8 h = 23:00. The floor is that ceiling minus the sum of every registered earner's first-unit rung (`LADDER`, a `Rung` per earner name), so doing everything lands exactly on the ceiling: | done | shutdown | |---|---| | nothing | 19:00 | | workout (110) | 20:50 | | + LeetCode (50) | 21:40 | | + reading (30) | 22:10 | | + Anki (25) | 22:35 | | + Automation (25) | 23:00 | A second workout earns nothing more (`Rung.extra` defaults to 0). Move the alarm (`WAKE_MINUTES = 6 * 60`) and the whole ladder shifts an hour earlier on its own. Days before `LADDER_FROM` keep their frozen 23:00 ceiling, so the change never rewrites history. 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. - `base_for(day=None, earners=EARNERS) -> Base` — the day's floors. - `done_today(earner, ledger, key_file, *, now=None) -> bool | None` - `first_credit_at(earner, ledger, key_file, day) -> float | None` — unix time of the earliest verified credit that counts for `day`. The time is the stamp the earner's `match` decides on (`_match.CREDIT_STAMPS`: LeetCode `submitted_at`, reading `ended_at`, workout `completed_at`; Anki/Automation fall back to `created_at`). It can lie outside `day` when a gate's day is 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 (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 view needs to list what is still left to earn. `Earner.shutdown_for(units, day=None)` is the day-aware sum (default today). Never read `earner.shutdown_minutes` directly: it is the pre-ladder value. - `day_window(day)`, `today_window(now=None)`, `entry_signature`, `verified`. ## The workout ledger `WORKOUT` is `kind="counted"` with a ledger at `~/.local/share/workout_locker/ledger.json`, written by screen-locker: one signed credit row per credited unit, `{"kind": "credit", "entry_id", "day", "detail": {"completed_at", "source"}}`. `workout_match` counts a `runnerup_tcx` row when `completed_at` falls in the window, and a `rest_day` row on the `day` it names — only if `detail.declared_at` is before that day's local midnight. A rest day declared on the day itself is logged and does not count. ## Adding a gate 1. The gate writes `{"entries": [...]}` to its ledger. Each row has `kind: "credit"`, a `detail` dict, and an `hmac` signed with `/etc/workout-locker/hmac.key` (`earned_time.entry_signature`, the same as gatelock's `log_integrity`). 2. Add a matcher to `earned_time/_match.py` and an `Earner` to `earned_time/_policy.py` and `EARNERS`: ```python PIANO = Earner( name="piano", # also screen-locker's "piano_bonus_date" stamp label="piano", gaming_minutes=30, shutdown_minutes=30, # pre-ladder value penalty_from=date(2026, 11, 1), # gaming base drops by 30 from then on ledger=".local/share/piano_guard/ledger.json", match=piano_match, # which verified credit rows count today missing_ledger_is_no=True, ) ``` Then give it a rung in `LADDER` (`PIANO.name: Rung(20)`), and, if its matcher decides on an event stamp, an entry in `_match.CREDIT_STAMPS`. On the ladder a new earner's rung comes out of the floor the day its pin lands (the ceiling is fixed): re-split the rungs and ship it the day the gate can actually be satisfied. 3. Bump the version, tag `earned-time-vX.Y.Z`, and bump the pin in both consumers. Neither needs code for a ledger-backed flat earner. ## Rules the consumers rely on - **Minutes everywhere.** Shutdown is minutes after local midnight. - **Paths are passed in.** No reader resolves a real path itself; consumers' test suites redirect their own constants. - **`None` is "could not check", never "no".** It earns nothing (fail closed), but consumers log it. A missing answer in `resolve()` is logged as well; an unknown earner name raises. - **The HMAC key is root-owned and 0644 on purpose.** The gates run as the user and must sign their rows, so the user can read the key; root owns it so a user process cannot replace or rotate it. The signature therefore stops casual edits and corruption, not a determined forger with a shell -- the accepted trade-off of user-run gates. - **Stdlib only.** The gaming daemon imports this as root, headless. ## Install ```console 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 from the same user site-packages. ## Test ```console scripts/run_subproject_tests.sh earned_time ```