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 ## The model
**Gaming** (unchanged): a base of 5 h, minus every earner's `gaming_minutes` **Gaming**: a base (`gaming_base_minutes_for(day)`) minus every earner's
from its `penalty_from` date; each earner done adds its `gaming_minutes` back. `gaming_minutes` from its `penalty_from` date; each earner done adds its
Capped at 8 h. `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 — **Shutdown** has two regimes, switched by date (`LADDER_FROM`, 2026-10-10 —
never a same-day cut): 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 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 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 every real ladder day uses `TUTOR_LADDER` (below); `LADDER`, the pre-tutor
split (workout 110, LeetCode 50, reading 30, Anki 25, Automation 25; floor split (floor 19:00), is never in force on a real day.
19:00), is never in force on a real day.
A second workout earns nothing more (`Rung.extra` defaults to 0). 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 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 `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 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 tutor's ledger (`~/.local/share/tutor/ledger.json`, `tutor_match` on
`detail.ended_at`). Until 0.9.0 it was `AUTOMATION_TUTOR`, name `automation`, `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, its own ledger, copied whole on the rename: old rows still pay).
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
Since 0.8.0 it pays per **active a day summed across sessions.
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 **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 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, 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 iterate `earners_for(day)`, never `EARNERS`; `resolve`/`base_for` default to
it, and ignore answers for an earner of another day's registry. 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 **The gap waiver (`ANKI_WAIVED_FROM`, 2026-10-09 only).** Until `TUTOR_FROM`
credit, and the tutor is not confirmed yet, so from `ANKI_WAIVED_FROM` until (2026-10-10) the registry is `EARNERS` without `anki` and `automation`: neither
`TUTOR_FROM` the registry is `EARNERS` without `anki` and `automation`: penalised nor paid, so the base rises by what they could pay back (nothing
neither penalised nor paid. The base rises by exactly what the two could have done 4 h / 19:00; everything 8 h / 23:00). The tutor was confirmed in 0.7.0
paid back (nothing done: 4 h / 19:00, pre-ladder; everything done: still (`confirmed_on` 2026-10-09), so its penalty bites from `TUTOR_FROM`.
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`.
## Gate maturity (0.6.0) ## 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 ## API
- `resolve(answers, day=None, earners=None, *, first_credits=None, sick=False)` - `resolve(answers, day=None, earners=None, *, first_credits=None, sick=False,
`-> Resolution` — the one sum. `sick_shutdown_from(paid_minutes, day) -> int`. leisure=False) -> Resolution` — the one sum. `sick_shutdown_from(paid_minutes,
- `base_for(day=None, earners=EARNERS) -> Base` — the day's floors. 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` - `done_today(earner, ledger, key_file, *, now=None) -> bool | None`
- `first_credit_at(earner, ledger, key_file, day) -> float | None` — unix time - `first_credit_at(earner, ledger, key_file, day) -> float | None` — unix time
of the earliest verified credit for `day`, on its matcher's stamp 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 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 watch). They read `earned_time.EARNERS` through the package, so a consumer
test that patches it still registers its stand-in. test that patches it still registers its stand-in.
- `shutdown_minutes_for(earner, day) -> int`, `extra_shutdown_minutes_for(earner, - `shutdown_minutes_for(earner, day)`, `extra_shutdown_minutes_for(earner, day)`,
day) -> int`, `shutdown_ceiling_for(day) -> int`, `on_ladder(day) -> bool` — what a status `shutdown_ceiling_for(day)` (ints), `on_ladder(day) -> bool` — what a status
view needs to list what is still left to earn. `Earner.shutdown_for(units, view lists as still left to earn. `Earner.shutdown_for(units, day=None)` is
day=None)` is the day-aware sum (default today). Never read the day-aware sum. Never read `earner.shutdown_minutes`: pre-ladder value.
`earner.shutdown_minutes` directly: it is the pre-ladder value.
- `day_window(day)`, `today_window(now=None)`, `entry_signature`, `verified`. - `day_window(day)`, `today_window(now=None)`, `entry_signature`, `verified`.
## The workout ledger ## 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 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`. 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 On the ladder a new earner's rung comes out of the floor from its penalty
penalty start (a pure bonus: the day its pin lands; the ceiling is fixed): start (a pure bonus: the day its pin lands; the ceiling is fixed): re-split
re-split the rungs and ship it the day the the rungs and ship it the day the gate can actually be satisfied.
gate can actually be satisfied.
3. Bump the version, tag `earned-time-vX.Y.Z`, and bump the pin in both 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. 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), - **`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 but consumers log it. A missing answer in `resolve()` is logged as well; an
unknown earner name raises. unknown earner name raises.
- **The HMAC key is root-owned and 0644 on purpose.** The gates run as the - **The HMAC key is root-owned and 0644 on purpose.** User-run gates must sign
user and must sign their rows, so the user can read the key; root owns it rows, so the user reads it; root owns it so no user process can replace it.
so a user process cannot replace or rotate it. The signature therefore It stops casual edits and corruption, not a determined forger with a shell.
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. - **Stdlib only.** The gaming daemon imports this as root, headless.
## Install ## Install
```console ```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 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) apply(day.gaming_minutes)
``sick`` (0.11.0) only raises shutdown to at least 20:00; gaming is unchanged. ``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 How far to trust a gate is computed, not listed: :func:`maturity` (and
``python -m earned_time maturity``). ``python -m earned_time maturity``).
@ -57,6 +59,13 @@ from earned_time._ladder import (
shutdown_minutes_for, shutdown_minutes_for,
) )
from earned_time._ledger import done_today, entry_signature, today_window, verified 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 ( from earned_time._maturity import (
MATURE_MIN_AGE_DAYS, MATURE_MIN_AGE_DAYS,
MATURE_MIN_CREDIT_DAYS, MATURE_MIN_CREDIT_DAYS,
@ -96,6 +105,9 @@ __all__ = [
"LADDER", "LADDER",
"LADDER_FROM", "LADDER_FROM",
"LEETCODE", "LEETCODE",
"LEISURE_BASE_MINUTES",
"LEISURE_CEILING_MINUTES",
"LEISURE_FROM",
"MATURE_MIN_AGE_DAYS", "MATURE_MIN_AGE_DAYS",
"MATURE_MIN_CREDIT_DAYS", "MATURE_MIN_CREDIT_DAYS",
"READING", "READING",
@ -130,6 +142,8 @@ __all__ = [
"entry_signature", "entry_signature",
"extra_shutdown_minutes_for", "extra_shutdown_minutes_for",
"first_credit_at", "first_credit_at",
"gaming_base_minutes_for",
"gaming_ceiling_minutes_for",
"ladder_for", "ladder_for",
"maturity", "maturity",
"on_ladder", "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 typing import TYPE_CHECKING, Final
from earned_time._ladder import on_ladder, shutdown_ceiling_for from earned_time._ladder import on_ladder, shutdown_ceiling_for
from earned_time._maturity import confirmed_start, penalty_start from earned_time._leisure import (
from earned_time._policy import ( gaming_base_minutes_for,
GAMING_BASE_MINUTES, gaming_ceiling_minutes_for,
GAMING_CEILING_MINUTES, leisure_day,
SHUTDOWN_BASE_MINUTES,
Earner,
) )
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._registry import all_earners, earners_for
from earned_time._resolution import Base, Resolution, Term from earned_time._resolution import Base, Resolution, Term
from earned_time._sick import sick_shutdown_from from earned_time._sick import sick_shutdown_from
@ -92,6 +92,7 @@ def base_for(
earners: tuple[Earner, ...] | None = None, earners: tuple[Earner, ...] | None = None,
*, *,
first_credits: Mapping[str, date | None] | None = None, first_credits: Mapping[str, date | None] | None = None,
leisure: bool = False,
) -> Base: ) -> Base:
"""The floor for ``day`` (default today) under ``earners`` (default its own). """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. derived separately so the ladder can never move a gaming minute.
``first_credits`` (per earner name) delays each penalty, and the ladder ``first_credits`` (per earner name) delays each penalty, and the ladder
rung, to :func:`penalty_start`; omitted, 0.5.0's ``penalty_from`` applies. 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() target = day or _today()
registry = earners_for(target) if earners is None else earners registry = earners_for(target) if earners is None else earners
penalised = [e for e in registry if _penalised(e, target, first_credits)] penalised = [e for e in registry if _penalised(e, target, first_credits)]
return Base( 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), - sum(e.max_gaming_minutes for e in penalised),
shutdown_minutes=_shutdown_floor(target, registry, penalised), shutdown_minutes=_shutdown_floor(target, registry, penalised),
) )
@ -128,6 +131,22 @@ def _check_registered(
raise KeyError(msg) 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: def _sick_or(earned: int, terms: list[Term], day: date) -> int:
"""A sick day's shutdown: 20:00 plus gaming pay, never below ``earned``.""" """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)) 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, first_credits: Mapping[str, date | None] | None = None,
sick: bool = False, sick: bool = False,
leisure: bool = False,
) -> Resolution: ) -> Resolution:
"""Sum the base and every earned term, each capped at its ceiling. """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 is 20:00 plus the terms' gaming pay
(:func:`~earned_time._sick.sick_shutdown_from`, 0.13.0). (:func:`~earned_time._sick.sick_shutdown_from`, 0.13.0).
Gaming and every term are unchanged. 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: Returns:
The resolution, with a term for every registered earner. The resolution, with a term for every registered earner.
@ -170,23 +193,8 @@ def resolve(
target = day or _today() target = day or _today()
registry = earners_for(target) if earners is None else earners registry = earners_for(target) if earners is None else earners
_check_registered(answers, registry) _check_registered(answers, registry)
base = base_for(target, registry, first_credits=first_credits) base = base_for(target, registry, first_credits=first_credits, leisure=leisure)
terms: list[Term] = [] terms = [_term(item, answers, target) for item in registry]
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),
)
)
gaming = base.gaming_minutes + sum(t.gaming_minutes for t in terms) gaming = base.gaming_minutes + sum(t.gaming_minutes for t in terms)
earned = min( earned = min(
shutdown_ceiling_for(target), shutdown_ceiling_for(target),
@ -197,7 +205,9 @@ def resolve(
day=target, day=target,
base=base, base=base,
terms=tuple(terms), 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, shutdown_minutes=shutdown,
sick=sick, sick=sick,
sick_lift_minutes=shutdown - earned, sick_lift_minutes=shutdown - earned,

View File

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import UTC, date, datetime
import pytest import pytest
@ -127,9 +127,10 @@ def test_earner_without_ladder_values_keeps_its_own() -> None:
assert extra_shutdown_minutes_for(plain, FIRST) == 5 assert extra_shutdown_minutes_for(plain, FIRST) == 5
def test_shutdown_for_defaults_to_today(monkeypatch: pytest.MonkeyPatch) -> None: def test_shutdown_for_defaults_to_today() -> None:
monkeypatch.setattr(ladder, "LADDER_FROM", date(2000, 1, 1)) # Pinned to today, not a constant: a literal broke when UNIFIED_FROM passed.
assert WORKOUT.shutdown_for(2) == 110 today = datetime.now(tz=UTC).astimezone().date()
assert WORKOUT.shutdown_for(2) == WORKOUT.shutdown_for(2, today)
def test_the_ladder() -> None: 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] [project]
name = "earned-time" 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" description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [] dependencies = []
@ -33,6 +33,10 @@ unfixable = []
"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"] "**/tests/**/*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
"**/test_*.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] [tool.ruff.lint.pydocstyle]
convention = "google" convention = "google"
@ -108,6 +112,9 @@ min-public-methods = 0
max-module-lines = 1000 max-module-lines = 1000
# Earner is a frozen registry record: its fields are the policy (cf. music_theory). # Earner is a frozen registry record: its fields are the policy (cf. music_theory).
max-attributes = 13 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] [tool.pylint.typecheck]
generated-members = [ generated-members = [