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] types: [python]
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.7 rev: v0.17.0
hooks: hooks:
- id: ruff - id: ruff
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes] 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. 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 - **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 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 that ceiling minus the sum of every registered earner's rung (a `Rung` per
(`LADDER`, a `Rung` per earner name), so doing everything lands exactly on earner name; all of a counted earner's capped units), so doing everything
the ceiling: 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 | | done | shutdown |
|---|---| |---|---|
@ -59,9 +61,10 @@ one signed row per verified 15-minute block, `tutor_match` on
| each | +15 min | 13 / 13 / 12 / 12 | | each | +15 min | 13 / 13 / 12 / 12 |
| all 4 | +60 (Anki's 30 folded in) | +50 (Anki's 25 folded in) | | 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 Once its penalty has started, the gaming base drops by the full 60
ladder floor by the full 50, so nothing done is still 3 h / 19:00 and (`Earner.max_gaming_minutes`) and the ladder floor by the full 50, so nothing
everything done is still 8 h / 23:00. Days before `TUTOR_FROM` resolve on 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` exactly as before. Consumers must iterate `earners_for(day)`, never
`EARNERS`; `resolve`/`base_for` default to it, and ignore answers for an `EARNERS`; `resolve`/`base_for` default to it, and ignore answers for an
earner of another day's registry. 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 credit, and the tutor is not confirmed yet, so from `ANKI_WAIVED_FROM` until
`TUTOR_FROM` the registry is `EARNERS` without `anki` and `automation`: `TUTOR_FROM` the registry is `EARNERS` without `anki` and `automation`:
neither penalised nor paid. The base rises by exactly what the two could have 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 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` stays a far-future 8 h / 23:00), so it can only ever raise time. `TUTOR_FROM` is 2026-10-10
sentinel (2099-01-01) until kuhy confirms a real tutor session credits a (0.6.1), the day after the deploy, never the deploy day; the waiver therefore
block; it is then set to the day after, never the deploy day. covers 2026-10-09 only. The tutor has no `confirmed_on` yet, so it stays `new`.
## Gate maturity (0.6.0) ## 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 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 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` 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. / `base_for` to apply it; omitted, a penalty starts at `penalty_from`, as
It can only delay a penalty, never add one. 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]` - **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 prints one row per ledger-backed earner of every registry. It is the only
code that resolves real paths (`~/<Earner.ledger>`, 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`. 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 On the ladder a new earner's rung comes out of the floor from its
lands (the ceiling is fixed): re-split the rungs and ship it the day the 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. 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.
@ -191,7 +201,7 @@ on the day itself is logged and does not count.
## Install ## Install
```console ```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 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. # sleep before it, so moving the alarm moves the whole ladder with it.
WAKE_MINUTES: Final = 7 * 60 WAKE_MINUTES: Final = 7 * 60
# From this day the Anki earner is retired and Automation is paid per tutor # From this day the Anki earner is retired and Automation is paid per tutor
# block (``earned_time._policy.AUTOMATION_TUTOR``). A far-future sentinel # block (``earned_time._policy.AUTOMATION_TUTOR``). The day after the deploy
# until kuhy confirms a real tutor session earns a block; then the day after # (0.6.1, 2026-10-09) -- never the deploy day itself (a same-day cut,
# that confirmation -- never the deploy day itself (a same-day cut, 2026-09-26). # 2026-09-26). The tutor is still unconfirmed (no ``confirmed_on``), so given
TUTOR_FROM: Final = date(2099, 1, 1) # ``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 # 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 # 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 # 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, gaming_minutes=15,
extra_gaming_minutes=15, extra_gaming_minutes=15,
max_units=4, max_units=4,
# Pre-ladder values, unused (TUTOR_FROM is after LADDER_FROM); the ladder # Pre-ladder values, unused (TUTOR_FROM is not before LADDER_FROM); the
# pays 13/13/12/12 (``TUTOR_LADDER``). # ladder pays 13/13/12/12 (``TUTOR_LADDER``).
shutdown_minutes=13, shutdown_minutes=13,
extra_shutdown_minutes=13, extra_shutdown_minutes=13,
kind="counted", 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 **Maturity can only delay a penalty.** Given ``first_credits`` (each earner's
first real credit, from :func:`earned_time.maturity`), a penalty starts no first real credit, from :func:`earned_time.maturity`), a penalty starts no
earlier than the day after the gate first paid out earlier than the day after the gate first paid out
(:func:`~earned_time._maturity.penalty_start`). Without it every day resolves (:func:`~earned_time._maturity.penalty_start`). Without it a penalty starts at
exactly as in 0.5.0. ``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 from __future__ import annotations
@ -129,23 +130,21 @@ def _penalised(
return start is not None and day >= start return start is not None and day >= start
def _shutdown_floor( def _shutdown_floor(day: date, earners: tuple[Earner, ...], cut: list[Earner]) -> int:
day: date, """Shutdown of a day that earned nothing; ``cut``: the penalties in force.
earners: tuple[Earner, ...],
first_credits: Mapping[str, date | None] | None = None,
) -> int:
"""Shutdown of a day that earned nothing.
On the ladder: the ceiling minus the most every registered earner can On the ladder: the ceiling minus the most each earner can pay (all of a
pay (all of a capped earner's units), so doing everything lands exactly capped earner's units), so doing everything lands exactly on the ceiling
on the ceiling. Before it: the old base minus the penalties in force. -- 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): 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( 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 cut)
return SHUTDOWN_BASE_MINUTES - sum(e.shutdown_minutes for e in penalised)
def base_for( def base_for(
@ -159,8 +158,8 @@ def base_for(
Gaming: every penalty in force, the most each earner can pay, taken off Gaming: every penalty in force, the most each earner can pay, taken off
the gaming base. Shutdown: see :func:`_shutdown_floor` -- the two are the gaming base. Shutdown: see :func:`_shutdown_floor` -- the two are
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 to ``first_credits`` (per earner name) delays each penalty, and the ladder
:func:`penalty_start`; omitted, 0.5.0's ``penalty_from`` applies. rung, to :func:`penalty_start`; omitted, 0.5.0's ``penalty_from`` applies.
""" """
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
@ -168,7 +167,7 @@ def base_for(
return Base( return Base(
gaming_minutes=GAMING_BASE_MINUTES gaming_minutes=GAMING_BASE_MINUTES
- sum(e.max_gaming_minutes for e in penalised), - 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] 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: def _shutdown(day: date, **done: int) -> str:
answers = {name: done.get(name, 0) for name in NAMES} answers = {name: done.get(name, 0) for name in NAMES}
minutes = resolve(answers, day, EARNERS).shutdown_minutes 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, AUTOMATION_TUTOR,
EARNERS, EARNERS,
LADDER, LADDER,
LADDER_FROM,
TUTOR_EARNERS, TUTOR_EARNERS,
TUTOR_FROM, TUTOR_FROM,
TUTOR_LADDER, TUTOR_LADDER,
@ -38,9 +39,11 @@ def _day(blocks: int, **others: int) -> tuple[int, str]:
return day.gaming_minutes, _hhmm(day.shutdown_minutes) return day.gaming_minutes, _hhmm(day.shutdown_minutes)
def test_tutor_from_is_after_the_ladder_and_not_today() -> None: def test_tutor_from_is_the_day_after_the_deploy() -> None:
assert date(2026, 10, 10) < TUTOR_FROM assert date(2026, 10, 10) == TUTOR_FROM
assert date(2026, 10, 9) < TUTOR_FROM # never the day it was prepared 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: 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(None) is earners_for(datetime.now().astimezone().date())
assert earners_for(TUTOR_FROM) is TUTOR_EARNERS assert earners_for(TUTOR_FROM) is TUTOR_EARNERS
assert "anki" not in {e.name for e in TUTOR_EARNERS} assert "anki" not in {e.name for e in TUTOR_EARNERS}
assert ladder_for(EVE) is LADDER assert ladder_for(EVE) is None # the eve is pre-ladder
assert ladder_for(TUTOR_FROM) is TUTOR_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 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: 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 _day(0) == (180, "19:00")
assert base_for(TUTOR_FROM).gaming_minutes == 180 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: 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) eve = resolve({**OTHERS, "anki": 0, "automation": 0}, EVE, EARNERS)
assert eve.base.gaming_minutes == 180 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) done = resolve({**OTHERS, "anki": 1, "automation": 1}, EVE, EARNERS)
assert (done.gaming_minutes, _hhmm(done.shutdown_minutes)) == (480, "23:00") assert (done.gaming_minutes, _hhmm(done.shutdown_minutes)) == (480, "23:00")
assert done.term("automation").earner is AUTOMATION 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: def test_nothing_done_is_raised_by_exactly_what_the_two_paid() -> None:
zero = dict.fromkeys(NAMES, 0) zero = dict.fromkeys(NAMES, 0)
# The first ladder day inside the waiver (the waiver began pre-ladder). # TUTOR_FROM == LADDER_FROM: the waiver's only day is pre-ladder; the first
assert ANKI_WAIVED_FROM <= LADDER_FROM < TUTOR_FROM # ladder day's floor is in test_maturity_floor.py.
gap = resolve(zero, LADDER_FROM) assert ANKI_WAIVED_FROM < LADDER_FROM == TUTOR_FROM
assert gap.base.gaming_minutes == 240 gap = resolve(zero, ANKI_WAIVED_FROM)
assert gap.gaming_minutes == 240 unwaived = resolve(zero, ANKI_WAIVED_FROM, EARNERS)
assert gap.shutdown_minutes == 23 * 60 - 190 # 19:50: workout+leetcode+reading 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]) @pytest.mark.parametrize("workout", [0, 1])

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "earned-time" 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" 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 = []
@ -25,6 +25,8 @@ ignore = [
"COM812", "COM812",
"ISC001", "ISC001",
"S603", "S603",
# personal repo, no copyright policy (ruff 0.17 made CPY001 stable under select=ALL)
"CPY001",
] ]
fixable = ["ALL"] fixable = ["ALL"]
unfixable = [] unfixable = []

View File

@ -7,4 +7,4 @@ pre-commit==4.6.2
pylint==4.1.2 pylint==4.1.2
pytest==9.1.1 pytest==9.1.1
pytest-cov==7.1.0 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: def main() -> None:
"""Print the table.""" """Print the table."""
eve = TUTOR_FROM - timedelta(days=1) 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") _say(f"{'case':<44} {'gaming':>15} shutdown")
for done in (0, 1): for done in (0, 1):
answers = {**_OTHERS, "anki": done, "automation": done} answers = {**_OTHERS, "anki": done, "automation": done}