utils/earned_time/README.md

176 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
```