mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:27:15 +01:00
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
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:
parent
0ecdc4e4be
commit
ba34b603a1
@ -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
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
67
earned_time/earned_time/_quota.py
Normal file
67
earned_time/earned_time/_quota.py
Normal 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})
|
||||
)
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
153
earned_time/earned_time/tests/test_tutor_quota.py
Normal file
153
earned_time/earned_time/tests/test_tutor_quota.py
Normal 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)
|
||||
@ -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 = []
|
||||
|
||||
Loading…
Reference in New Issue
Block a user