diff --git a/earned_time/.pre-commit-config.yaml b/earned_time/.pre-commit-config.yaml index 7937ed4..1b38e77 100644 --- a/earned_time/.pre-commit-config.yaml +++ b/earned_time/.pre-commit-config.yaml @@ -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] diff --git a/earned_time/README.md b/earned_time/README.md index 4e18a38..db775d3 100644 --- a/earned_time/README.md +++ b/earned_time/README.md @@ -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 (`~/`, @@ -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 diff --git a/earned_time/earned_time/_ladder.py b/earned_time/earned_time/_ladder.py index 694d6c6..22208f1 100644 --- a/earned_time/earned_time/_ladder.py +++ b/earned_time/earned_time/_ladder.py @@ -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 diff --git a/earned_time/earned_time/_policy.py b/earned_time/earned_time/_policy.py index 7fc207e..0fe2c4b 100644 --- a/earned_time/earned_time/_policy.py +++ b/earned_time/earned_time/_policy.py @@ -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", diff --git a/earned_time/earned_time/_resolve.py b/earned_time/earned_time/_resolve.py index 7818aab..a04c3e2 100644 --- a/earned_time/earned_time/_resolve.py +++ b/earned_time/earned_time/_resolve.py @@ -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), ) diff --git a/earned_time/earned_time/tests/test_ladder.py b/earned_time/earned_time/tests/test_ladder.py index e89966f..fd90cce 100644 --- a/earned_time/earned_time/tests/test_ladder.py +++ b/earned_time/earned_time/tests/test_ladder.py @@ -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 diff --git a/earned_time/earned_time/tests/test_maturity_floor.py b/earned_time/earned_time/tests/test_maturity_floor.py new file mode 100644 index 0000000..70b5c46 --- /dev/null +++ b/earned_time/earned_time/tests/test_maturity_floor.py @@ -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 diff --git a/earned_time/earned_time/tests/test_tutor.py b/earned_time/earned_time/tests/test_tutor.py index 1a0b7c3..bd80edc 100644 --- a/earned_time/earned_time/tests/test_tutor.py +++ b/earned_time/earned_time/tests/test_tutor.py @@ -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 diff --git a/earned_time/earned_time/tests/test_waiver.py b/earned_time/earned_time/tests/test_waiver.py index d855ca0..d2c8ee4 100644 --- a/earned_time/earned_time/tests/test_waiver.py +++ b/earned_time/earned_time/tests/test_waiver.py @@ -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]) diff --git a/earned_time/pyproject.toml b/earned_time/pyproject.toml index 48c9dda..fa4dc9f 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.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 = [] diff --git a/earned_time/requirements.txt b/earned_time/requirements.txt index 8702f9f..ce1a92d 100644 --- a/earned_time/requirements.txt +++ b/earned_time/requirements.txt @@ -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 diff --git a/earned_time/scripts/demo_tutor_cutover.py b/earned_time/scripts/demo_tutor_cutover.py index 0bffe1d..35fa0c9 100644 --- a/earned_time/scripts/demo_tutor_cutover.py +++ b/earned_time/scripts/demo_tutor_cutover.py @@ -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}