mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
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:
parent
e376660ab0
commit
bce1f6d0cc
@ -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
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -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})
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
130
earned_time/earned_time/tests/test_tutor_minutes.py
Normal file
130
earned_time/earned_time/tests/test_tutor_minutes.py
Normal 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,
|
||||
)
|
||||
@ -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 = []
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user