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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N9cLaZkZaH7Zi2K63uWuoB
This commit is contained in:
Krzysztof Rudnicki 2026-10-10 15:47:15 +02:00
parent e376660ab0
commit bce1f6d0cc
10 changed files with 302 additions and 93 deletions

View File

@ -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

View File

@ -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())

View File

@ -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),
}
)

View File

@ -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})
)

View File

@ -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,

View File

@ -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

View File

@ -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:

View File

@ -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,
)

View File

@ -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 = []

View File

@ -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