mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-13 23:50:58 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (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
TUTOR_FROM = 2026-10-10 (the day after the deploy). The automation tutor keeps confirmed_on=None, so it stays "new". Ladder floor: a rung comes off the floor only for a pure bonus (no penalty_from) or an earner whose penalty has started -- the same _penalised() rule (first_credits -> penalty_start, confirmed_on fail-closed fallback, penalty_from without first_credits) the gaming base uses. Before this, a never-credited tutor would have lowered 2026-10-10's floor by its full 50-minute rung (19:50 -> 19:00) even with first_credits. Maturity can only raise time: new property test against the 0.6.0 floor formula. ruff 0.17.0 (dependency-freshness): CPY001 ignored -- personal repo, no copyright policy. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WdfmsGMht9ypyvxGPmNF1V
215 lines
10 KiB
Markdown
215 lines
10 KiB
Markdown
# 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 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, is never in force on a real day:
|
||
|
||
| 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) |
|
||
|
||
Once its penalty has started, the gaming base drops by the full 60
|
||
(`Earner.max_gaming_minutes`) and the ladder floor by the full 50, so nothing
|
||
done is 3 h / 19:00 and everything done is still 8 h / 23:00. Until then
|
||
(given `first_credits`, see below) it costs nothing: 4 h / 19:50. 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:00, pre-ladder; everything done: still
|
||
8 h / 23:00), so it can only ever raise time. `TUTOR_FROM` is 2026-10-10
|
||
(0.6.1), the day after the deploy, never the deploy day; the waiver therefore
|
||
covers 2026-10-09 only. The tutor has no `confirmed_on` yet, so it stays `new`.
|
||
|
||
## 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)` (two earners share 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 is the only
|
||
code that resolves real paths (`~/<Earner.ledger>`,
|
||
`/etc/workout-locker/hmac.key`), and it only reads.
|
||
|
||
## 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 `TUTOR_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 from its
|
||
penalty start (a pure bonus: 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.6.1#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
|
||
```
|