A focus-project day cuts the whole 180 of gaming base (LeetCode's free hour too); a full day still earns it all back. Shutdown unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWxdzckyBH7fEbPywpr
15 KiB
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'sshutdown_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 (aRungper earner name; all of a counted earner's capped units), so doing everything lands exactly on the ceiling. Since 0.6.1TUTOR_FROM == LADDER_FROM, so every real ladder day usesTUTOR_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.extradefaults to 0). Move the alarm (WAKE_MINUTES = 6 * 60) and the whole ladder shifts an hour earlier on its own. Days beforeLADDER_FROMkeep 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 whoseminutesis present but invalid is refused bytutor_match(logged) and pays nothing anywhere.- An
entry_idpays 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
tutor/credit.py) must guarantee: every new row carriesdetail.minutes; one row per credited minute,entry_id = f"{session_id}-m{n}"withnthe 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_atis 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'searner-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
creditrow, accepted by the earner'smatch, not manual (detail.sourcemanual/manual_grant,entry_idmanual:/manual_grant:), andamount > 0when the row has one -- a gate's own re-evaluation grant (detail.grant_of, book-guardbonus:rows) may pay 0. A matcher cannot loosen this (anki_matchalone accepts amanual_grantrow). - Levels:
new= no real credit, or noEarner.confirmed_on(set by kuhy);mature= at leastMATURE_MIN_CREDIT_DAYS(14) distinct credit days and the first at leastMATURE_MIN_AGE_DAYS(21) old; elsematuring. An unreadable ledger or key ischecked=False,new, reason recorded. - Penalty start:
penalty_start(earner, first_credit)= max(penalty_from, day after the first real credit, day afterconfirmed_on). No first credit (never paid out, or could not check) is not penalised -- unlessconfirmed_onis set: then fail closed, max(penalty_from, day afterconfirmed_on), so deleting or locking a ledger cannot lift a penalty. Passfirst_credits={name: Maturity.first_credit}, computed overearners_for(day)(until 0.9.0 two earners shared the nameautomation), toresolve/base_forto apply it; omitted, a penalty starts atpenalty_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 (nofirst_credits) cuts the tutor frompenalty_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 andleisure_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 | Nonefirst_credit_at(earner, ledger, key_file, day) -> float | None— unix time of the earliest verified credit forday, on its matcher's stamp (_match.CREDIT_STAMPS; elsecreated_at); it can lie outsideday.credit_units(earner, ledger, key_file, day) -> int | None— the units the verified rows pay forday: 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 repeatedentry_idpays once;resolveappliesmax_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 readearned_time.EARNERSthrough 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 readearner.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)
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) paying what they paid (shutdown by their rungs,
gaming by max_gaming_minutes: 60+60+60 over 120 min = 1.5/min):
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
-
The gate writes
{"entries": [...]}to its ledger. Each row haskind: "credit", adetaildict, and anhmacsigned with/etc/workout-locker/hmac.key(earned_time.entry_signature, the same as gatelock'slog_integrity). -
Add a matcher to
earned_time/_match.pyand anEarnertoearned_time/_policy.pyandEARNERS: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.
-
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). Noneis "could not check", never "no". It earns nothing (fail closed), but consumers log it. A missing answer inresolve()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
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.