earned_time 0.10.0: tutor quota per weekday (15 min Tue-Thu, 30 Mon/Fri, 50 Sat/Sun for the full 60)
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
earned_time pre-commit / pre-commit (push) Has been cancelled
earned_time tests / test (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled

From TUTOR_QUOTA_FROM (2026-10-12) credit_units scales the tutor's deduped
day minute sum to min(60, minutes * 60 // quota); reward and penalty are
unchanged, every quota <= 60 so the switch is raise-only. Earlier days pay
one unit per minute as before. WORKDAYS is a literal copy of freedays'
(importing freedays pulls crdt-sync; earned_time is stdlib only), guarded by
a drift test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E57caQF4KXwEvqEnKtJiHM
This commit is contained in:
Krzysztof Rudnicki 2026-10-12 21:07:03 +02:00
parent 0ecdc4e4be
commit ba34b603a1
7 changed files with 248 additions and 15 deletions

View File

@ -57,7 +57,11 @@ tutor's ledger (`~/.local/share/tutor/ledger.json`, `tutor_match` on
on `~/.local/share/automation_tutor/ledger.json`; rows carry no earner name,
so the rename only moved the ledger (copied whole, old rows still pay).
Since 0.8.0 it pays per **active
minute** (a unit is one minute), at most 60 a day summed across sessions:
minute** (a unit is one minute), at most 60 a day summed across sessions.
**Weekday quota (0.10.0, `TUTOR_QUOTA_FROM` 2026-10-12, `_quota.py`):** all 60
units take 15 min Tue-Thu (`WORKDAYS`, a copy of freedays'), 30 Mon/Fri, 50
Sat/Sun; `credit_units` scales the day's deduped minute sum,
min(60, minutes * 60 // quota), so it returns units, no longer minutes:
| units | gaming | shutdown rung |
|---|---|---|
@ -152,17 +156,14 @@ How far to trust a gate is computed from its ledger, never listed by name:
- `base_for(day=None, earners=EARNERS) -> Base` — the day's floors.
- `done_today(earner, ledger, key_file, *, now=None) -> bool | None`
- `first_credit_at(earner, ledger, key_file, day) -> float | None` — unix time
of the earliest verified credit that counts for `day`. The time is the
stamp the earner's `match` decides on (`_match.CREDIT_STAMPS`: LeetCode
`submitted_at`, reading `ended_at`, workout `completed_at`; Anki/Automation
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).
of the earliest verified credit for `day`, on its matcher's stamp
(`_match.CREDIT_STAMPS`; Anki/Automation: `created_at`). It can lie outside
`day` (Anki's rollover, a rest day declared the evening before).
- `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`.
count (the tutor's `detail.minutes`, legacy block rows 15; from 0.10.0 the
day's sum scaled to its weekday quota). 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
@ -236,7 +237,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.9.0#subdirectory=earned_time"
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.10.0#subdirectory=earned_time"
```
The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports

View File

@ -77,6 +77,7 @@ from earned_time._policy import (
Earner,
earner,
)
from earned_time._quota import TUTOR_QUOTA_FROM, TUTOR_QUOTA_MINUTES, tutor_quota_for
from earned_time._registry import all_earners, earners_for, registries
from earned_time._resolve import Base, Resolution, Term, base_for, resolve
@ -99,6 +100,8 @@ __all__ = [
"TUTOR_EARNERS",
"TUTOR_FROM",
"TUTOR_LADDER",
"TUTOR_QUOTA_FROM",
"TUTOR_QUOTA_MINUTES",
"WAKE_MINUTES",
"WORKOUT",
"Base",
@ -129,5 +132,6 @@ __all__ = [
"shutdown_ceiling_for",
"shutdown_minutes_for",
"today_window",
"tutor_quota_for",
"verified",
]

View File

@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Final
from earned_time._ledger import counting_rows
from earned_time._match import CREDIT_STAMPS, ROW_UNITS
from earned_time._quota import DAY_UNITS
if TYPE_CHECKING:
from pathlib import Path
@ -100,7 +101,10 @@ def credit_units(earner: Earner, ledger: Path, key_file: Path, day: date) -> int
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.
day mixing block rows and minute rows sums both. From
``TUTOR_QUOTA_FROM`` the tutor's day sum is then scaled to its weekday
quota (:func:`~earned_time._quota.tutor_day_units`, 0.10.0): units, no
longer minutes.
Args:
earner: A ledger-backed earner (it must have ``match``).
@ -130,4 +134,6 @@ def credit_units(earner: Earner, ledger: Path, key_file: Path, day: date) -> int
anonymous += units
else:
by_id[entry_id] = min(units, by_id.get(entry_id, units))
return anonymous + sum(by_id.values())
total = anonymous + sum(by_id.values())
scale = DAY_UNITS.get(earner.match) if earner.match is not None else None
return total if scale is None else scale(earner, total, day)

View File

@ -0,0 +1,67 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The tutor's weekday quota: how many minutes a full day's reward takes.
From ``TUTOR_QUOTA_FROM`` the tutor's full reward (all ``max_units``, 60 gaming
and 60 shutdown minutes) is paid for fewer minutes on most days: 15 on a
workday, 30 on Monday and Friday, 50 at the weekend. A day pays
``min(max_units, minutes * max_units // quota)`` -- scaled on the day's summed
minutes, never per row, so flooring loses at most one unit a day. Every quota
is at most ``max_units``, so the switch only ever raises what a day pays
(may land on its deploy day). Earlier days pay one unit per minute, as before.
"""
from __future__ import annotations
from datetime import date
from types import MappingProxyType
from typing import TYPE_CHECKING, Final
from earned_time._match import tutor_match
if TYPE_CHECKING:
from collections.abc import Callable, Mapping
from earned_time._policy import CreditMatch, Earner
# From this day (a Monday, kuhy's call on 2026-10-12) the weekday quota applies.
TUTOR_QUOTA_FROM: Final = date(2026, 10, 12)
# A literal copy of ``freedays.WORKDAYS`` (Tue/Wed/Thu, ``date.weekday()``):
# importing freedays pulls in crdt-sync, and earned_time is stdlib only. A
# drift test compares the two.
WORKDAYS: Final[frozenset[int]] = frozenset({1, 2, 3})
# The rest of the week, split: Monday/Friday and the weekend.
EDGE_DAYS: Final[frozenset[int]] = frozenset({0, 4})
WEEKEND: Final[frozenset[int]] = frozenset({5, 6})
# Minutes of tutoring the full reward takes, per weekday class.
TUTOR_QUOTA_MINUTES: Final[Mapping[frozenset[int], int]] = MappingProxyType(
{WORKDAYS: 15, EDGE_DAYS: 30, WEEKEND: 50}
)
def tutor_quota_for(day: date) -> int | None:
"""Minutes the tutor's full reward takes on ``day``; ``None`` before it."""
if day < TUTOR_QUOTA_FROM:
return None
weekday = day.weekday()
return next(q for days, q in TUTOR_QUOTA_MINUTES.items() if weekday in days)
def tutor_day_units(earner: Earner, minutes: int, day: date) -> int:
"""Units ``minutes`` of tutoring pay on ``day``: scaled to its quota.
Before ``TUTOR_QUOTA_FROM`` one unit per minute (``max_units`` still
applied by :func:`~earned_time.resolve`); from it,
``min(max_units, minutes * max_units // quota)``.
"""
quota = tutor_quota_for(day)
if quota is None:
return minutes
most = earner.max_units or quota
return earner.capped(minutes * most // quota)
# How a day's summed row units become the units it pays, per matcher; a
# matcher without an entry pays its sum unchanged.
DAY_UNITS: Final[Mapping[CreditMatch, Callable[[Earner, int, date], int]]] = (
MappingProxyType({tutor_match: tutor_day_units})
)

View File

@ -22,7 +22,9 @@ if TYPE_CHECKING:
from pathlib import Path
KEY = b"test-key"
DAY = date(2026, 10, 12)
# The day before TUTOR_QUOTA_FROM: one unit per minute (test_tutor_quota.py
# covers the weekday quota).
DAY = date(2026, 10, 11)
WINDOW = day_window(DAY)

View File

@ -0,0 +1,153 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The tutor's weekday quota (0.10.0): fewer minutes for the same full reward."""
from __future__ import annotations
from datetime import date, datetime, time, timedelta
import json
from typing import TYPE_CHECKING
import pytest
from earned_time import (
TUTOR,
TUTOR_QUOTA_FROM,
TUTOR_QUOTA_MINUTES,
credit_units,
entry_signature,
resolve,
tutor_quota_for,
)
from earned_time._quota import EDGE_DAYS, WEEKEND, WORKDAYS, tutor_day_units
if TYPE_CHECKING:
from pathlib import Path
KEY = b"quota-key"
EVE = date(2026, 10, 11) # Sunday, the day before the switch
MON, TUE, WED, THU, FRI, SAT, SUN = (
TUTOR_QUOTA_FROM + timedelta(days=n) for n in range(7)
)
def _at(day: date, minute: int) -> float:
start = datetime.combine(day, time(17)).astimezone()
return (start + timedelta(minutes=minute)).timestamp()
def _row(day: date, entry: str, ended: float, minutes: int | None) -> dict[str, object]:
detail: dict[str, object] = {"ended_at": ended}
if minutes is not None:
detail["minutes"] = minutes
row: dict[str, object] = {
"kind": "credit",
"entry_id": entry,
"day": day.isoformat(),
"created_at": ended,
"detail": detail,
}
return {**row, "hmac": entry_signature(row, KEY)}
def _units(tmp_path: Path, day: date, rows: list[dict[str, object]]) -> int | None:
ledger = tmp_path / "ledger.json"
ledger.write_text(json.dumps({"entries": rows}), encoding="utf-8")
key = tmp_path / "hmac.key"
key.write_bytes(KEY)
return credit_units(TUTOR, ledger, key, day)
def _minutes(day: date, count: int) -> list[dict[str, object]]:
return [_row(day, f"s-m{n}", _at(day, n), 1) for n in range(1, count + 1)]
def test_weekday_classes_partition_the_week() -> None:
assert frozenset(range(7)) == WORKDAYS | EDGE_DAYS | WEEKEND
assert len(WORKDAYS) + len(EDGE_DAYS) + len(WEEKEND) == 7
assert set(TUTOR_QUOTA_MINUTES) == {WORKDAYS, EDGE_DAYS, WEEKEND}
def test_workdays_match_freedays() -> None:
freedays = pytest.importorskip("freedays")
assert freedays.WORKDAYS == WORKDAYS
@pytest.mark.parametrize(
("day", "quota"),
[(MON, 30), (TUE, 15), (WED, 15), (THU, 15), (FRI, 30), (SAT, 50), (SUN, 50)],
)
def test_quota_per_weekday(day: date, quota: int) -> None:
assert tutor_quota_for(day) == quota
def test_no_quota_before_the_switch() -> None:
assert date(2026, 10, 12) == MON
assert tutor_quota_for(EVE) is None
@pytest.mark.parametrize(
("day", "minutes", "units"),
[
(EVE, 10, 10),
(EVE, 50, 50),
(MON, 10, 20),
(MON, 15, 30),
(MON, 30, 60),
(TUE, 10, 40),
(TUE, 15, 60),
(SAT, 10, 12),
(SAT, 25, 30),
(SAT, 50, 60),
(SAT, 7, 8), # 7 * 60 / 50 = 8.4, floored
(FRI, 1, 2),
],
)
def test_one_minute_rows_scale_to_the_quota(
tmp_path: Path, day: date, minutes: int, units: int
) -> None:
assert _units(tmp_path, day, _minutes(day, minutes)) == units
@pytest.mark.parametrize("day", [MON, TUE, SAT])
def test_the_day_caps_at_60(tmp_path: Path, day: date) -> None:
assert _units(tmp_path, day, _minutes(day, 90)) == 60
def test_before_the_switch_minutes_pass_through_uncapped(tmp_path: Path) -> None:
assert _units(tmp_path, EVE, _minutes(EVE, 90)) == 90
def test_multi_row_day_scales_the_sum_not_each_row(tmp_path: Path) -> None:
# Per row: 3 * 60 // 50 = 3 each, 5 rows = 15. On the sum: 15 * 60 // 50 = 18.
rows = [_row(SAT, f"s{n}-m1", _at(SAT, 10 * n), 3) for n in range(5)]
assert _units(tmp_path, SAT, rows) == 18
def test_a_replayed_row_still_pays_once(tmp_path: Path) -> None:
rows = [_row(TUE, "s-m1", _at(TUE, 1), 5), _row(TUE, "s-m1", _at(TUE, 2), 5)]
assert _units(tmp_path, TUE, rows) == 20
def test_legacy_block_rows_count_15_minutes_then_scale(tmp_path: Path) -> None:
blocks = [_row(MON, "s-b1", _at(MON, 15), None)]
assert _units(tmp_path, MON, blocks) == 30
mixed = [*blocks, _row(MON, "t-m1", _at(MON, 40), 5)]
assert _units(tmp_path, MON, mixed) == 40
assert _units(tmp_path, TUE, [_row(TUE, "s-b1", _at(TUE, 15), None)]) == 60
def test_full_reward_and_penalty_unchanged(tmp_path: Path) -> None:
others = {"workout": 0, "leetcode": 0, "reading": 0}
full = resolve({**others, "tutor": _units(tmp_path, TUE, _minutes(TUE, 15))}, TUE)
none = resolve({**others, "tutor": 0}, TUE)
assert full.term("tutor").gaming_minutes == 60
assert full.term("tutor").shutdown_minutes == 60
assert full.base == none.base
assert none.term("tutor").gaming_minutes == 0
@pytest.mark.parametrize("offset", range(7))
def test_raise_only_against_0_9_0(offset: int) -> None:
day = TUTOR_QUOTA_FROM + timedelta(days=offset)
for minutes in range(91):
assert tutor_day_units(TUTOR, minutes, day) >= TUTOR.capped(minutes)

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "earned-time"
version = "0.9.0"
version = "0.10.0"
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
requires-python = ">=3.14"
dependencies = []