diff --git a/earned_time/earned_time/_ladder.py b/earned_time/earned_time/_ladder.py index 22208f1..8352a20 100644 --- a/earned_time/earned_time/_ladder.py +++ b/earned_time/earned_time/_ladder.py @@ -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), } ) diff --git a/earned_time/earned_time/_policy.py b/earned_time/earned_time/_policy.py index 0fe2c4b..28a0a89 100644 --- a/earned_time/earned_time/_policy.py +++ b/earned_time/earned_time/_policy.py @@ -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 diff --git a/earned_time/earned_time/tests/test_maturity_floor.py b/earned_time/earned_time/tests/test_maturity_floor.py index 70b5c46..502ffeb 100644 --- a/earned_time/earned_time/tests/test_maturity_floor.py +++ b/earned_time/earned_time/tests/test_maturity_floor.py @@ -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: diff --git a/earned_time/earned_time/tests/test_tutor.py b/earned_time/earned_time/tests/test_tutor.py index bd80edc..e234598 100644 --- a/earned_time/earned_time/tests/test_tutor.py +++ b/earned_time/earned_time/tests/test_tutor.py @@ -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 diff --git a/earned_time/pyproject.toml b/earned_time/pyproject.toml index 52ac392..539a5e5 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.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 = []