|
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 |
||
|---|---|---|
| .. | ||
| earned_time | ||
| scripts | ||
| .pre-commit-config.yaml | ||
| pyproject.toml | ||
| README.md | ||
| requirements.txt | ||
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'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, 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.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.
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
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)(two earners share 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 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 | Nonefirst_credit_at(earner, ledger, key_file, day) -> float | None— unix time of the earliest verified credit that counts forday. The time is the stamp the earner'smatchdecides on (_match.CREDIT_STAMPS: LeetCodesubmitted_at, readingended_at, workoutcompleted_at; Anki/Automation fall back tocreated_at). It can lie outsidedaywhen 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 forday: a counted earner's units (a repeatedentry_idcounts once;max_unitsis applied byresolve).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 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 readearner.shutdown_minutesdirectly: 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
-
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 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.
-
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.
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-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
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
scripts/run_subproject_tests.sh earned_time