earned_time 0.7.0: tutor confirmed (penalty from 2026-10-10), 15 min shutdown per block
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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-10 14:59:27 +02:00
parent 6d96c58f01
commit e376660ab0
5 changed files with 66 additions and 39 deletions

View File

@ -37,9 +37,8 @@ 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``). 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.
# 2026-09-26). Confirmed in 0.7.0 (``confirmed_on`` 2026-10-09, kuhy's call
# on 2026-10-10), so its penalty bites from this day on.
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
@ -59,7 +58,7 @@ class Rung:
first: Shutdown minutes the first unit earns.
extra: Each further unit (counted earners) once ``steps`` run out.
steps: What the second, third, ... unit earn, in order, when they are
not all the same (the tutor's 13/13/12/12 split).
not all the same.
"""
first: int
@ -95,13 +94,15 @@ LADDER: Final[Mapping[str, Rung]] = MappingProxyType(
# From ``TUTOR_FROM``: Anki's 25 folded into Automation's, which is paid per
# 15-minute tutor block. Still sums to 240, so doing everything is 23:00.
# 15-minute tutor block. Each block earns the 15 minutes it costs (the fairness
# rule; 13/13/12/12 paid 50 for 60 sat), so the rungs sum to 250: doing
# everything is still 23:00, doing nothing 18:50.
TUTOR_LADDER: Final[Mapping[str, Rung]] = MappingProxyType(
{
"workout": Rung(110),
"leetcode": Rung(50),
"reading": Rung(30),
"automation": Rung(13, steps=(13, 12, 12)),
"automation": Rung(15, extra=15),
}
)

View File

@ -204,14 +204,18 @@ AUTOMATION_TUTOR: Final = Earner(
extra_gaming_minutes=15,
max_units=4,
# 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,
# ladder pays 15 per block (``TUTOR_LADDER``).
shutdown_minutes=15,
extra_shutdown_minutes=15,
kind="counted",
penalty_from=TUTOR_FROM,
ledger=".local/share/automation_tutor/ledger.json",
match=tutor_match,
missing_ledger_is_no=True,
# Confirmed by kuhy on 2026-10-10 before any block had paid out, dated the
# day before so the -60 gaming penalty applies on TUTOR_FROM itself
# (penalty starts at confirmed_on + 1).
confirmed_on=date(2026, 10, 9),
)
# The registry of every day before ``TUTOR_FROM``. Order is the order of

View File

@ -106,21 +106,29 @@ def test_omitting_first_credits_keeps_0_6_0_on_the_real_registries(
assert base_for(day, registry).shutdown_minutes == _head_floor(day, earners)
def test_a_never_paid_tutor_spares_the_first_tutor_day() -> None:
def _unconfirmed() -> tuple[Earner, ...]:
"""The tutor day's registry as it was before 0.7.0 confirmed the tutor."""
tutor = replace(AUTOMATION_TUTOR, confirmed_on=None)
return (*earners_for(TUTOR_FROM)[:-1], tutor)
def test_a_never_paid_unconfirmed_tutor_spares_the_first_tutor_day() -> None:
assert TUTOR_FROM == LADDER_FROM == date(2026, 10, 10)
base = base_for(TUTOR_FROM, first_credits=REAL)
registry = _unconfirmed()
base = base_for(TUTOR_FROM, registry, 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,
registry,
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)
fallback = base_for(TUTOR_FROM, registry)
assert (fallback.gaming_minutes, _hhmm(fallback.shutdown_minutes)) == (
180,
"19:00",
"18:50",
)
@ -128,17 +136,24 @@ 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"),
(TUTOR_FROM + DAY, 180, "18:50"),
):
base = base_for(day, first_credits=paid)
base = base_for(day, _unconfirmed(), 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_the_confirmed_tutor_with_no_credit_fails_closed_on_the_floor() -> None:
# 0.7.0: confirmed the eve, before any block paid out. The real ledgers
# of 2026-10-10 give the 5 h base (180 + LeetCode's 60 + reading's 60).
assert AUTOMATION_TUTOR.confirmed_on == TUTOR_FROM - DAY
base = base_for(TUTOR_FROM, first_credits=REAL)
assert (base.gaming_minutes, _hhmm(base.shutdown_minutes)) == (180, "18:50")
day = resolve(
{"workout": 0, "leetcode": 1, "reading": 1, "automation": 0},
TUTOR_FROM,
first_credits=REAL,
)
assert day.gaming_minutes == 300
def test_a_pure_bonus_is_always_on_the_ladder() -> None:

View File

@ -43,7 +43,8 @@ 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
# Confirmed the eve, so the penalty bites on TUTOR_FROM itself.
assert AUTOMATION_TUTOR.confirmed_on == EVE
def test_registry_switches_on_tutor_from() -> None:
@ -64,28 +65,34 @@ def test_all_earners_lists_each_once() -> None:
assert AUTOMATION_TUTOR in all_earners()
def test_tutor_ladder_still_sums_to_the_old_one() -> None:
old = sum(r.minutes(4) for r in LADDER.values())
new = sum(r.minutes(4) for r in TUTOR_LADDER.values())
assert old == new == 240
assert TUTOR_LADDER["automation"].minutes(4) == 50
def test_each_tutor_block_earns_the_15_minutes_it_costs() -> None:
# The fairness rule: 13/13/12/12 paid 50 for 60 sat. The extra 10 come
# off the floor, so the ladder spans 250 instead of 240.
assert sum(r.minutes(4) for r in LADDER.values()) == 240
assert sum(r.minutes(4) for r in TUTOR_LADDER.values()) == 250
assert [TUTOR_LADDER["automation"].minutes(n) for n in range(5)] == [
0,
15,
30,
45,
60,
]
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")
def test_nothing_done_is_three_hours_and_18_50() -> None:
# Confirmed, so the tutor is cut from penalty_from with or without
# first_credits (test_maturity_floor.py).
assert _day(0) == (180, "18:50")
assert base_for(TUTOR_FROM).gaming_minutes == 180
def test_everything_but_the_tutor() -> None:
assert _day(0, **OTHERS) == (420, "22:10")
assert _day(0, **OTHERS) == (420, "22:00")
@pytest.mark.parametrize(
("blocks", "gaming", "shutdown"),
[(1, 435, "22:23"), (2, 450, "22:36"), (3, 465, "22:48"), (4, 480, "23:00")],
[(1, 435, "22:15"), (2, 450, "22:30"), (3, 465, "22:45"), (4, 480, "23:00")],
)
def test_each_block_pays_15_gaming_and_its_rung(
blocks: int, gaming: int, shutdown: str
@ -94,15 +101,15 @@ def test_each_block_pays_15_gaming_and_its_rung(
def test_partial_credit_without_the_others() -> None:
assert _day(1) == (195, "19:13")
assert _day(2) == (210, "19:26")
assert _day(1) == (195, "19:05")
assert _day(2) == (210, "19:20")
assert _day(4) == (240, "19:50")
def test_a_fifth_block_is_clamped_to_four() -> None:
assert _day(5, **OTHERS) == _day(4, **OTHERS) == (480, "23:00")
term = resolve({"automation": 5}, TUTOR_FROM).term("automation")
assert (term.answer, term.gaming_minutes, term.shutdown_minutes) == (5, 60, 50)
assert (term.answer, term.gaming_minutes, term.shutdown_minutes) == (5, 60, 60)
def test_anki_is_neither_penalised_nor_paid_from_tutor_from() -> None:
@ -132,7 +139,7 @@ def test_rung_steps_and_extras() -> None:
assert rung.second == 13
assert Rung(10, extra=5).minutes(3) == 20
assert Rung(10, extra=5).second == 5
assert extra_shutdown_minutes_for(AUTOMATION_TUTOR, TUTOR_FROM) == 13
assert extra_shutdown_minutes_for(AUTOMATION_TUTOR, TUTOR_FROM) == 15
def test_gaming_for_and_max() -> None:
@ -150,5 +157,5 @@ def test_gaming_for_and_max() -> None:
def test_tutor_shutdown_before_the_ladder_uses_its_own_fields() -> None:
old = date(2026, 10, 1)
assert AUTOMATION_TUTOR.shutdown_for(2, old) == 26
assert AUTOMATION_TUTOR.shutdown_for(2, old) == 30
assert AUTOMATION_TUTOR.shutdown_for(0, old) == 0

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "earned-time"
version = "0.6.1"
version = "0.7.0"
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
requires-python = ">=3.14"
dependencies = []