diff --git a/earned_time/README.md b/earned_time/README.md index abc4623..f74e317 100644 --- a/earned_time/README.md +++ b/earned_time/README.md @@ -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 diff --git a/earned_time/earned_time/__init__.py b/earned_time/earned_time/__init__.py index 489a797..23f168c 100644 --- a/earned_time/earned_time/__init__.py +++ b/earned_time/earned_time/__init__.py @@ -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", diff --git a/earned_time/earned_time/_leisure.py b/earned_time/earned_time/_leisure.py new file mode 100644 index 0000000..4b799bd --- /dev/null +++ b/earned_time/earned_time/_leisure.py @@ -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 diff --git a/earned_time/earned_time/_resolve.py b/earned_time/earned_time/_resolve.py index 691f165..4a23351 100644 --- a/earned_time/earned_time/_resolve.py +++ b/earned_time/earned_time/_resolve.py @@ -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, diff --git a/earned_time/earned_time/tests/test_ladder.py b/earned_time/earned_time/tests/test_ladder.py index fd90cce..2cd71a2 100644 --- a/earned_time/earned_time/tests/test_ladder.py +++ b/earned_time/earned_time/tests/test_ladder.py @@ -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: diff --git a/earned_time/earned_time/tests/test_leisure.py b/earned_time/earned_time/tests/test_leisure.py new file mode 100644 index 0000000..ab9bb3d --- /dev/null +++ b/earned_time/earned_time/tests/test_leisure.py @@ -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 diff --git a/earned_time/pyproject.toml b/earned_time/pyproject.toml index 2b4558d..a1d918a 100644 --- a/earned_time/pyproject.toml +++ b/earned_time/pyproject.toml @@ -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 = [