earned_time 0.14.0: LEISURE_FROM leisure budget (7 h base, 10 h cap) + resolve(leisure=True)

Contract C1 of the leisure rollout (leisure-watch TODO-leisure-rollout.md).
New _leisure.py: LEISURE_FROM = 2027-01-01 (parked; Phase 2 sets it to the
day after the shadow week), gaming_base_minutes_for(day) (7 h from it, else
5 h) and gaming_ceiling_minutes_for(day) (10 h / 8 h). base_for and resolve
use them; leisure=True prices any day with the leisure base and cap (the
shadow budget). Pay, penalties, shutdown and the sick rule untouched; every
day before LEISURE_FROM resolves byte-identically (full day still 480).

resolve() now has six parameters (the contract fixes the signature), so
ruff/pylint max-args is 6; the term loop moved into _term() for
too-many-locals. test_ladder's shutdown-defaults-to-today test compared to
a literal that broke when UNIFIED_FROM passed; it now compares to today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
This commit is contained in:
Krzysztof Rudnicki 2026-10-19 11:34:50 +02:00
parent 26c55675dd
commit 2d97dc4b0e
7 changed files with 217 additions and 67 deletions

View File

@ -14,9 +14,18 @@ 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.
**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 `LEISURE_FROM` the budget
covers all leisure (games, browsing, video, messaging): base 5 h -> 7 h, cap
8 h -> 10 h; pay, penalties and shutdown untouched (fully done day: 180 + 300
= 480 before, 300 + 300 = 600 from it). `LEISURE_FROM` is parked at 2027-01-01
until Phase 2 sets it to the day after kuhy confirms the shadow week, so every
real day still resolves byte-identically. `resolve(..., leisure=True)` (and
`base_for(..., leisure=True)`) prices any day with the leisure base and cap:
the shadow budget the enforcer shows during that week.
**Shutdown** has two regimes, switched by date (`LADDER_FROM`, 2026-10-10 —
never a same-day cut):
@ -29,8 +38,7 @@ never a same-day cut):
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 (workout 110, LeetCode 50, reading 30, Anki 25, Automation 25; floor
19:00), is never in force on a real day.
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
@ -59,11 +67,10 @@ 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`,
on `~/.local/share/automation_tutor/ledger.json`; rows carry no earner name,
so the rename only moved the ledger (copied whole, 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.
`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,
@ -110,15 +117,11 @@ 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 was confirmed in 0.7.0 (`confirmed_on`
2026-10-09), so its penalty bites from `TUTOR_FROM`.
**The gap waiver (`ANKI_WAIVED_FROM`, 2026-10-09 only).** Until `TUTOR_FROM`
(2026-10-10) the registry is `EARNERS` without `anki` and `automation`: neither
penalised nor paid, so the base rises by what they could pay back (nothing
done 4 h / 19:00; everything 8 h / 23:00). The tutor was confirmed in 0.7.0
(`confirmed_on` 2026-10-09), so its penalty bites from `TUTOR_FROM`.
## Gate maturity (0.6.0)
@ -157,9 +160,11 @@ How far to trust a gate is computed from its ledger, never listed by name:
## API
- `resolve(answers, day=None, earners=None, *, first_credits=None, sick=False)`
`-> Resolution` — the one sum. `sick_shutdown_from(paid_minutes, day) -> int`.
- `base_for(day=None, earners=EARNERS) -> Base` — the day's floors.
- `resolve(answers, day=None, earners=None, *, first_credits=None, sick=False,
leisure=False) -> Resolution` — the one sum. `sick_shutdown_from(paid_minutes,
day) -> int`. `base_for(day=None, earners=None, *, first_credits=None,
leisure=False) -> Base` — the day's floors. `gaming_base_minutes_for(day)`,
`gaming_ceiling_minutes_for(day) -> int` — the gaming base and cap.
- `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
@ -174,11 +179,10 @@ How far to trust a gate is computed from its ledger, never listed by name:
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.
- `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
@ -217,10 +221,9 @@ on the day itself is logged and does not count.
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.
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.
@ -232,17 +235,15 @@ on the day itself is logged and does not count.
- **`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.
- **The HMAC key is root-owned and 0644 on purpose.** User-run gates must sign
rows, so the user reads it; root owns it so no user process can replace it.
It stops casual edits and corruption, not a determined forger with a shell.
- **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.13.1#subdirectory=earned_time"
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.14.0#subdirectory=earned_time"
```
The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports

View File

@ -23,6 +23,8 @@ A consumer's whole integration::
apply(day.gaming_minutes)
``sick`` (0.11.0) only raises shutdown to at least 20:00; gaming is unchanged.
``leisure=True`` (0.14.0) prices gaming with the leisure base and cap
(:data:`LEISURE_FROM`) whatever the day: the shadow budget.
How far to trust a gate is computed, not listed: :func:`maturity` (and
``python -m earned_time maturity``).
@ -57,6 +59,13 @@ from earned_time._ladder import (
shutdown_minutes_for,
)
from earned_time._ledger import done_today, entry_signature, today_window, verified
from earned_time._leisure import (
LEISURE_BASE_MINUTES,
LEISURE_CEILING_MINUTES,
LEISURE_FROM,
gaming_base_minutes_for,
gaming_ceiling_minutes_for,
)
from earned_time._maturity import (
MATURE_MIN_AGE_DAYS,
MATURE_MIN_CREDIT_DAYS,
@ -96,6 +105,9 @@ __all__ = [
"LADDER",
"LADDER_FROM",
"LEETCODE",
"LEISURE_BASE_MINUTES",
"LEISURE_CEILING_MINUTES",
"LEISURE_FROM",
"MATURE_MIN_AGE_DAYS",
"MATURE_MIN_CREDIT_DAYS",
"READING",
@ -130,6 +142,8 @@ __all__ = [
"entry_signature",
"extra_shutdown_minutes_for",
"first_credit_at",
"gaming_base_minutes_for",
"gaming_ceiling_minutes_for",
"ladder_for",
"maturity",
"on_ladder",

View File

@ -0,0 +1,47 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The leisure budget: from ``LEISURE_FROM`` gaming time covers all leisure.
The daily "gaming" budget becomes a budget for every non-productive activity
on the PC (games, browsing, video, messaging), so a fully earned day grows
from 8 h to 10 h: the base rises 5 h -> 7 h and the cap 8 h -> 10 h. Earner
pay, penalties and the shutdown ladder are untouched, so a day still earns
exactly what it did; only the floor and the cap move, by the same 2 h.
Days before ``LEISURE_FROM`` keep ``GAMING_BASE_MINUTES`` and
``GAMING_CEILING_MINUTES``, byte-identical. ``resolve(..., leisure=True)``
prices any day as if ``LEISURE_FROM`` had already passed: the shadow budget
the enforcer reports during the shadow week, while it still enforces the old.
"""
from __future__ import annotations
from datetime import date
from typing import Final
from earned_time._policy import GAMING_BASE_MINUTES, GAMING_CEILING_MINUTES
# Parked far ahead: Phase 2 of the leisure rollout sets it to the day after
# kuhy confirms the shadow week, never a same-day cut (2026-09-26).
LEISURE_FROM: Final = date(2027, 1, 1)
# From ``LEISURE_FROM``: 7 h base, 10 h cap (+2 h each).
LEISURE_BASE_MINUTES: Final = 7 * 60
LEISURE_CEILING_MINUTES: Final = 10 * 60
def leisure_day(day: date, *, leisure: bool = False) -> date:
"""The day the gaming base and cap are priced on.
``day`` itself, unless ``leisure``: then no earlier than ``LEISURE_FROM``,
so the shadow budget of an old day uses the leisure base and cap.
"""
return max(day, LEISURE_FROM) if leisure else day
def gaming_base_minutes_for(day: date) -> int:
"""The gaming base before penalties: 7 h from ``LEISURE_FROM``, else 5 h."""
return LEISURE_BASE_MINUTES if day >= LEISURE_FROM else GAMING_BASE_MINUTES
def gaming_ceiling_minutes_for(day: date) -> int:
"""The gaming cap: 10 h from ``LEISURE_FROM``, else 8 h."""
return LEISURE_CEILING_MINUTES if day >= LEISURE_FROM else GAMING_CEILING_MINUTES

View File

@ -26,13 +26,13 @@ import logging
from typing import TYPE_CHECKING, Final
from earned_time._ladder import on_ladder, shutdown_ceiling_for
from earned_time._maturity import confirmed_start, penalty_start
from earned_time._policy import (
GAMING_BASE_MINUTES,
GAMING_CEILING_MINUTES,
SHUTDOWN_BASE_MINUTES,
Earner,
from earned_time._leisure import (
gaming_base_minutes_for,
gaming_ceiling_minutes_for,
leisure_day,
)
from earned_time._maturity import confirmed_start, penalty_start
from earned_time._policy import SHUTDOWN_BASE_MINUTES, Earner
from earned_time._registry import all_earners, earners_for
from earned_time._resolution import Base, Resolution, Term
from earned_time._sick import sick_shutdown_from
@ -92,6 +92,7 @@ def base_for(
earners: tuple[Earner, ...] | None = None,
*,
first_credits: Mapping[str, date | None] | None = None,
leisure: bool = False,
) -> Base:
"""The floor for ``day`` (default today) under ``earners`` (default its own).
@ -100,12 +101,14 @@ def base_for(
derived separately so the ladder can never move a gaming minute.
``first_credits`` (per earner name) delays each penalty, and the ladder
rung, to :func:`penalty_start`; omitted, 0.5.0's ``penalty_from`` applies.
The gaming base is :func:`gaming_base_minutes_for` the day, or with
``leisure`` the leisure base whatever the date (:func:`leisure_day`).
"""
target = day or _today()
registry = earners_for(target) if earners is None else earners
penalised = [e for e in registry if _penalised(e, target, first_credits)]
return Base(
gaming_minutes=GAMING_BASE_MINUTES
gaming_minutes=gaming_base_minutes_for(leisure_day(target, leisure=leisure))
- sum(e.max_gaming_minutes for e in penalised),
shutdown_minutes=_shutdown_floor(target, registry, penalised),
)
@ -128,6 +131,22 @@ def _check_registered(
raise KeyError(msg)
def _term(item: Earner, answers: Mapping[str, Answer], day: date) -> Term:
"""``item``'s term on ``day``; no answer at all is logged and earns nothing."""
if item.name not in answers:
_logger.warning(
"No answer for the %s earner; it earns nothing today", item.label
)
units = _units(answers.get(item.name))
count = units or 0
return Term(
earner=item,
answer=units,
gaming_minutes=item.gaming_for(count),
shutdown_minutes=item.shutdown_for(count, day),
)
def _sick_or(earned: int, terms: list[Term], day: date) -> int:
"""A sick day's shutdown: 20:00 plus gaming pay, never below ``earned``."""
return max(earned, sick_shutdown_from(sum(t.gaming_minutes for t in terms), day))
@ -140,6 +159,7 @@ def resolve(
*,
first_credits: Mapping[str, date | None] | None = None,
sick: bool = False,
leisure: bool = False,
) -> Resolution:
"""Sum the base and every earned term, each capped at its ceiling.
@ -159,6 +179,9 @@ def resolve(
is 20:00 plus the terms' gaming pay
(:func:`~earned_time._sick.sick_shutdown_from`, 0.13.0).
Gaming and every term are unchanged.
leisure: Price gaming as if ``LEISURE_FROM`` had passed (0.14.0): the
leisure base and cap whatever the day -- the shadow budget.
Terms, penalties and shutdown are unchanged.
Returns:
The resolution, with a term for every registered earner.
@ -170,23 +193,8 @@ def resolve(
target = day or _today()
registry = earners_for(target) if earners is None else earners
_check_registered(answers, registry)
base = base_for(target, registry, first_credits=first_credits)
terms: list[Term] = []
for item in registry:
if item.name not in answers:
_logger.warning(
"No answer for the %s earner; it earns nothing today", item.label
)
units = _units(answers.get(item.name))
count = units or 0
terms.append(
Term(
earner=item,
answer=units,
gaming_minutes=item.gaming_for(count),
shutdown_minutes=item.shutdown_for(count, target),
)
)
base = base_for(target, registry, first_credits=first_credits, leisure=leisure)
terms = [_term(item, answers, target) for item in registry]
gaming = base.gaming_minutes + sum(t.gaming_minutes for t in terms)
earned = min(
shutdown_ceiling_for(target),
@ -197,7 +205,9 @@ def resolve(
day=target,
base=base,
terms=tuple(terms),
gaming_minutes=min(GAMING_CEILING_MINUTES, gaming),
gaming_minutes=min(
gaming_ceiling_minutes_for(leisure_day(target, leisure=leisure)), gaming
),
shutdown_minutes=shutdown,
sick=sick,
sick_lift_minutes=shutdown - earned,

View File

@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import date
from datetime import UTC, date, datetime
import pytest
@ -127,9 +127,10 @@ def test_earner_without_ladder_values_keeps_its_own() -> None:
assert extra_shutdown_minutes_for(plain, FIRST) == 5
def test_shutdown_for_defaults_to_today(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(ladder, "LADDER_FROM", date(2000, 1, 1))
assert WORKOUT.shutdown_for(2) == 110
def test_shutdown_for_defaults_to_today() -> None:
# Pinned to today, not a constant: a literal broke when UNIFIED_FROM passed.
today = datetime.now(tz=UTC).astimezone().date()
assert WORKOUT.shutdown_for(2) == WORKOUT.shutdown_for(2, today)
def test_the_ladder() -> None:

View File

@ -0,0 +1,70 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The leisure budget (0.14.0): base 7 h, cap 10 h from ``LEISURE_FROM``."""
from __future__ import annotations
from datetime import date, timedelta
from earned_time import (
GAMING_BASE_MINUTES,
GAMING_CEILING_MINUTES,
LEISURE_FROM,
TUTOR_EARNERS,
UNIFIED_FROM,
base_for,
gaming_base_minutes_for,
gaming_ceiling_minutes_for,
resolve,
)
EVE = LEISURE_FROM - timedelta(days=1)
FULL = {"workout": 1, "leetcode": 1, "reading": 1, "tutor": 60}
NONE = dict.fromkeys(FULL, 0)
def _gaming(day: date, answers: dict[str, int], *, leisure: bool = False) -> int:
return resolve(answers, day, TUTOR_EARNERS, leisure=leisure).gaming_minutes
def test_parked_until_phase_two() -> None:
assert date(2027, 1, 1) == LEISURE_FROM
def test_base_and_ceiling_switch_on_leisure_from() -> None:
assert gaming_base_minutes_for(EVE) == GAMING_BASE_MINUTES == 5 * 60
assert gaming_base_minutes_for(LEISURE_FROM) == 7 * 60
assert gaming_ceiling_minutes_for(EVE) == GAMING_CEILING_MINUTES == 8 * 60
assert gaming_ceiling_minutes_for(LEISURE_FROM) == 10 * 60
def test_enforced_days_before_leisure_from_are_unchanged() -> None:
for day in (UNIFIED_FROM, EVE):
assert base_for(day).gaming_minutes == 180
assert _gaming(day, NONE) == 180
assert _gaming(day, FULL) == 180 + 300 == 480
assert _gaming(day, FULL, leisure=False) == 480
def test_leisure_prices_an_old_day_as_the_shadow_budget() -> None:
assert base_for(UNIFIED_FROM, leisure=True).gaming_minutes == 300
assert _gaming(UNIFIED_FROM, NONE, leisure=True) == 300
assert _gaming(UNIFIED_FROM, FULL, leisure=True) == 300 + 300 == 600
def test_from_leisure_from_the_flag_changes_nothing() -> None:
assert _gaming(LEISURE_FROM, NONE) == 300
assert _gaming(LEISURE_FROM, FULL) == 600
assert _gaming(LEISURE_FROM, FULL, leisure=True) == 600
def test_leisure_never_moves_shutdown_or_the_sick_rule() -> None:
for sick in (False, True):
for answers in (NONE, FULL, {**NONE, "leetcode": 1, "reading": 1}):
plain = resolve(answers, UNIFIED_FROM, TUTOR_EARNERS, sick=sick)
shadow = resolve(
answers, UNIFIED_FROM, TUTOR_EARNERS, sick=sick, leisure=True
)
assert shadow.shutdown_minutes == plain.shutdown_minutes
assert shadow.sick_lift_minutes == plain.sick_lift_minutes
assert shadow.terms == plain.terms
assert shadow.base.shutdown_minutes == plain.base.shutdown_minutes

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "earned-time"
version = "0.13.2"
version = "0.14.0"
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
requires-python = ">=3.14"
dependencies = []
@ -33,6 +33,10 @@ unfixable = []
"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
"**/test_*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
[tool.ruff.lint.pylint]
# Same as [tool.pylint.design] max-args: resolve()'s keyword-only switches.
max-args = 6
[tool.ruff.lint.pydocstyle]
convention = "google"
@ -108,6 +112,9 @@ min-public-methods = 0
max-module-lines = 1000
# Earner is a frozen registry record: its fields are the policy (cf. music_theory).
max-attributes = 13
# resolve()'s keyword-only switches (first_credits, sick, leisure) are the
# public API every consumer calls; bundling them would break each caller.
max-args = 6
[tool.pylint.typecheck]
generated-members = [