mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
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
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:
parent
6d96c58f01
commit
e376660ab0
@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = []
|
||||
|
||||
Loading…
Reference in New Issue
Block a user