utils/earned_time/README.md
Krzysztof kuhy Rudnicki ba4dd58545
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
earned_time pre-commit / pre-commit (push) Has been cancelled
earned_time tests / test (push) Has been cancelled
leetcode-coach pre-commit / pre-commit (push) Has been cancelled
leetcode-coach tests / test (push) Has been cancelled
earned_time 0.21.0: focus day pays 240 gaming
kuhy 2026-11-02: keep the 180 base cut, pay 2 gaming min per focus min
(240 at 120/120), so a full focus day is 8 h again; an idle day stays 2 h.
FocusEarner.cut_gaming_minutes holds the cut (_resolve._gaming_cut) so the
pay (cut + pure bonuses again) can exceed it. Shutdown unchanged. Carry
rows (focus_project 0.6.0, <project>-<YYYYMMDD>-carry) pay like own rows;
test added. Failure modes unchanged: focus_project unusable = no project
(fail-open, logged); unreadable ledger = None, earns nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 15:22:10 +01:00

251 lines
15 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. LeetCode also counts its signed `source == "leetcode"` `charge` for the day (0.17.0, `ROW_KINDS`): a day paid ahead from banked credits earns like a solve; escape and outage charges never do. Since 0.18.0 that charge is the *only* LeetCode row that earns while the gate is on: a credit is one unit of a price that may be 5, so one solve on a 5-credit day earns nothing. A consumer passes `gate_off=True` (live: a free day, or a sick day not keeping `leetcode`) to `done_today` / `first_credit_at` / `credit_units`, and then one verified solve earns again (`PAID_DAY_KINDS`).
## The model
**Gaming**: a base (`gaming_base_minutes_for(day)`) minus every earner's
`gaming_minutes` from its `penalty_from` date; each earner done adds its
`gaming_minutes` back. Capped at `gaming_ceiling_minutes_for(day)`.
**Leisure budget (0.14.0, `_leisure.py`).** From the first leisure day the
budget covers all leisure (games, browsing, video, messaging): base 5 h -> 7 h,
cap 8 h -> 10 h; pay, penalties and shutdown untouched (full day 480 -> 600).
`resolve`/`base_for(..., leisure=)` (0.16.0): `None` = by the switch file on
`day`, `True` = leisure pricing (shadow budget), `False` = old pricing; a
consumer billing on the 06:00 gaming day passes `leisure_active(gaming_day)`.
**The switch file (0.15.0).** The first leisure day is `leisure_from()`: the
`YYYY-MM-DD` line in `/etc/leisure-watch/leisure_from` (`LEISURE_FROM_PATH`,
root:root 0644; read afresh, once per `resolve`, `O_NOFOLLOW|O_NONBLOCK`,
regular, <= 64 bytes, strict), else the parked `LEISURE_FROM` (2027-01-01);
never raises. Consumers ask only `leisure_active(day)` (gaming day); none
reads the file. `leisure-watch go-live` writes tomorrow's date; `--undo` removes it.
**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 rung (a `Rung` per
earner name; all of a counted earner's capped units), so doing everything
lands exactly on the ceiling. Since 0.6.1 `TUTOR_FROM == LADDER_FROM`, so
every real ladder day uses `TUTOR_LADDER` (below); `LADDER`, the pre-tutor
split (floor 19:00), is never in force on a real day.
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.
**Unified ladder (0.12.0; `UNIFIED_FROM` = 2026-10-18 since 0.13.2, the day
after screen-locker's late-credit rule shipped):** each rung pays what
its earner pays in gaming (workout 120, LeetCode 60, reading 60, tutor 1/unit
to 60), so the floor drops 18:50 -> 18:00 under the same 23:00 ceiling:
nothing 3 h / 18:00, workout 5 h / 20:00, +LeetCode 21:00, +reading 22:00.
**Sick days (0.13.0, `_sick.py`).** `resolve(..., sick=True)` shuts down at
20:00 (`SICK_SHUTDOWN_FLOOR_MINUTES`) plus every term's *gaming* pay, capped
at the ceiling: LeetCode + reading is 22:00 (0.11.0's non-stacking max gave
20:10). Never below 0.11.0's `max(20:00, earned)`, so it landed same-day.
Base, terms, gaming, waivers and penalties are unchanged; earners still earn.
`sick` is the consumer's answer (`sickdays.is_sick_day`, never imported here).
A live consumer raises the config to the sick resolution after each credit.
**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 the tutor is `TUTOR` (name `tutor`), a counted earner on the
tutor's ledger (`~/.local/share/tutor/ledger.json`, `tutor_match` on
`detail.ended_at`). Until 0.9.0 it was `AUTOMATION_TUTOR` (name `automation`,
its own ledger, copied whole on the rename: old rows still pay).
Since 0.8.0 it pays per **active minute** (a unit is one minute), at most 60
a day summed across sessions.
**Weekday quota (0.10.0, `TUTOR_QUOTA_FROM` 2026-10-12, `_quota.py`):** all 60
units take 15 min Tue-Thu (`WORKDAYS`, a copy of freedays'), 30 Mon/Fri, 50
Sat/Sun; `credit_units` scales the day's deduped minute sum,
min(60, minutes * 60 // quota), so it returns units, no longer minutes:
| units | gaming | shutdown rung |
|---|---|---|
| each minute | +1 min | +1 min (`Rung(1, extra=1)`) |
| all 60 | +60 (Anki's 30 folded in) | +60 (Anki's 25 folded in) |
Each minute earns exactly the shutdown it costs (the fairness rule). Once its
penalty has started, the gaming base drops by the full 60
(`Earner.max_gaming_minutes`) and the ladder floor by the full 60, so nothing
done is 3 h / 18:50 and everything done is still 8 h / 23:00. Until then
(given `first_credits`, see below) it costs nothing: 4 h / 19:50.
**The tutor row contract (0.8.0, mixed-day safe).** `credit_units` sums what
each counting row pays instead of counting rows (`_credits.row_units`, the
per-matcher reader `_match.ROW_UNITS`):
- `detail.minutes` (a positive JSON int, never a bool/float/string) is the
active minutes the row pays. A row **without** it is a 0.7.0 15-minute
block and pays 15 (`LEGACY_TUTOR_MINUTES`), so a day that mixes block rows
and minute rows sums both, and an all-block day resolves exactly as 0.7.0
did (min(15 n, 60) = 15 min(n, 4)). A row whose `minutes` is present but
invalid is refused by `tutor_match` (logged) and pays nothing anywhere.
- An `entry_id` pays once; rows repeated under one id pay the **smallest**
of their counts, so a replay or a rewrite can never raise a credit.
- What the writer ([kuhyx/tutor](https://github.com/kuhyx/tutor) `tutor/credit.py`) must guarantee: every
new row carries `detail.minutes`; one row per credited minute,
`entry_id = f"{session_id}-m{n}"` with `n` the session's n-th credited
active minute (deterministic, so a retry rewrites the same id; never the
`-b{n}` form); ids never cover overlapping minutes (no cumulative "total so
far" rows); `detail.ended_at` is when the last paid minute ended (it picks
the day, so a multi-minute row must never span local midnight); its own
daily cap sums minutes (block rows as 15) up to 60. N-minute rows (e.g.
every 5 minutes) are allowed under the same rules; each write fires
screen-locker's `earner-bonus.path`, so per-minute rows rewrite the
shutdown schedule once a minute.
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.
**Gap waiver** (`ANKI_WAIVED_FROM`, 2026-10-09): no anki/automation earner.
## Gate maturity (0.6.0)
How far to trust a gate is computed from its ledger, never listed by name:
`maturity(earner, ledger, key_file, today) -> Maturity` (`_maturity.py`).
- **Real credit:** HMAC-verified `credit` row, accepted by the earner's
`match`, not manual (`detail.source` `manual`/`manual_grant`, `entry_id`
`manual:`/`manual_grant:`), and `amount > 0` when the row has one -- a
gate's own re-evaluation grant (`detail.grant_of`, book-guard `bonus:`
rows) may pay 0. A matcher cannot loosen this (`anki_match` alone accepts a
`manual_grant` row).
- **Levels:** `new` = no real credit, or no `Earner.confirmed_on` (set by
kuhy); `mature` = at least `MATURE_MIN_CREDIT_DAYS` (14) distinct credit
days and the first at least `MATURE_MIN_AGE_DAYS` (21) old; else `maturing`.
An unreadable ledger or key is `checked=False`, `new`, reason recorded.
- **Penalty start:** `penalty_start(earner, first_credit)` =
max(`penalty_from`, day after the first real credit, day after
`confirmed_on`). No first credit (never paid out, or could not check) is
not penalised -- unless `confirmed_on` is set: then fail closed,
max(`penalty_from`, day after `confirmed_on`), so deleting or locking a
ledger cannot lift a penalty. Pass `first_credits={name: Maturity.first_credit}`, computed
over `earners_for(day)` (until 0.9.0 two earners shared the name `automation`), to `resolve`
/ `base_for` to apply it; omitted, a penalty starts at `penalty_from`, as
in 0.5.0. It can only delay a penalty, never add one.
- **Ladder floor (0.6.1):** the same start gates the shutdown floor. A rung
comes off the floor only for a pure bonus (no `penalty_from`: workout,
LeetCode) or an earner whose penalty has started, so a gate that never paid
out cannot lower it either (10-10, tutor never credited: 19:50, not 19:00).
An unwired consumer (no `first_credits`) cuts the tutor from `penalty_from`:
19:00 / 3 h.
- **CLI:** `python -m earned_time maturity [--json] [--day YYYY-MM-DD]`
prints one row per ledger-backed earner of every registry. It and
`leisure_from()` alone resolve real paths (`~/<Earner.ledger>`,
`/etc/workout-locker/hmac.key`), and it only reads.
## API
- `resolve(answers, day=None, earners=None, *, first_credits=None, sick=False,
leisure=None) -> Resolution` — the one sum. `sick_shutdown_from(paid_minutes,
day) -> int`. `base_for(day=None, earners=None, *, first_credits=None,
leisure=None) -> Base` — the day's floors. `gaming_base_minutes_for(day)`,
`gaming_ceiling_minutes_for(day) -> int` — the gaming base and cap.
`leisure_from(path=LEISURE_FROM_PATH) -> date`, `leisure_active(day, path=...)`.
- `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 for `day`, on its matcher's stamp
(`_match.CREDIT_STAMPS`; else `created_at`); it can lie outside `day`.
- `credit_units(earner, ledger, key_file, day) -> int | None` — the units the
verified rows pay for `day`: 1 per row (the workout), or the row's own count
(`detail.minutes`: tutor, legacy blocks 15, from 0.10.0 scaled to its weekday
quota; focus). A repeated `entry_id` pays once; `resolve` applies `max_units`.
- `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 patch holds.
- `shutdown_minutes_for(earner, day)`, `extra_shutdown_minutes_for(earner, day)`,
`shutdown_ceiling_for(day)` (ints), `on_ladder(day) -> bool` — what a status
view lists as still left to earn. `Earner.shutdown_for(units, day=None)` is
the day-aware sum. Never read `earner.shutdown_minutes`: pre-ladder value.
- `day_window(day)`, `today_window(now=None)`, `entry_signature`, `verified`.
## The workout ledger
`WORKOUT` is `kind="counted"`, ledger `~/.local/share/workout_locker/ledger.json`
(screen-locker; one signed credit row per unit, `detail.completed_at`/`source`).
`workout_match` counts a `runnerup_tcx` row by `completed_at`, a `rest_day` row
on its `day` only if `detail.declared_at` predates that day (else logged, no).
## The focus project (0.19.0, cut 180 since 0.20.0, pays 240 since 0.21.0)
While `focus_project.active(day)` returns a project, `earners_for` drops the
gates it `replaces` (`book` = `reading`) and adds one counted `focus` earner
(`max_units` = the day's `daily_on`). Shutdown pays their rungs (180/120 min); gaming
their `max_gaming_minutes` + pure bonuses again (240/120 min = 2/min; full day = 8 h):
`floor(summed minutes * pay / daily_minutes)`, never per row. The penalty runs
from the start day, cutting all of it, 180 with LeetCode's formerly free hour (0.20.0); maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`:
signed `credit` rows, `entry_id` `<project>-<YYYYMMDD>-m<n>`, `detail.minutes` (positive int),
`detail.ended_at` (else `day`). `focus_project` unusable = no project (logged).
## 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
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
)
```
Then a rung in `TUTOR_LADDER` (`PIANO.name: Rung(20)`) and, for an event
stamp, an entry in `_match.CREDIT_STAMPS`.
A new rung comes out of the floor from its penalty start (a pure bonus:
the day its pin lands): ship it the day the gate can be satisfied.
3. Bump, tag `earned-time-vX.Y.Z`, re-pin every consumer (no code needed).
## Rules the consumers rely on
- **Minutes everywhere.** Shutdown is minutes after local midnight.
- **Paths are passed in.** Only the switch file is resolved here
(patch `earned_time._leisure.LEISURE_FROM_PATH`).
- **`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 0644 on purpose.** User-run gates sign rows with it;
it stops casual edits, not a determined forger with a shell.
- **Stdlib only.** The root gaming daemon imports this headless; `focus_project`
(stdlib too) is imported lazily and may be absent.
## Install
```console
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.20.0#subdirectory=earned_time"
```
The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy` and imports from
the same user site-packages. Test: `scripts/run_subproject_tests.sh earned_time`.