diff --git a/earned_time/README.md b/earned_time/README.md index 15b9ad3..87e8d31 100644 --- a/earned_time/README.md +++ b/earned_time/README.md @@ -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 diff --git a/earned_time/earned_time/__init__.py b/earned_time/earned_time/__init__.py index 1116f35..45ac627 100644 --- a/earned_time/earned_time/__init__.py +++ b/earned_time/earned_time/__init__.py @@ -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", ] diff --git a/earned_time/earned_time/_credits.py b/earned_time/earned_time/_credits.py index 41dd5e4..9153cef 100644 --- a/earned_time/earned_time/_credits.py +++ b/earned_time/earned_time/_credits.py @@ -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) diff --git a/earned_time/earned_time/_quota.py b/earned_time/earned_time/_quota.py new file mode 100644 index 0000000..cf7b1e5 --- /dev/null +++ b/earned_time/earned_time/_quota.py @@ -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}) +) diff --git a/earned_time/earned_time/tests/test_tutor_ledger.py b/earned_time/earned_time/tests/test_tutor_ledger.py index 2ccf6d4..acebcc7 100644 --- a/earned_time/earned_time/tests/test_tutor_ledger.py +++ b/earned_time/earned_time/tests/test_tutor_ledger.py @@ -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) diff --git a/earned_time/earned_time/tests/test_tutor_quota.py b/earned_time/earned_time/tests/test_tutor_quota.py new file mode 100644 index 0000000..8ed53ea --- /dev/null +++ b/earned_time/earned_time/tests/test_tutor_quota.py @@ -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) diff --git a/earned_time/pyproject.toml b/earned_time/pyproject.toml index fa25e2b..acaa398 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.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 = []