From bce1f6d0ccdc50264bfa955122fa8444167b306f Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 10 Oct 2026 15:47:15 +0200 Subject: [PATCH] earned_time 0.8.0: Automation tutor paid per active minute (legacy 15-min block rows count 15) AUTOMATION_TUTOR: 1 gaming min per unit (minute), max_units 60; TUTOR_LADDER automation Rung(1, extra=1), rungs still sum 250 (23:00 / 18:50). credit_units sums each row's units (ROW_UNITS: tutor detail.minutes, a row without it = 15); a repeated entry_id pays once at its smallest count; an invalid minutes field is refused by tutor_match. An all-block day resolves exactly as 0.7.0. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N9cLaZkZaH7Zi2K63uWuoB --- earned_time/README.md | 53 +++++-- earned_time/earned_time/_credits.py | 40 ++++-- earned_time/earned_time/_ladder.py | 21 +-- earned_time/earned_time/_match.py | 40 +++++- earned_time/earned_time/_policy.py | 22 +-- earned_time/earned_time/tests/test_tutor.py | 73 +++++----- .../earned_time/tests/test_tutor_ledger.py | 9 +- .../earned_time/tests/test_tutor_minutes.py | 130 ++++++++++++++++++ earned_time/pyproject.toml | 2 +- earned_time/scripts/demo_tutor_cutover.py | 5 +- 10 files changed, 302 insertions(+), 93 deletions(-) create mode 100644 earned_time/earned_time/tests/test_tutor_minutes.py diff --git a/earned_time/README.md b/earned_time/README.md index db775d3..5245822 100644 --- a/earned_time/README.md +++ b/earned_time/README.md @@ -53,18 +53,43 @@ registry is per day: `earners_for(day)` is `EARNERS` before it and `TUTOR_EARNERS` from it. From `TUTOR_FROM` Anki is retired (neither penalised nor paid) and `automation` is `AUTOMATION_TUTOR`, a counted earner on the Automation tutor's ledger (`~/.local/share/automation_tutor/ledger.json`, -one signed row per verified 15-minute block, `tutor_match` on -`detail.ended_at`), at most 4 blocks a day: +`tutor_match` on `detail.ended_at`). Since 0.8.0 it pays per **active +minute** (a unit is one minute), at most 60 a day summed across sessions: -| blocks | gaming | shutdown rung | +| units | gaming | shutdown rung | |---|---|---| -| each | +15 min | 13 / 13 / 12 / 12 | -| all 4 | +60 (Anki's 30 folded in) | +50 (Anki's 25 folded in) | +| each minute | +1 min | +1 min (`Rung(1, extra=1)`) | +| all 60 | +60 (Anki's 30 folded in) | +60 (Anki's 25 folded in) | -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 +Each minute earns exactly the shutdown it costs (the fairness rule). Once its +penalty has started, the gaming base drops by the full 60 +(`Earner.max_gaming_minutes`) and the ladder floor by the full 60, so nothing +done is 3 h / 18:50 and everything done is still 8 h / 23:00. Until then +(given `first_credits`, see below) it costs nothing: 4 h / 19:50. + +**The tutor row contract (0.8.0, mixed-day safe).** `credit_units` sums what +each counting row pays instead of counting rows (`_credits.row_units`, the +per-matcher reader `_match.ROW_UNITS`): + +- `detail.minutes` (a positive JSON int, never a bool/float/string) is the + active minutes the row pays. A row **without** it is a 0.7.0 15-minute + block and pays 15 (`LEGACY_TUTOR_MINUTES`), so a day that mixes block rows + and minute rows sums both, and an all-block day resolves exactly as 0.7.0 + did (min(15 n, 60) = 15 min(n, 4)). A row whose `minutes` is present but + invalid is refused by `tutor_match` (logged) and pays nothing anywhere. +- An `entry_id` pays once; rows repeated under one id pay the **smallest** + of their counts, so a replay or a rewrite can never raise a credit. +- What the writer (plc-lab `plc_lab/tutor/credit.py`) must guarantee: every + new row carries `detail.minutes`; one row per credited minute, + `entry_id = f"{session_id}-m{n}"` with `n` the session's n-th credited + active minute (deterministic, so a retry rewrites the same id; never the + `-b{n}` form); ids never cover overlapping minutes (no cumulative "total so + far" rows); `detail.ended_at` is when the last paid minute ended (it picks + the day); its own daily cap sums minutes (block rows as 15) up to 60. +- Rollout order: no minute row may be written until every consumer process + runs 0.8.0. A 0.7.0 reader pays each row as a 15-minute block. + +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. @@ -125,9 +150,11 @@ How far to trust a gate is computed from its ledger, never listed by name: fall back to `created_at`). It can lie outside `day` when a gate's day is not the calendar day (Anki's rollover, a rest day declared the evening before). -- `credit_units(earner, ledger, key_file, day) -> int | None` — how many - verified rows count for `day`: a counted earner's units (a repeated - `entry_id` counts once; `max_units` is applied by `resolve`). +- `credit_units(earner, ledger, key_file, day) -> int | None` — the units + the verified rows pay for `day`: 1 per row (the workout), or the row's own + count (the tutor's `detail.minutes`, legacy block rows 15). A repeated + `entry_id` pays once, its smallest count; `max_units` is applied by + `resolve`. - `earners_for(day)`, `all_earners()`, `registries()` — the registry in force on a day, and every earner of every registry (what a ledger watcher must watch). They read `earned_time.EARNERS` through the package, so a consumer @@ -201,7 +228,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.1#subdirectory=earned_time" +pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.8.0#subdirectory=earned_time" ``` The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports diff --git a/earned_time/earned_time/_credits.py b/earned_time/earned_time/_credits.py index 27424db..41dd5e4 100644 --- a/earned_time/earned_time/_credits.py +++ b/earned_time/earned_time/_credits.py @@ -15,7 +15,7 @@ import logging from typing import TYPE_CHECKING, Final from earned_time._ledger import counting_rows -from earned_time._match import CREDIT_STAMPS +from earned_time._match import CREDIT_STAMPS, ROW_UNITS if TYPE_CHECKING: from pathlib import Path @@ -85,8 +85,22 @@ def first_credit_at( return min(times, default=None) +def row_units(earner: Earner, row: dict[str, object]) -> int: + """Units one counting row pays: its own count (``ROW_UNITS``), else 1. + + Only the tutor's rows carry a count (``detail.minutes``, 0.8.0); a row + whose count is unusable pays 0 (``tutor_match`` already refuses it). + """ + reader = ROW_UNITS.get(earner.match) if earner.match is not None else None + return 1 if reader is None else reader(row) or 0 + + def credit_units(earner: Earner, ledger: Path, key_file: Path, day: date) -> int | None: - """How many verified credit rows count for ``day`` -- a counted earner's units. + """How many units the verified credit rows pay for ``day``. + + Each row pays :func:`row_units`: 1 for most earners (the workout), its + ``detail.minutes`` for the tutor (a 0.7.0 block row without it, 15), so a + day mixing block rows and minute rows sums both. Args: earner: A ledger-backed earner (it must have ``match``). @@ -95,9 +109,10 @@ def credit_units(earner: Earner, ledger: Path, key_file: Path, day: date) -> int day: The local day the rows must count for (by ``match``). Returns: - The count (``0`` included), or ``None`` when the ledger or key could - not be read -- never "no units". A row repeated under the same - ``entry_id`` (a replayed write) counts once. The earner's + The sum (``0`` included), or ``None`` when the ledger or key could + not be read -- never "no units". An ``entry_id`` pays once: rows + repeated under one id (a replayed write) pay the smallest of their + counts, so a rewrite can never raise a credit. The earner's ``max_units`` is applied by :func:`~earned_time.resolve`, not here. Raises: @@ -106,12 +121,13 @@ def credit_units(earner: Earner, ledger: Path, key_file: Path, day: date) -> int rows = counting_rows(earner, ledger, key_file, day_window(day)) if rows is None: return None - ids: set[object] = set() - units = 0 + by_id: dict[object, int] = {} + anonymous = 0 for row in rows: + units = row_units(earner, row) entry_id = row.get("entry_id") - if entry_id is not None and entry_id in ids: - continue - ids.add(entry_id) - units += 1 - return units + if entry_id is None: + anonymous += units + else: + by_id[entry_id] = min(units, by_id.get(entry_id, units)) + return anonymous + sum(by_id.values()) diff --git a/earned_time/earned_time/_ladder.py b/earned_time/earned_time/_ladder.py index 8352a20..15a6f56 100644 --- a/earned_time/earned_time/_ladder.py +++ b/earned_time/earned_time/_ladder.py @@ -9,7 +9,7 @@ frozen ceiling, so the switch never rewrites how an old day resolved. Gaming is not on the ladder. From ``TUTOR_FROM`` the rungs are :data:`TUTOR_LADDER`: Anki is retired and -its 25 minutes fold into the Automation tutor's, paid per 15-minute block. +its 25 minutes fold into the Automation tutor's, paid per active minute. """ from __future__ import annotations @@ -34,11 +34,12 @@ LADDER_FROM: Final = date(2026, 10, 10) # Alarm time, minutes after midnight. The ladder's ceiling is eight hours of # 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``). The day after the deploy -# (0.6.1, 2026-10-09) -- never the deploy day itself (a same-day cut, -# 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. +# From this day the Anki earner is retired and Automation is paid by the +# tutor (``earned_time._policy.AUTOMATION_TUTOR``: per 15-minute block until +# 0.8.0, per active minute since). The day after the deploy (0.6.1, +# 2026-10-09) -- never the deploy day itself (a same-day cut, 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 @@ -94,15 +95,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. 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. +# active tutor minute (0.8.0; 0.7.0 paid 15 per 15-minute block). Each minute +# earns the minute it costs (the fairness rule), 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(15, extra=15), + "automation": Rung(1, extra=1), } ) diff --git a/earned_time/earned_time/_match.py b/earned_time/earned_time/_match.py index af1cacd..dbb71f9 100644 --- a/earned_time/earned_time/_match.py +++ b/earned_time/earned_time/_match.py @@ -114,13 +114,38 @@ def workout_match(row: dict[str, object], window: Window) -> bool: return landed -def tutor_match(row: dict[str, object], window: Window) -> bool: - """A tutor block counts on the day it ended (``detail.ended_at``). +# A tutor row without ``detail.minutes`` is a 0.7.0 15-minute block. +LEGACY_TUTOR_MINUTES: Final = 15 - The Automation tutor writes one row per finished 15-minute block; a - session that runs past midnight pays the day each block ended on. + +def tutor_minutes(row: dict[str, object]) -> int | None: + """Active minutes a tutor row pays (0.8.0); ``None`` if ``minutes`` is bad. + + ``detail.minutes`` is a positive int (never a bool, float or string). A + row without it is a legacy 15-minute block (:data:`LEGACY_TUTOR_MINUTES`). """ detail = row.get("detail") + if not isinstance(detail, dict) or "minutes" not in detail: + return LEGACY_TUTOR_MINUTES + raw = detail["minutes"] + if isinstance(raw, bool) or not isinstance(raw, int) or raw < 1: + return None + return raw + + +def tutor_match(row: dict[str, object], window: Window) -> bool: + """A tutor credit counts on the day it ended (``detail.ended_at``). + + The Automation tutor pays per active minute (0.8.0): each row carries the + minutes it pays in ``detail.minutes`` (:func:`tutor_minutes`); a 0.7.0 + 15-minute block row has none and pays 15. A row whose ``minutes`` is + present but not a positive int counts for nothing, logged. A session + that runs past midnight pays the day each row ended on. + """ + if tutor_minutes(row) is None: + _logger.warning("tutor credit %r has bad minutes", row.get("entry_id")) + return False + detail = row.get("detail") raw = detail.get("ended_at") if isinstance(detail, dict) else None landed = _within(raw, window) if landed is None: @@ -140,3 +165,10 @@ CREDIT_STAMPS: Final[Mapping[CreditMatch, str]] = MappingProxyType( tutor_match: "ended_at", } ) +# How many units one counting row pays, per matcher; a matcher without an +# entry pays 1 per row (the workout). Only the tutor's rows carry their own +# count (``detail.minutes``): book-guard's ``detail.minutes`` is a string +# and means something else, so this is never read generically. +ROW_UNITS: Final[Mapping[CreditMatch, Callable[[dict[str, object]], int | None]]] = ( + MappingProxyType({tutor_match: tutor_minutes}) +) diff --git a/earned_time/earned_time/_policy.py b/earned_time/earned_time/_policy.py index 28a0a89..02b3cb0 100644 --- a/earned_time/earned_time/_policy.py +++ b/earned_time/earned_time/_policy.py @@ -70,7 +70,7 @@ class Earner: applied by the consumer that counts the units. extra_shutdown_minutes: Each further unit, for ``"counted"`` earners. extra_gaming_minutes: Gaming time each further unit earns - (``"counted"``); the workout's are 0, the tutor's 15. + (``"counted"``); the workout's are 0, the tutor's 1 (per minute). max_units: The most units one day can pay; ``None`` is unbounded. penalty_from: From this day the gaming base drops by the most the earner can pay (:attr:`max_gaming_minutes`). ``None`` means a pure @@ -194,19 +194,19 @@ AUTOMATION: Final = Earner( missing_ledger_is_no=True, ) -# From ``TUTOR_FROM``: the Automation tutor pays per verified 15-minute block, -# up to four a day. Anki's 30 gaming minutes are folded in (4 x 15 = 60), so -# retiring Anki is not a free +30: the base drops by the full 60. +# From ``TUTOR_FROM``: the Automation tutor pays per active minute (0.8.0; a +# 0.7.0 15-minute block row counts 15), 1 gaming minute each, up to 60 a day +# summed across sessions. Anki's 30 is folded in, so the base drops by 60. AUTOMATION_TUTOR: Final = Earner( name="automation", label="Automation", - gaming_minutes=15, - extra_gaming_minutes=15, - max_units=4, + gaming_minutes=1, + extra_gaming_minutes=1, + max_units=60, # Pre-ladder values, unused (TUTOR_FROM is not before LADDER_FROM); the - # ladder pays 15 per block (``TUTOR_LADDER``). - shutdown_minutes=15, - extra_shutdown_minutes=15, + # ladder pays 1 per minute (``TUTOR_LADDER``). + shutdown_minutes=1, + extra_shutdown_minutes=1, kind="counted", penalty_from=TUTOR_FROM, ledger=".local/share/automation_tutor/ledger.json", @@ -227,7 +227,7 @@ EARNERS: Final[tuple[Earner, ...]] = ( ANKI, AUTOMATION, ) -# From ``TUTOR_FROM``: Anki retired, Automation paid per tutor block. +# From ``TUTOR_FROM``: Anki retired, Automation paid per tutor minute. TUTOR_EARNERS: Final[tuple[Earner, ...]] = ( WORKOUT, LEETCODE, diff --git a/earned_time/earned_time/tests/test_tutor.py b/earned_time/earned_time/tests/test_tutor.py index e234598..c1c04d3 100644 --- a/earned_time/earned_time/tests/test_tutor.py +++ b/earned_time/earned_time/tests/test_tutor.py @@ -1,5 +1,5 @@ # Copyright (c) 2026 Krzysztof Rudnicki -"""The tutor cutover: Anki retired, Automation paid per 15-minute block.""" +"""The tutor cutover: Anki retired, Automation paid per active minute (0.8.0).""" from __future__ import annotations @@ -34,8 +34,8 @@ def _hhmm(minutes: int) -> str: return f"{minutes // 60:02d}:{minutes % 60:02d}" -def _day(blocks: int, **others: int) -> tuple[int, str]: - day = resolve({**others, "automation": blocks}, TUTOR_FROM) +def _day(minutes: int, **others: int) -> tuple[int, str]: + day = resolve({**others, "automation": minutes}, TUTOR_FROM) return day.gaming_minutes, _hhmm(day.shutdown_minutes) @@ -65,18 +65,18 @@ def test_all_earners_lists_each_once() -> None: assert AUTOMATION_TUTOR in all_earners() -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. +def test_each_tutor_minute_earns_the_minute_it_costs() -> None: + # The fairness rule: no unit costs more than the shutdown it earns. The + # rungs, each at its earner's cap, still span 250 (23:00 down to 18:50). 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, - ] + full = { + e.name: TUTOR_LADDER[e.name].minutes(e.max_units or 1) for e in TUTOR_EARNERS + } + assert full == {"workout": 110, "leetcode": 50, "reading": 30, "automation": 60} + assert sum(full.values()) == 250 + for n in range(61): + assert AUTOMATION_TUTOR.shutdown_for(n, TUTOR_FROM) == n + assert AUTOMATION_TUTOR.gaming_for(n) == n def test_nothing_done_is_three_hours_and_18_50() -> None: @@ -91,29 +91,35 @@ def test_everything_but_the_tutor() -> None: @pytest.mark.parametrize( - ("blocks", "gaming", "shutdown"), - [(1, 435, "22:15"), (2, 450, "22:30"), (3, 465, "22:45"), (4, 480, "23:00")], + ("minutes", "gaming", "shutdown"), + [ + (1, 421, "22:01"), + (15, 435, "22:15"), + (30, 450, "22:30"), + (59, 479, "22:59"), + (60, 480, "23:00"), + ], ) -def test_each_block_pays_15_gaming_and_its_rung( - blocks: int, gaming: int, shutdown: str +def test_each_minute_pays_1_gaming_and_1_shutdown( + minutes: int, gaming: int, shutdown: str ) -> None: - assert _day(blocks, **OTHERS) == (gaming, shutdown) + assert _day(minutes, **OTHERS) == (gaming, shutdown) def test_partial_credit_without_the_others() -> None: - assert _day(1) == (195, "19:05") - assert _day(2) == (210, "19:20") - assert _day(4) == (240, "19:50") + assert _day(1) == (181, "18:51") + assert _day(15) == (195, "19:05") # one legacy block, as 0.7.0 paid it + assert _day(60) == (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, 60) +def test_minutes_past_60_are_clamped() -> None: + assert _day(61, **OTHERS) == _day(60, **OTHERS) == (480, "23:00") + term = resolve({"automation": 75}, TUTOR_FROM).term("automation") + assert (term.answer, term.gaming_minutes, term.shutdown_minutes) == (75, 60, 60) def test_anki_is_neither_penalised_nor_paid_from_tutor_from() -> None: - with_anki = resolve({**OTHERS, "anki": 1, "automation": 4}, TUTOR_FROM) + with_anki = resolve({**OTHERS, "anki": 1, "automation": 60}, TUTOR_FROM) assert (with_anki.gaming_minutes, with_anki.shutdown_minutes) == (480, 23 * 60) assert "anki" not in {t.earner.name for t in with_anki.terms} @@ -139,17 +145,12 @@ 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) == 15 + assert extra_shutdown_minutes_for(AUTOMATION_TUTOR, TUTOR_FROM) == 1 def test_gaming_for_and_max() -> None: - assert [AUTOMATION_TUTOR.gaming_for(n) for n in (-1, 0, 1, 4, 9)] == [ - 0, - 0, - 15, - 60, - 60, - ] + gaming = [AUTOMATION_TUTOR.gaming_for(n) for n in (-1, 0, 1, 15, 60, 90)] + assert gaming == [0, 0, 1, 15, 60, 60] assert AUTOMATION_TUTOR.max_gaming_minutes == 60 assert WORKOUT.max_gaming_minutes == WORKOUT.gaming_minutes assert WORKOUT.gaming_for(3) == WORKOUT.gaming_minutes @@ -157,5 +158,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) == 30 + assert AUTOMATION_TUTOR.shutdown_for(2, old) == 2 assert AUTOMATION_TUTOR.shutdown_for(0, old) == 0 diff --git a/earned_time/earned_time/tests/test_tutor_ledger.py b/earned_time/earned_time/tests/test_tutor_ledger.py index 898dee5..7905582 100644 --- a/earned_time/earned_time/tests/test_tutor_ledger.py +++ b/earned_time/earned_time/tests/test_tutor_ledger.py @@ -1,5 +1,5 @@ # Copyright (c) 2026 Krzysztof Rudnicki -"""The tutor ledger: which signed block rows count, and how many.""" +"""The tutor ledger: which signed 0.7.0 block rows count, and how many minutes.""" from __future__ import annotations @@ -80,7 +80,7 @@ def test_row_without_detail_never_counts() -> None: assert not tutor_match({"kind": "credit", "entry_id": "x"}, WINDOW) -def test_units_count_blocks_once_and_skip_forgeries( +def test_legacy_blocks_count_15_once_and_skip_forgeries( tmp_path: Path, key_file: Path ) -> None: tampered = block("s1", 3, _at(19)) @@ -95,7 +95,8 @@ def test_units_count_blocks_once_and_skip_forgeries( block("s0", 1, _at(23, 0, DAY - timedelta(days=1))), ] path = _ledger(tmp_path, rows) - assert credit_units(AUTOMATION_TUTOR, path, key_file, DAY) == 2 + # 0.7.0 block rows carry no detail.minutes: each pays 15 minutes. + assert credit_units(AUTOMATION_TUTOR, path, key_file, DAY) == 30 assert first_credit_at(AUTOMATION_TUTOR, path, key_file, DAY) == _at(18, 15) @@ -106,7 +107,7 @@ def test_rows_without_entry_id_each_count(tmp_path: Path, key_file: Path) -> Non del row["entry_id"] rows.append({**row, "hmac": entry_signature(row, KEY)}) path = _ledger(tmp_path, rows) - assert credit_units(AUTOMATION_TUTOR, path, key_file, DAY) == 2 + assert credit_units(AUTOMATION_TUTOR, path, key_file, DAY) == 30 def test_missing_tutor_ledger_is_zero(tmp_path: Path, key_file: Path) -> None: diff --git a/earned_time/earned_time/tests/test_tutor_minutes.py b/earned_time/earned_time/tests/test_tutor_minutes.py new file mode 100644 index 0000000..3631a62 --- /dev/null +++ b/earned_time/earned_time/tests/test_tutor_minutes.py @@ -0,0 +1,130 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""0.8.0: tutor rows pay ``detail.minutes``; 0.7.0 block rows still pay 15.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from earned_time import ( + AUTOMATION_TUTOR, + WORKOUT, + credit_units, + entry_signature, + first_credit_at, + resolve, +) +from earned_time._credits import row_units +from earned_time._match import LEGACY_TUTOR_MINUTES, tutor_match, tutor_minutes +from earned_time.tests.test_tutor_ledger import DAY, KEY, WINDOW, _at, _ledger, block + +if TYPE_CHECKING: + from pathlib import Path + +OTHERS = {"workout": 1, "leetcode": 1, "reading": 1} + + +def minute( + session: str, first: int, ended: float, minutes: object = 1 +) -> dict[str, object]: + """A signed 0.8.0 row: ``minutes`` active minutes from the session's ``first``.""" + row: dict[str, object] = { + "kind": "credit", + "entry_id": f"{session}-m{first}", + "day": DAY.isoformat(), + "created_at": ended, + "detail": {"session_id": session, "minutes": minutes, "ended_at": ended}, + } + return {**row, "hmac": entry_signature(row, KEY)} + + +@pytest.fixture +def key_file(tmp_path: Path) -> Path: + path = tmp_path / "hmac.key" + path.write_bytes(KEY) + return path + + +def _units(tmp_path: Path, key_file: Path, rows: list[object]) -> int | None: + return credit_units(AUTOMATION_TUTOR, _ledger(tmp_path, rows), key_file, DAY) + + +def test_minute_rows_sum(tmp_path: Path, key_file: Path) -> None: + rows: list[object] = [minute("s", n, _at(18, n)) for n in (1, 2, 3)] + rows.append(minute("s", 4, _at(18, 13), minutes=10)) + assert _units(tmp_path, key_file, rows) == 13 + assert first_credit_at( + AUTOMATION_TUTOR, _ledger(tmp_path, rows), key_file, DAY + ) == _at(18, 1) + + +def test_legacy_rows_count_15() -> None: + assert tutor_minutes(block("s", 1, _at(9))) == LEGACY_TUTOR_MINUTES == 15 + assert tutor_minutes({"kind": "credit"}) == 15 # no detail: legacy shape + + +def test_mixed_day_sums_blocks_and_minutes(tmp_path: Path, key_file: Path) -> None: + rows: list[object] = [block("old", 1, _at(15, 0))] + rows += [minute("new", n, _at(18, n)) for n in range(1, 21)] + units = _units(tmp_path, key_file, rows) + assert units == 35 + day = resolve({**OTHERS, "automation": units or 0}, DAY) + assert (day.gaming_minutes, day.shutdown_minutes) == (420 + 35, 22 * 60 + 35) + + +def test_the_day_caps_at_60_across_sessions(tmp_path: Path, key_file: Path) -> None: + rows: list[object] = [block("a", n, _at(9, 15 * n)) for n in (1, 2, 3)] + rows += [minute("b", n, _at(14, n)) for n in range(1, 21)] + units = _units(tmp_path, key_file, rows) + assert units == 65 # the raw sum; resolve applies max_units + term = resolve({"automation": units}, DAY).term("automation") + assert (term.gaming_minutes, term.shutdown_minutes) == (60, 60) + + +def test_a_replayed_entry_id_pays_once_and_never_more( + tmp_path: Path, key_file: Path +) -> None: + rows: list[object] = [ + minute("s", 1, _at(18, 1)), + minute("s", 1, _at(18, 1)), # the same minute written twice + minute("s", 2, _at(18, 6), minutes=5), + minute("s", 2, _at(18, 6), minutes=1), # a rewrite: the smaller pays + minute("s", 3, _at(18, 9), minutes=2), + minute("s", 3, _at(18, 9), minutes=9), + ] + assert _units(tmp_path, key_file, rows) == 1 + 1 + 2 + + +@pytest.mark.parametrize("bad", [0, -1, True, 1.5, "5", None]) +def test_bad_minutes_never_count( + bad: object, tmp_path: Path, key_file: Path, caplog: pytest.LogCaptureFixture +) -> None: + row = minute("s", 1, _at(18), minutes=bad) + assert tutor_minutes(row) is None + assert not tutor_match(row, WINDOW) + assert "bad minutes" in caplog.text + assert row_units(AUTOMATION_TUTOR, row) == 0 + assert _units(tmp_path, key_file, [row, minute("s", 2, _at(18, 1))]) == 1 + + +def test_other_earners_pay_one_unit_per_row() -> None: + assert row_units(WORKOUT, {"detail": {"minutes": 40}}) == 1 + + +@pytest.mark.parametrize("blocks", range(7)) +def test_an_all_legacy_day_resolves_as_0_7_0_did( + blocks: int, tmp_path: Path, key_file: Path +) -> None: + # 0.7.0: min(blocks, 4) x 15 gaming and 15 shutdown; 0.8.0: min(15n, 60). + rows: list[object] = [block("s", n, _at(9, 5 * n)) for n in range(1, blocks + 1)] + units = _units(tmp_path, key_file, rows) or 0 + paid = 15 * min(blocks, 4) + for others in ({}, OTHERS): + day = resolve({**others, "automation": units}, DAY) + base = 420 if others else 180 + floor = 22 * 60 if others else 18 * 60 + 50 + assert (day.gaming_minutes, day.shutdown_minutes) == ( + base + paid, + floor + paid, + ) diff --git a/earned_time/pyproject.toml b/earned_time/pyproject.toml index 539a5e5..8255f4e 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.7.0" +version = "0.8.0" description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time" requires-python = ">=3.14" dependencies = [] diff --git a/earned_time/scripts/demo_tutor_cutover.py b/earned_time/scripts/demo_tutor_cutover.py index 35fa0c9..a64594b 100644 --- a/earned_time/scripts/demo_tutor_cutover.py +++ b/earned_time/scripts/demo_tutor_cutover.py @@ -1,7 +1,8 @@ # Copyright (c) 2026 Krzysztof Rudnicki """Demo: what the tutor cutover pays, read from a signed temp ledger. -Writes tutor block rows in the exact contract the Automation tutor writes, +Writes 0.7.0 tutor block rows (no ``detail.minutes``: 15 minutes each since +0.8.0, which pays per active minute) in the Automation tutor's contract, signs them with a throwaway key, counts them with the shared reader (``credit_units``) and resolves the day. Prints gaming minutes and shutdown for the day before ``TUTOR_FROM`` and for ``TUTOR_FROM`` itself with 0-5 @@ -110,7 +111,7 @@ def main() -> None: units = _units(folder, rows, TUTOR_FROM) label = f"{TUTOR_FROM}: 4 rows, 1 tampered -> {units} units" _say(_line(label, {**_OTHERS, "automation": units}, TUTOR_FROM)) - anki = {**_OTHERS, "anki": 1, "automation": 4} + anki = {**_OTHERS, "anki": 1, "automation": 60} _say( _line( f"{TUTOR_FROM}: all + a stale anki answer (ignored)", anki, TUTOR_FROM