earned_time 0.6.1: tutor cutover 2026-10-10; new-gate rungs spare the shutdown floor
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
This commit is contained in:
Krzysztof Rudnicki 2026-10-09 22:11:35 +02:00
parent 6f4f739ed7
commit fd6c772eaf
12 changed files with 237 additions and 54 deletions

View File

@ -67,7 +67,7 @@ repos:
types: [python]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.7
rev: v0.17.0
hooks:
- id: ruff
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes]

View File

@ -25,9 +25,11 @@ never a same-day cut):
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:
that ceiling minus the sum of every registered earner's rung (a `Rung` per
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, is never in force on a real day:
| done | shutdown |
|---|---|
@ -59,9 +61,10 @@ one signed row per verified 15-minute block, `tutor_match` on
| each | +15 min | 13 / 13 / 12 / 12 |
| all 4 | +60 (Anki's 30 folded in) | +50 (Anki's 25 folded in) |
The gaming base drops by the full 60 (`Earner.max_gaming_minutes`) and the
ladder floor by the full 50, so nothing done is still 3 h / 19:00 and
everything done is still 8 h / 23:00. Days before `TUTOR_FROM` resolve on
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.
@ -70,10 +73,10 @@ earner of another day's registry.
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:50 on a ladder day; everything done: still
8 h / 23:00), so it can only ever raise time. `TUTOR_FROM` stays a far-future
sentinel (2099-01-01) until kuhy confirms a real tutor session credits a
block; it is then set to the day after, never the deploy day.
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)
@ -97,8 +100,14 @@ How far to trust a gate is computed from its ledger, never listed by name:
max(`penalty_from`, day after `confirmed_on`), so deleting or locking a
ledger cannot lift a penalty. Pass `first_credits={name: Maturity.first_credit}`, computed
over `earners_for(day)` (two earners share the name `automation`), to `resolve`
/ `base_for` to apply it; omitted, every day resolves exactly as in 0.5.0.
It can only delay a penalty, never add one.
/ `base_for` to apply it; omitted, a penalty starts at `penalty_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 (no `first_credits`) cuts the tutor from `penalty_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>`,
@ -164,11 +173,12 @@ on the day itself is logged and does not count.
)
```
Then give it a rung in `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`.
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
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.
@ -191,7 +201,7 @@ on the day itself is logged and does not count.
## Install
```console
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.0#subdirectory=earned_time"
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

View File

@ -35,10 +35,12 @@ LADDER_FROM: Final = date(2026, 10, 10)
# sleep before it, so moving the alarm moves the whole ladder with it.
WAKE_MINUTES: Final = 7 * 60
# From this day the Anki earner is retired and Automation is paid per tutor
# block (``earned_time._policy.AUTOMATION_TUTOR``). A far-future sentinel
# until kuhy confirms a real tutor session earns a block; then the day after
# that confirmation -- never the deploy day itself (a same-day cut, 2026-09-26).
TUTOR_FROM: Final = date(2099, 1, 1)
# block (``earned_time._policy.AUTOMATION_TUTOR``). The day after the deploy
# (0.6.1, 2026-10-09) -- never the deploy day itself (a same-day cut,
# 2026-09-26). The tutor is still unconfirmed (no ``confirmed_on``), so given
# ``first_credits`` it costs nothing -- neither gaming base nor ladder floor
# -- until the day after it first pays out.
TUTOR_FROM: Final = date(2026, 10, 10)
# From this day until TUTOR_FROM the Anki and old Automation earners are
# waived: neither penalised nor paid, since both are being retired and the
# tutor that replaces them is not yet confirmed. Raise-only on every day (the

View File

@ -203,8 +203,8 @@ AUTOMATION_TUTOR: Final = Earner(
gaming_minutes=15,
extra_gaming_minutes=15,
max_units=4,
# Pre-ladder values, unused (TUTOR_FROM is after LADDER_FROM); the ladder
# pays 13/13/12/12 (``TUTOR_LADDER``).
# Pre-ladder values, unused (TUTOR_FROM is not before LADDER_FROM); the
# ladder pays 13/13/12/12 (``TUTOR_LADDER``).
shutdown_minutes=13,
extra_shutdown_minutes=13,
kind="counted",

View File

@ -14,8 +14,9 @@ all -- earns nothing: fail closed. The difference shows up only in
**Maturity can only delay a penalty.** Given ``first_credits`` (each earner's
first real credit, from :func:`earned_time.maturity`), a penalty starts no
earlier than the day after the gate first paid out
(:func:`~earned_time._maturity.penalty_start`). Without it every day resolves
exactly as in 0.5.0.
(:func:`~earned_time._maturity.penalty_start`). Without it a penalty starts at
``penalty_from``, as in 0.5.0. The same start spares the ladder floor: a gate
whose penalty has not started does not lower it (0.6.1).
"""
from __future__ import annotations
@ -129,23 +130,21 @@ def _penalised(
return start is not None and day >= start
def _shutdown_floor(
day: date,
earners: tuple[Earner, ...],
first_credits: Mapping[str, date | None] | None = None,
) -> int:
"""Shutdown of a day that earned nothing.
def _shutdown_floor(day: date, earners: tuple[Earner, ...], cut: list[Earner]) -> int:
"""Shutdown of a day that earned nothing; ``cut``: the penalties in force.
On the ladder: the ceiling minus the most every registered earner can
pay (all of a capped earner's units), so doing everything lands exactly
on the ceiling. Before it: the old base minus the penalties in force.
On the ladder: the ceiling minus the most each earner can pay (all of a
capped earner's units), so doing everything lands exactly on the ceiling
-- but only a pure bonus (no ``penalty_from``) or an earner in ``cut``: a
gate whose penalty has not started cannot lower the floor (0.6.1). Before
it: the old base minus the penalties in force.
"""
if on_ladder(day):
rungs = (e for e in earners if e.penalty_from is None or e in cut)
return shutdown_ceiling_for(day) - sum(
e.shutdown_for(e.max_units or 1, day) for e in earners
e.shutdown_for(e.max_units or 1, day) for e in rungs
)
penalised = [e for e in earners if _penalised(e, day, first_credits)]
return SHUTDOWN_BASE_MINUTES - sum(e.shutdown_minutes for e in penalised)
return SHUTDOWN_BASE_MINUTES - sum(e.shutdown_minutes for e in cut)
def base_for(
@ -159,8 +158,8 @@ def base_for(
Gaming: every penalty in force, the most each earner can pay, taken off
the gaming base. Shutdown: see :func:`_shutdown_floor` -- the two are
derived separately so the ladder can never move a gaming minute.
``first_credits`` (per earner name) delays each penalty to
:func:`penalty_start`; omitted, 0.5.0's ``penalty_from`` applies.
``first_credits`` (per earner name) delays each penalty, and the ladder
rung, to :func:`penalty_start`; omitted, 0.5.0's ``penalty_from`` applies.
"""
target = day or _today()
registry = earners_for(target) if earners is None else earners
@ -168,7 +167,7 @@ def base_for(
return Base(
gaming_minutes=GAMING_BASE_MINUTES
- sum(e.max_gaming_minutes for e in penalised),
shutdown_minutes=_shutdown_floor(target, registry, first_credits),
shutdown_minutes=_shutdown_floor(target, registry, penalised),
)

View File

@ -34,6 +34,17 @@ FIRST = date(2026, 10, 10)
NAMES = [e.name for e in EARNERS]
@pytest.fixture(autouse=True)
def _pre_tutor_ladder(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep the tutor cutover out of the way of ``LADDER``'s own arithmetic.
Since 0.6.1 ``TUTOR_FROM == LADDER_FROM``, so no real day resolves on
``LADDER``; these tests pin the ladder mechanism on it regardless (the
tutor's rungs: ``test_tutor.py``).
"""
monkeypatch.setattr(ladder, "TUTOR_FROM", date(2099, 1, 1))
def _shutdown(day: date, **done: int) -> str:
answers = {name: done.get(name, 0) for name in NAMES}
minutes = resolve(answers, day, EARNERS).shutdown_minutes

View File

@ -0,0 +1,148 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""A gate whose penalty has not started spares the ladder floor (0.6.1).
Maturity may only raise time: the floor is never below 0.6.0's (the ceiling
minus every registered earner's rung), never above the ceiling, and passing
``first_credits`` never lowers anything.
"""
from __future__ import annotations
from dataclasses import replace
from datetime import date, timedelta
import pytest
from earned_time import (
AUTOMATION_TUTOR,
EARNERS,
LADDER_FROM,
TUTOR_FROM,
WORKOUT,
Earner,
base_for,
earners_for,
resolve,
shutdown_ceiling_for,
)
DAY = timedelta(days=1)
LADDER_DAYS = (LADDER_FROM, LADDER_FROM + DAY, LADDER_FROM + 30 * DAY)
FIRSTS = ("missing", None, -5, 0, 3)
CONFIRMED = (None, -5, 3)
# What the real ledgers said on 2026-10-09: the tutor never paid out.
REAL = {
"workout": date(2026, 10, 1),
"leetcode": date(2026, 8, 12),
"reading": date(2026, 10, 2),
"automation": None,
}
def _hhmm(minutes: int) -> str:
return f"{minutes // 60:02d}:{minutes % 60:02d}"
def _head_floor(day: date, registry: tuple[Earner, ...]) -> int:
"""0.6.0's ladder floor: every registered rung, maturity ignored."""
return shutdown_ceiling_for(day) - sum(
e.shutdown_for(e.max_units or 1, day) for e in registry
)
def _anchor(item: Earner) -> date:
return item.penalty_from or date(2026, 9, 1)
def _registry(day: date, confirmed: int | None) -> tuple[Earner, ...]:
return tuple(
replace(
e,
confirmed_on=None if confirmed is None else _anchor(e) + confirmed * DAY,
)
for e in earners_for(day)
)
def _firsts(
registry: tuple[Earner, ...], first: str | int | None
) -> dict[str, date | None]:
if first == "missing":
return {}
if first is None:
return dict.fromkeys((e.name for e in registry), None)
assert isinstance(first, int)
return {e.name: _anchor(e) + first * DAY for e in registry}
@pytest.mark.parametrize("day", LADDER_DAYS)
@pytest.mark.parametrize("first", FIRSTS)
@pytest.mark.parametrize("confirmed", CONFIRMED)
@pytest.mark.parametrize("blocks", range(5))
@pytest.mark.parametrize("done", [0, 1])
def test_the_floor_only_ever_rises(
day: date, first: str | int | None, confirmed: int | None, blocks: int, done: int
) -> None:
registry = _registry(day, confirmed)
answers = {e.name: done for e in registry} | {"automation": blocks}
firsts = _firsts(registry, first)
ceiling = shutdown_ceiling_for(day)
head = _head_floor(day, registry)
old = resolve(answers, day, registry)
new = resolve(answers, day, registry, first_credits=firsts)
earned = sum(t.shutdown_minutes for t in new.terms)
assert head <= old.base.shutdown_minutes <= new.base.shutdown_minutes <= ceiling
assert new.shutdown_minutes >= old.shutdown_minutes >= min(ceiling, head + earned)
assert new.gaming_minutes >= old.gaming_minutes
assert new.base.gaming_minutes >= old.base.gaming_minutes
@pytest.mark.parametrize("day", LADDER_DAYS)
@pytest.mark.parametrize("registry", [EARNERS, None])
def test_omitting_first_credits_keeps_0_6_0_on_the_real_registries(
day: date, registry: tuple[Earner, ...] | None
) -> None:
earners = earners_for(day) if registry is None else registry
assert base_for(day, registry).shutdown_minutes == _head_floor(day, earners)
def test_a_never_paid_tutor_spares_the_first_tutor_day() -> None:
assert TUTOR_FROM == LADDER_FROM == date(2026, 10, 10)
base = base_for(TUTOR_FROM, first_credits=REAL)
assert (base.gaming_minutes, _hhmm(base.shutdown_minutes)) == (240, "19:50")
full = resolve(
{"workout": 1, "leetcode": 1, "reading": 1, "automation": 4},
TUTOR_FROM,
first_credits=REAL,
)
assert (full.gaming_minutes, _hhmm(full.shutdown_minutes)) == (480, "23:00")
# Unwired consumer (no first_credits): the tutor is cut from penalty_from.
fallback = base_for(TUTOR_FROM)
assert (fallback.gaming_minutes, _hhmm(fallback.shutdown_minutes)) == (
180,
"19:00",
)
def test_the_floor_waits_for_the_day_after_the_first_block() -> None:
paid = REAL | {"automation": TUTOR_FROM}
for day, gaming, floor in (
(TUTOR_FROM, 240, "19:50"),
(TUTOR_FROM + DAY, 180, "19:00"),
):
base = base_for(day, first_credits=paid)
assert (base.gaming_minutes, _hhmm(base.shutdown_minutes)) == (gaming, floor)
def test_a_confirmed_tutor_with_no_credit_fails_closed_on_the_floor() -> None:
confirmed = replace(AUTOMATION_TUTOR, confirmed_on=TUTOR_FROM - DAY)
registry = (*earners_for(TUTOR_FROM)[:-1], confirmed)
base = base_for(TUTOR_FROM, registry, first_credits=REAL)
assert (base.gaming_minutes, _hhmm(base.shutdown_minutes)) == (180, "19:00")
def test_a_pure_bonus_is_always_on_the_ladder() -> None:
assert WORKOUT.penalty_from is None
never = base_for(TUTOR_FROM, (WORKOUT,), first_credits={"workout": None})
assert never.shutdown_minutes == 23 * 60 - 110
assert never.gaming_minutes == 300 # and never cut from gaming

View File

@ -12,6 +12,7 @@ from earned_time import (
AUTOMATION_TUTOR,
EARNERS,
LADDER,
LADDER_FROM,
TUTOR_EARNERS,
TUTOR_FROM,
TUTOR_LADDER,
@ -38,9 +39,11 @@ def _day(blocks: int, **others: int) -> tuple[int, str]:
return day.gaming_minutes, _hhmm(day.shutdown_minutes)
def test_tutor_from_is_after_the_ladder_and_not_today() -> None:
assert date(2026, 10, 10) < TUTOR_FROM
def test_tutor_from_is_the_day_after_the_deploy() -> None:
assert date(2026, 10, 10) == TUTOR_FROM
assert date(2026, 10, 9) < TUTOR_FROM # never the day it was prepared
assert LADDER_FROM <= TUTOR_FROM
assert AUTOMATION_TUTOR.confirmed_on is None # no real session confirmed yet
def test_registry_switches_on_tutor_from() -> None:
@ -48,8 +51,9 @@ def test_registry_switches_on_tutor_from() -> None:
assert earners_for(None) is earners_for(datetime.now().astimezone().date())
assert earners_for(TUTOR_FROM) is TUTOR_EARNERS
assert "anki" not in {e.name for e in TUTOR_EARNERS}
assert ladder_for(EVE) is LADDER
assert ladder_for(TUTOR_FROM) is TUTOR_LADDER
assert ladder_for(EVE) is None # the eve is pre-ladder
# TUTOR_FROM == LADDER_FROM: the first ladder day already has the tutor's.
assert ladder_for(LADDER_FROM) is TUTOR_LADDER
assert ladder_for(date(2026, 10, 1)) is None
@ -68,6 +72,9 @@ def test_tutor_ladder_still_sums_to_the_old_one() -> None:
def test_nothing_done_is_three_hours_and_19() -> None:
# Without first_credits (an unwired consumer): the tutor is cut from
# penalty_from; given them, a never-paid tutor costs nothing
# (test_maturity_floor.py).
assert _day(0) == (180, "19:00")
assert base_for(TUTOR_FROM).gaming_minutes == 180
@ -105,9 +112,10 @@ def test_anki_is_neither_penalised_nor_paid_from_tutor_from() -> None:
def test_the_eve_resolves_as_before() -> None:
# The eve is pre-ladder: 20:00 minus the cuts in force, plus the terms.
eve = resolve({**OTHERS, "anki": 0, "automation": 0}, EVE, EARNERS)
assert eve.base.gaming_minutes == 180
assert (eve.gaming_minutes, _hhmm(eve.shutdown_minutes)) == (420, "22:10")
assert (eve.gaming_minutes, _hhmm(eve.shutdown_minutes)) == (420, "22:00")
done = resolve({**OTHERS, "anki": 1, "automation": 1}, EVE, EARNERS)
assert (done.gaming_minutes, _hhmm(done.shutdown_minutes)) == (480, "23:00")
assert done.term("automation").earner is AUTOMATION

View File

@ -30,12 +30,15 @@ def test_the_waiver_sits_between_the_old_registry_and_the_tutor() -> None:
def test_nothing_done_is_raised_by_exactly_what_the_two_paid() -> None:
zero = dict.fromkeys(NAMES, 0)
# The first ladder day inside the waiver (the waiver began pre-ladder).
assert ANKI_WAIVED_FROM <= LADDER_FROM < TUTOR_FROM
gap = resolve(zero, LADDER_FROM)
assert gap.base.gaming_minutes == 240
assert gap.gaming_minutes == 240
assert gap.shutdown_minutes == 23 * 60 - 190 # 19:50: workout+leetcode+reading
# TUTOR_FROM == LADDER_FROM: the waiver's only day is pre-ladder; the first
# ladder day's floor is in test_maturity_floor.py.
assert ANKI_WAIVED_FROM < LADDER_FROM == TUTOR_FROM
gap = resolve(zero, ANKI_WAIVED_FROM)
unwaived = resolve(zero, ANKI_WAIVED_FROM, EARNERS)
assert (gap.base.gaming_minutes, gap.gaming_minutes) == (240, 240)
assert gap.shutdown_minutes == 19 * 60 # 20:00 minus reading's 60
assert gap.gaming_minutes - unwaived.gaming_minutes == 30 + 30
assert gap.shutdown_minutes - unwaived.shutdown_minutes == 30 + 30
@pytest.mark.parametrize("workout", [0, 1])

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "earned-time"
version = "0.6.0"
version = "0.6.1"
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
requires-python = ">=3.14"
dependencies = []
@ -25,6 +25,8 @@ ignore = [
"COM812",
"ISC001",
"S603",
# personal repo, no copyright policy (ruff 0.17 made CPY001 stable under select=ALL)
"CPY001",
]
fixable = ["ALL"]
unfixable = []

View File

@ -7,4 +7,4 @@ pre-commit==4.6.2
pylint==4.1.2
pytest==9.1.1
pytest-cov==7.1.0
ruff==0.16.10
ruff==0.17.0

View File

@ -85,7 +85,7 @@ def _line(label: str, answers: dict[str, int], day: date) -> str:
def main() -> None:
"""Print the table."""
eve = TUTOR_FROM - timedelta(days=1)
_say(f"TUTOR_FROM = {TUTOR_FROM} (placeholder; set at go-live)")
_say(f"TUTOR_FROM = {TUTOR_FROM} (no first_credits: an unwired consumer)")
_say(f"{'case':<44} {'gaming':>15} shutdown")
for done in (0, 1):
answers = {**_OTHERS, "anki": done, "automation": done}