utils/earned_time/README.md
Krzysztof kuhy Rudnicki df7330f015
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
earned_time 0.4.0: sleep ladder for shutdown from 2026-10-10, workout ledger, first_credit_at/credit_units
From LADDER_FROM (2026-10-10) shutdown is read down from sleep: ceiling =
WAKE_MINUTES (07:00) - 8h = 23:00, floor = ceiling - every earner's rung
(workout 110, LeetCode 50, reading 30, Anki 25, Automation 25) = 19:00.
A second workout earns nothing more. Earlier days resolve exactly as in
0.3.0 (frozen 23:00 ceiling); gaming is unchanged for every input.

WORKOUT gets a signed ledger (~/.local/share/workout_locker/ledger.json,
written by screen-locker) and workout_match; a rest_day row counts only if
declared before its day began. New API: first_credit_at, credit_units,
day_window, shutdown_minutes_for, extra_shutdown_minutes_for,
shutdown_ceiling_for, on_ladder, LADDER/Rung, LADDER_FROM, WAKE_MINUTES.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QrjUTo1yhgAsUUasdw4bpG
2026-10-09 10:39:19 +02:00

6.1 KiB
Raw Blame History

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.

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.
  • 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:

    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

pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.4.0#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