mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
earned_time 0.5.0: per-day registry, counted AUTOMATION tutor earner (TUTOR_FROM sentinel), Anki/Automation gap waiver from 2026-10-09
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
223b57eb87
commit
6641927dbe
@ -46,6 +46,35 @@ never a same-day cut):
|
||||
The two floors are derived separately (`_resolve._shutdown_floor`), so the
|
||||
ladder cannot move a gaming minute.
|
||||
|
||||
**The tutor cutover (`TUTOR_FROM`, one constant in `_ladder.py`).** The
|
||||
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:
|
||||
|
||||
| blocks | gaming | shutdown rung |
|
||||
|---|---|---|
|
||||
| each | +15 min | 13 / 13 / 12 / 12 |
|
||||
| all 4 | +60 (Anki's 30 folded in) | +50 (Anki's 25 folded in) |
|
||||
|
||||
The gaming base drops by the full 60 (`Earner.max_gaming_minutes`) and the
|
||||
ladder floor by the full 50, so nothing done is still 3 h / 19:00 and
|
||||
everything done is still 8 h / 23:00. 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.
|
||||
|
||||
**The gap waiver (`ANKI_WAIVED_FROM`, 2026-10-09).** anki-guard never wrote a
|
||||
credit, and the tutor is not confirmed yet, so from `ANKI_WAIVED_FROM` until
|
||||
`TUTOR_FROM` the registry is `EARNERS` without `anki` and `automation`:
|
||||
neither penalised nor paid. The base rises by exactly what the two could have
|
||||
paid back (nothing done: 4 h / 19:50 on a ladder day; everything done: still
|
||||
8 h / 23:00), so it can only ever raise time. `TUTOR_FROM` stays a far-future
|
||||
sentinel (2099-01-01) until kuhy confirms a real tutor session credits a
|
||||
block; it is then set to the day after, never the deploy day.
|
||||
|
||||
## API
|
||||
|
||||
- `resolve(answers, day=None, earners=EARNERS) -> Resolution` — the one sum.
|
||||
@ -59,7 +88,12 @@ ladder cannot move a gaming minute.
|
||||
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.
|
||||
verified rows count for `day`: a counted earner's units (a repeated
|
||||
`entry_id` counts once; `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
|
||||
test that patches it still registers its stand-in.
|
||||
- `shutdown_minutes_for(earner, day) -> int`,
|
||||
`extra_shutdown_minutes_for(earner, day) -> int`,
|
||||
`shutdown_ceiling_for(day) -> int`, `on_ladder(day) -> bool` — what a status
|
||||
@ -128,7 +162,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.4.0#subdirectory=earned_time"
|
||||
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.5.0#subdirectory=earned_time"
|
||||
```
|
||||
|
||||
The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports
|
||||
|
||||
@ -34,12 +34,16 @@ from earned_time._credits import (
|
||||
first_credit_at,
|
||||
)
|
||||
from earned_time._ladder import (
|
||||
ANKI_WAIVED_FROM,
|
||||
LADDER,
|
||||
LADDER_FROM,
|
||||
SHUTDOWN_CEILING_MINUTES,
|
||||
TUTOR_FROM,
|
||||
TUTOR_LADDER,
|
||||
WAKE_MINUTES,
|
||||
Rung,
|
||||
extra_shutdown_minutes_for,
|
||||
ladder_for,
|
||||
on_ladder,
|
||||
shutdown_ceiling_for,
|
||||
shutdown_minutes_for,
|
||||
@ -48,21 +52,26 @@ from earned_time._ledger import done_today, entry_signature, today_window, verif
|
||||
from earned_time._policy import (
|
||||
ANKI,
|
||||
AUTOMATION,
|
||||
AUTOMATION_TUTOR,
|
||||
EARNERS,
|
||||
GAMING_BASE_MINUTES,
|
||||
GAMING_CEILING_MINUTES,
|
||||
LEETCODE,
|
||||
READING,
|
||||
SHUTDOWN_BASE_MINUTES,
|
||||
TUTOR_EARNERS,
|
||||
WORKOUT,
|
||||
Earner,
|
||||
earner,
|
||||
)
|
||||
from earned_time._registry import all_earners, earners_for, registries
|
||||
from earned_time._resolve import Base, Resolution, Term, base_for, resolve
|
||||
|
||||
__all__ = [
|
||||
"ANKI",
|
||||
"ANKI_WAIVED_FROM",
|
||||
"AUTOMATION",
|
||||
"AUTOMATION_TUTOR",
|
||||
"EARNERS",
|
||||
"GAMING_BASE_MINUTES",
|
||||
"GAMING_CEILING_MINUTES",
|
||||
@ -72,6 +81,9 @@ __all__ = [
|
||||
"READING",
|
||||
"SHUTDOWN_BASE_MINUTES",
|
||||
"SHUTDOWN_CEILING_MINUTES",
|
||||
"TUTOR_EARNERS",
|
||||
"TUTOR_FROM",
|
||||
"TUTOR_LADDER",
|
||||
"WAKE_MINUTES",
|
||||
"WORKOUT",
|
||||
"Base",
|
||||
@ -79,15 +91,19 @@ __all__ = [
|
||||
"Resolution",
|
||||
"Rung",
|
||||
"Term",
|
||||
"all_earners",
|
||||
"base_for",
|
||||
"credit_units",
|
||||
"day_window",
|
||||
"done_today",
|
||||
"earner",
|
||||
"earners_for",
|
||||
"entry_signature",
|
||||
"extra_shutdown_minutes_for",
|
||||
"first_credit_at",
|
||||
"ladder_for",
|
||||
"on_ladder",
|
||||
"registries",
|
||||
"resolve",
|
||||
"shutdown_ceiling_for",
|
||||
"shutdown_minutes_for",
|
||||
|
||||
@ -96,7 +96,9 @@ def credit_units(earner: Earner, ledger: Path, key_file: Path, day: date) -> int
|
||||
|
||||
Returns:
|
||||
The count (``0`` included), or ``None`` when the ledger or key could
|
||||
not be read -- never "no units".
|
||||
not be read -- never "no units". A row repeated under the same
|
||||
``entry_id`` (a replayed write) counts once. The earner's
|
||||
``max_units`` is applied by :func:`~earned_time.resolve`, not here.
|
||||
|
||||
Raises:
|
||||
ValueError: ``earner`` has no ``match``; its answer is the consumer's.
|
||||
@ -104,4 +106,12 @@ 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
|
||||
return sum(1 for _ in rows)
|
||||
ids: set[object] = set()
|
||||
units = 0
|
||||
for row in rows:
|
||||
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
|
||||
|
||||
@ -7,6 +7,9 @@ doing everything lands exactly on the ceiling. Move the alarm and the whole
|
||||
ladder moves with it. Days before ``LADDER_FROM`` keep their own values and a
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -31,6 +34,17 @@ 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``). A far-future sentinel
|
||||
# until kuhy confirms a real tutor session earns a block; then the day after
|
||||
# that confirmation -- never the deploy day itself (a same-day cut, 2026-09-26).
|
||||
TUTOR_FROM: Final = date(2099, 1, 1)
|
||||
# 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
|
||||
# tutor that replaces them is not yet confirmed. Raise-only on every day (the
|
||||
# floor rises by exactly what the two could have paid back), so it may land
|
||||
# on its deploy day: 0.5.0 landed 2026-10-09.
|
||||
ANKI_WAIVED_FROM: Final = date(2026, 10, 9)
|
||||
_SLEEP_MINUTES: Final = 8 * 60
|
||||
_DAY_MINUTES: Final = 24 * 60
|
||||
|
||||
@ -41,11 +55,27 @@ class Rung:
|
||||
|
||||
Attributes:
|
||||
first: Shutdown minutes the first unit earns.
|
||||
extra: Each further unit (counted earners); the ladder pays none.
|
||||
extra: Each further unit (counted earners) once ``steps`` run out.
|
||||
steps: What the second, third, ... unit earn, in order, when they are
|
||||
not all the same (the tutor's 13/13/12/12 split).
|
||||
"""
|
||||
|
||||
first: int
|
||||
extra: int = 0
|
||||
steps: tuple[int, ...] = ()
|
||||
|
||||
@property
|
||||
def second(self) -> int:
|
||||
"""What the second unit earns."""
|
||||
return self.steps[0] if self.steps else self.extra
|
||||
|
||||
def minutes(self, units: int) -> int:
|
||||
"""Shutdown minutes ``units`` earn on this rung (``0`` for none)."""
|
||||
if units <= 0:
|
||||
return 0
|
||||
further = units - 1
|
||||
listed = sum(self.steps[:further])
|
||||
return self.first + listed + max(0, further - len(self.steps)) * self.extra
|
||||
|
||||
|
||||
# The rungs, keyed by earner name: they sum to the gap between the floor and
|
||||
@ -62,13 +92,33 @@ LADDER: Final[Mapping[str, Rung]] = MappingProxyType(
|
||||
)
|
||||
|
||||
|
||||
# From ``TUTOR_FROM``: Anki's 25 folded into Automation's, which is paid per
|
||||
# 15-minute tutor block. Still sums to 240, so doing everything is 23:00.
|
||||
TUTOR_LADDER: Final[Mapping[str, Rung]] = MappingProxyType(
|
||||
{
|
||||
"workout": Rung(110),
|
||||
"leetcode": Rung(50),
|
||||
"reading": Rung(30),
|
||||
"automation": Rung(13, steps=(13, 12, 12)),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def on_ladder(day: date) -> bool:
|
||||
"""Whether ``day`` resolves shutdown on the sleep ladder."""
|
||||
return day >= LADDER_FROM
|
||||
|
||||
|
||||
def ladder_for(day: date) -> Mapping[str, Rung] | None:
|
||||
"""The rungs in force on ``day``; ``None`` before the ladder."""
|
||||
if not on_ladder(day):
|
||||
return None
|
||||
return TUTOR_LADDER if day >= TUTOR_FROM else LADDER
|
||||
|
||||
|
||||
def _rung(item: Earner, day: date) -> Rung | None:
|
||||
return LADDER.get(item.name) if on_ladder(day) else None
|
||||
rungs = ladder_for(day)
|
||||
return None if rungs is None else rungs.get(item.name)
|
||||
|
||||
|
||||
def shutdown_minutes_for(item: Earner, day: date) -> int:
|
||||
@ -82,9 +132,19 @@ def shutdown_minutes_for(item: Earner, day: date) -> int:
|
||||
|
||||
|
||||
def extra_shutdown_minutes_for(item: Earner, day: date) -> int:
|
||||
"""What each of ``item``'s further units earns on ``day`` (counted only)."""
|
||||
"""What ``item``'s second unit earns on ``day`` (counted only)."""
|
||||
rung = _rung(item, day)
|
||||
return item.extra_shutdown_minutes if rung is None else rung.extra
|
||||
return item.extra_shutdown_minutes if rung is None else rung.second
|
||||
|
||||
|
||||
def shutdown_units_minutes(item: Earner, units: int, day: date) -> int:
|
||||
"""What ``units`` (already capped by the caller) earn on ``day``."""
|
||||
if units <= 0:
|
||||
return 0
|
||||
rung = _rung(item, day)
|
||||
if rung is not None:
|
||||
return rung.minutes(units)
|
||||
return item.shutdown_minutes + (units - 1) * item.extra_shutdown_minutes
|
||||
|
||||
|
||||
def shutdown_ceiling_for(day: date) -> int:
|
||||
|
||||
@ -114,6 +114,21 @@ 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``).
|
||||
|
||||
The Automation tutor writes one row per finished 15-minute block; a
|
||||
session that runs past midnight pays the day each block ended on.
|
||||
"""
|
||||
detail = row.get("detail")
|
||||
raw = detail.get("ended_at") if isinstance(detail, dict) else None
|
||||
landed = _within(raw, window)
|
||||
if landed is None:
|
||||
_logger.warning("tutor credit %r has no usable ended_at", row.get("entry_id"))
|
||||
return False
|
||||
return landed
|
||||
|
||||
|
||||
# The ``detail`` field each matcher decides on: the unix second the work
|
||||
# happened, which :func:`earned_time._credits.credit_time` reports. A matcher
|
||||
# without one (Anki's day key) falls back to the row's ``created_at``.
|
||||
@ -122,5 +137,6 @@ CREDIT_STAMPS: Final[Mapping[CreditMatch, str]] = MappingProxyType(
|
||||
leetcode_match: "submitted_at",
|
||||
reading_match: "ended_at",
|
||||
workout_match: "completed_at",
|
||||
tutor_match: "ended_at",
|
||||
}
|
||||
)
|
||||
|
||||
@ -16,6 +16,11 @@ unchanged: the base drops by what the new earner adds.
|
||||
|
||||
From ``LADDER_FROM`` shutdown follows the sleep ladder instead
|
||||
(:mod:`earned_time._ladder`); gaming keeps the penalty derivation unchanged.
|
||||
|
||||
**The registry is per day** (:func:`earners_for`). From ``TUTOR_FROM`` Anki
|
||||
is retired -- neither penalised nor paid -- and Automation is the tutor's
|
||||
counted earner; every earlier day resolves on :data:`EARNERS` exactly as
|
||||
before, so the cutover never rewrites an old day.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -24,11 +29,12 @@ from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime
|
||||
from typing import TYPE_CHECKING, Final, Literal
|
||||
|
||||
from earned_time._ladder import extra_shutdown_minutes_for, shutdown_minutes_for
|
||||
from earned_time._ladder import TUTOR_FROM, shutdown_units_minutes
|
||||
from earned_time._match import (
|
||||
anki_match,
|
||||
leetcode_match,
|
||||
reading_match,
|
||||
tutor_match,
|
||||
workout_match,
|
||||
)
|
||||
|
||||
@ -63,8 +69,12 @@ class Earner:
|
||||
``"counted"`` earns per unit (the workout), and its live credit is
|
||||
applied by the consumer that counts the units.
|
||||
extra_shutdown_minutes: Each further unit, for ``"counted"`` earners.
|
||||
penalty_from: From this day the base drops by ``gaming_minutes`` and
|
||||
``shutdown_minutes``. ``None`` means a pure bonus.
|
||||
extra_gaming_minutes: Gaming time each further unit earns
|
||||
(``"counted"``); the workout's are 0, the tutor's 15.
|
||||
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
|
||||
bonus.
|
||||
ledger: The gate's HMAC-signed ledger, relative to the home directory.
|
||||
``None`` means no shared reader: the consumer supplies the answer.
|
||||
match: Which verified ``credit`` rows count for today.
|
||||
@ -86,23 +96,34 @@ class Earner:
|
||||
ledger: str | None = None
|
||||
match: CreditMatch | None = None
|
||||
missing_ledger_is_no: bool = False
|
||||
extra_gaming_minutes: int = 0
|
||||
max_units: int | None = None
|
||||
|
||||
def capped(self, units: int) -> int:
|
||||
"""``units`` clamped to ``[0, max_units]``."""
|
||||
units = max(0, units)
|
||||
return units if self.max_units is None else min(units, self.max_units)
|
||||
|
||||
@property
|
||||
def max_gaming_minutes(self) -> int:
|
||||
"""Gaming time a fully done day earns: what the penalty takes off."""
|
||||
return self.gaming_for(self.max_units or 1)
|
||||
|
||||
def penalised_on(self, day: date) -> bool:
|
||||
"""Whether this earner's base cut is in force on ``day``."""
|
||||
return self.penalty_from is not None and day >= self.penalty_from
|
||||
|
||||
def gaming_for(self, units: int) -> int:
|
||||
"""Gaming minutes ``units`` earn: the first unit only, never more."""
|
||||
return self.gaming_minutes if units > 0 else 0
|
||||
"""Gaming minutes ``units`` earn, capped at ``max_units``."""
|
||||
count = self.capped(units)
|
||||
if count == 0:
|
||||
return 0
|
||||
return self.gaming_minutes + (count - 1) * self.extra_gaming_minutes
|
||||
|
||||
def shutdown_for(self, units: int, day: date | None = None) -> int:
|
||||
"""Shutdown minutes ``units`` earn on ``day`` (default today)."""
|
||||
if units <= 0:
|
||||
return 0
|
||||
target = day or datetime.now(tz=UTC).astimezone().date()
|
||||
return shutdown_minutes_for(self, target) + (units - 1) * (
|
||||
extra_shutdown_minutes_for(self, target)
|
||||
)
|
||||
return shutdown_units_minutes(self, self.capped(units), target)
|
||||
|
||||
|
||||
WORKOUT: Final = Earner(
|
||||
@ -148,6 +169,7 @@ ANKI: Final = Earner(
|
||||
)
|
||||
# Same gate, same row shape: anki-guard's ``automation`` quota (the Automation
|
||||
# deck only) writes its own ledger, so the Anki day matcher applies unchanged.
|
||||
# Days before ``TUTOR_FROM`` only; from then on see AUTOMATION_TUTOR.
|
||||
AUTOMATION: Final = Earner(
|
||||
name="automation",
|
||||
label="Automation",
|
||||
@ -159,7 +181,28 @@ AUTOMATION: Final = Earner(
|
||||
missing_ledger_is_no=True,
|
||||
)
|
||||
|
||||
# Order is the order of reason strings and of screen-locker's live pass.
|
||||
# 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.
|
||||
AUTOMATION_TUTOR: Final = Earner(
|
||||
name="automation",
|
||||
label="Automation",
|
||||
gaming_minutes=15,
|
||||
extra_gaming_minutes=15,
|
||||
max_units=4,
|
||||
# Pre-ladder values, unused (TUTOR_FROM is after LADDER_FROM); the ladder
|
||||
# pays 13/13/12/12 (``TUTOR_LADDER``).
|
||||
shutdown_minutes=13,
|
||||
extra_shutdown_minutes=13,
|
||||
kind="counted",
|
||||
penalty_from=TUTOR_FROM,
|
||||
ledger=".local/share/automation_tutor/ledger.json",
|
||||
match=tutor_match,
|
||||
missing_ledger_is_no=True,
|
||||
)
|
||||
|
||||
# The registry of every day before ``TUTOR_FROM``. Order is the order of
|
||||
# reason strings and of screen-locker's live pass.
|
||||
EARNERS: Final[tuple[Earner, ...]] = (
|
||||
WORKOUT,
|
||||
LEETCODE,
|
||||
@ -167,6 +210,13 @@ EARNERS: Final[tuple[Earner, ...]] = (
|
||||
ANKI,
|
||||
AUTOMATION,
|
||||
)
|
||||
# From ``TUTOR_FROM``: Anki retired, Automation paid per tutor block.
|
||||
TUTOR_EARNERS: Final[tuple[Earner, ...]] = (
|
||||
WORKOUT,
|
||||
LEETCODE,
|
||||
READING,
|
||||
AUTOMATION_TUTOR,
|
||||
)
|
||||
|
||||
|
||||
def earner(name: str, earners: tuple[Earner, ...] = EARNERS) -> Earner:
|
||||
|
||||
67
earned_time/earned_time/_registry.py
Normal file
67
earned_time/earned_time/_registry.py
Normal file
@ -0,0 +1,67 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Which registry is in force on a day: the tutor cutover (``TUTOR_FROM``).
|
||||
|
||||
Before ``TUTOR_FROM`` every day resolves on :data:`~earned_time.EARNERS`,
|
||||
exactly as it always has; from it on :data:`~earned_time.TUTOR_EARNERS`
|
||||
(Anki retired, Automation paid per tutor block). The switch is a date, so it
|
||||
never rewrites how an old day resolved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from functools import cache
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from earned_time._ladder import ANKI_WAIVED_FROM, TUTOR_FROM
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from earned_time._policy import Earner
|
||||
|
||||
# Waived from ``ANKI_WAIVED_FROM`` until ``TUTOR_FROM``.
|
||||
_WAIVED: Final = frozenset({"anki", "automation"})
|
||||
|
||||
|
||||
def registries() -> tuple[tuple[Earner, ...], ...]:
|
||||
"""Every registry, oldest first.
|
||||
|
||||
Read through the package, not this module: consumers' test suites patch
|
||||
``earned_time.EARNERS`` to register a stand-in earner, and the patch must
|
||||
keep reaching :func:`earners_for` and :func:`all_earners`.
|
||||
"""
|
||||
package = sys.modules["earned_time"] # loaded: it imports this module
|
||||
return (package.EARNERS, package.TUTOR_EARNERS)
|
||||
|
||||
|
||||
def earners_for(day: date | None = None) -> tuple[Earner, ...]:
|
||||
"""The registry in force on ``day`` (default today).
|
||||
|
||||
Between ``ANKI_WAIVED_FROM`` and ``TUTOR_FROM`` the old registry without
|
||||
Anki and the anki-backed Automation: neither penalised nor paid.
|
||||
"""
|
||||
target = day or datetime.now(tz=UTC).astimezone().date()
|
||||
before, tutor = registries()
|
||||
if target >= TUTOR_FROM:
|
||||
return tutor
|
||||
if target >= ANKI_WAIVED_FROM:
|
||||
return _without_waived(before)
|
||||
return before
|
||||
|
||||
|
||||
@cache
|
||||
def _without_waived(registry: tuple[Earner, ...]) -> tuple[Earner, ...]:
|
||||
"""``registry`` minus the waived earners; one stable tuple per registry."""
|
||||
return tuple(e for e in registry if e.name not in _WAIVED)
|
||||
|
||||
|
||||
def all_earners() -> tuple[Earner, ...]:
|
||||
"""Every earner of every registry, once each, oldest registry first.
|
||||
|
||||
What a reader that must not miss a ledger across a cutover watches
|
||||
(screen-locker's path unit).
|
||||
"""
|
||||
seen: dict[Earner, None] = {}
|
||||
for registry in registries():
|
||||
seen.update(dict.fromkeys(registry))
|
||||
return tuple(seen)
|
||||
@ -19,14 +19,14 @@ from datetime import UTC, date, datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from earned_time._ladder import on_ladder, shutdown_ceiling_for, shutdown_minutes_for
|
||||
from earned_time._ladder import on_ladder, shutdown_ceiling_for
|
||||
from earned_time._policy import (
|
||||
EARNERS,
|
||||
GAMING_BASE_MINUTES,
|
||||
GAMING_CEILING_MINUTES,
|
||||
SHUTDOWN_BASE_MINUTES,
|
||||
Earner,
|
||||
)
|
||||
from earned_time._registry import all_earners, earners_for
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
@ -104,30 +104,34 @@ def _today() -> date:
|
||||
def _shutdown_floor(day: date, earners: tuple[Earner, ...]) -> int:
|
||||
"""Shutdown of a day that earned nothing.
|
||||
|
||||
On the ladder: the ceiling minus every registered earner's first unit,
|
||||
so doing everything lands exactly on the ceiling. Before it: the old base
|
||||
minus the penalties in force.
|
||||
On the ladder: the ceiling minus the most every registered earner can
|
||||
pay (all of a capped earner's units), so doing everything lands exactly
|
||||
on the ceiling. Before it: the old base minus the penalties in force.
|
||||
"""
|
||||
if on_ladder(day):
|
||||
return shutdown_ceiling_for(day) - sum(
|
||||
shutdown_minutes_for(e, day) for e in earners
|
||||
e.shutdown_for(e.max_units or 1, day) for e in earners
|
||||
)
|
||||
penalised = [e for e in earners if e.penalised_on(day)]
|
||||
return SHUTDOWN_BASE_MINUTES - sum(e.shutdown_minutes for e in penalised)
|
||||
|
||||
|
||||
def base_for(day: date | None = None, earners: tuple[Earner, ...] = EARNERS) -> Base:
|
||||
"""The floor for ``day`` (default today).
|
||||
def base_for(
|
||||
day: date | None = None, earners: tuple[Earner, ...] | None = None
|
||||
) -> Base:
|
||||
"""The floor for ``day`` (default today) under ``earners`` (default its own).
|
||||
|
||||
Gaming: every penalty in force, taken off the gaming base. Shutdown: see
|
||||
:func:`_shutdown_floor` -- the two are derived separately so the ladder
|
||||
can never move a gaming minute.
|
||||
Gaming: every penalty in force, the most each earner can pay, taken off
|
||||
the gaming base. Shutdown: see :func:`_shutdown_floor` -- the two are
|
||||
derived separately so the ladder can never move a gaming minute.
|
||||
"""
|
||||
target = day or _today()
|
||||
penalised = [e for e in earners if e.penalised_on(target)]
|
||||
registry = earners_for(target) if earners is None else earners
|
||||
penalised = [e for e in registry if e.penalised_on(target)]
|
||||
return Base(
|
||||
gaming_minutes=GAMING_BASE_MINUTES - sum(e.gaming_minutes for e in penalised),
|
||||
shutdown_minutes=_shutdown_floor(target, earners),
|
||||
gaming_minutes=GAMING_BASE_MINUTES
|
||||
- sum(e.max_gaming_minutes for e in penalised),
|
||||
shutdown_minutes=_shutdown_floor(target, registry),
|
||||
)
|
||||
|
||||
|
||||
@ -140,7 +144,7 @@ def _units(answer: Answer) -> int | None:
|
||||
def resolve(
|
||||
answers: Mapping[str, Answer],
|
||||
day: date | None = None,
|
||||
earners: tuple[Earner, ...] = EARNERS,
|
||||
earners: tuple[Earner, ...] | None = None,
|
||||
) -> Resolution:
|
||||
"""Sum the base and every earned term, each capped at its ceiling.
|
||||
|
||||
@ -148,9 +152,11 @@ def resolve(
|
||||
answers: Per earner name, a unit count or ``None`` ("could not check").
|
||||
A registered earner missing from it earns nothing and is logged:
|
||||
the consumer forgot to ask, which must not pass as a "no".
|
||||
day: The day to resolve (default today): it picks the base, the
|
||||
ceiling, and (on the ladder) each earner's shutdown minutes.
|
||||
earners: The registry; tests pass their own.
|
||||
day: The day to resolve (default today): it picks the registry, the
|
||||
base, the ceiling, and (on the ladder) each earner's minutes.
|
||||
earners: The registry (default :func:`earners_for` the day); tests
|
||||
pass their own. An answer for an earner registered on other days
|
||||
only (Anki after ``TUTOR_FROM``) is ignored.
|
||||
|
||||
Returns:
|
||||
The resolution, with a term for every registered earner.
|
||||
@ -159,15 +165,16 @@ def resolve(
|
||||
KeyError: ``answers`` names an earner that is not registered -- a
|
||||
typo that would otherwise silently earn nothing.
|
||||
"""
|
||||
known = {e.name for e in earners}
|
||||
target = day or _today()
|
||||
registry = earners_for(target) if earners is None else earners
|
||||
known = {e.name for e in (*registry, *all_earners())}
|
||||
unknown = sorted(set(answers) - known)
|
||||
if unknown:
|
||||
msg = f"not registered earners: {', '.join(unknown)}"
|
||||
raise KeyError(msg)
|
||||
target = day or _today()
|
||||
base = base_for(target, earners)
|
||||
base = base_for(target, registry)
|
||||
terms: list[Term] = []
|
||||
for item in earners:
|
||||
for item in registry:
|
||||
if item.name not in answers:
|
||||
_logger.warning(
|
||||
"No answer for the %s earner; it earns nothing today", item.label
|
||||
|
||||
@ -36,12 +36,14 @@ NAMES = [e.name for e in EARNERS]
|
||||
|
||||
def _shutdown(day: date, **done: int) -> str:
|
||||
answers = {name: done.get(name, 0) for name in NAMES}
|
||||
minutes = resolve(answers, day).shutdown_minutes
|
||||
minutes = resolve(answers, day, EARNERS).shutdown_minutes
|
||||
return f"{minutes // 60:02d}:{minutes % 60:02d}"
|
||||
|
||||
|
||||
def _gaming(day: date, **done: int) -> int:
|
||||
return resolve({name: done.get(name, 0) for name in NAMES}, day).gaming_minutes
|
||||
return resolve(
|
||||
{name: done.get(name, 0) for name in NAMES}, day, EARNERS
|
||||
).gaming_minutes
|
||||
|
||||
|
||||
def test_switch_date_and_wake_anchor() -> None:
|
||||
@ -61,9 +63,9 @@ def test_ceiling_follows_the_alarm_but_old_days_stay(
|
||||
) -> None:
|
||||
monkeypatch.setattr(ladder, "WAKE_MINUTES", 6 * 60)
|
||||
assert shutdown_ceiling_for(FIRST) == 22 * 60
|
||||
assert base_for(FIRST).shutdown_minutes == 18 * 60
|
||||
assert base_for(FIRST, EARNERS).shutdown_minutes == 18 * 60
|
||||
assert shutdown_ceiling_for(EVE) == 23 * 60
|
||||
assert base_for(EVE).shutdown_minutes == 18 * 60
|
||||
assert base_for(EVE, EARNERS).shutdown_minutes == 18 * 60
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@ -89,7 +91,9 @@ def test_every_earner_has_a_rung() -> None:
|
||||
def test_first_units_fill_the_gap_exactly() -> None:
|
||||
total = sum(shutdown_minutes_for(e, FIRST) for e in EARNERS)
|
||||
assert total == 240
|
||||
assert base_for(FIRST).shutdown_minutes == shutdown_ceiling_for(FIRST) - total
|
||||
assert (
|
||||
base_for(FIRST, EARNERS).shutdown_minutes == shutdown_ceiling_for(FIRST) - total
|
||||
)
|
||||
|
||||
|
||||
def test_extra_workouts_earn_nothing_on_the_ladder() -> None:
|
||||
@ -136,4 +140,8 @@ def test_the_eve_resolves_the_old_way() -> None:
|
||||
@pytest.mark.parametrize("done", [{}, {"workout": 2}, dict.fromkeys(NAMES, 1)])
|
||||
def test_gaming_is_the_same_on_both_sides_of_the_switch(done: dict[str, int]) -> None:
|
||||
assert _gaming(EVE, **done) == _gaming(FIRST, **done)
|
||||
assert base_for(EVE).gaming_minutes == base_for(FIRST).gaming_minutes == 180
|
||||
assert (
|
||||
base_for(EVE, EARNERS).gaming_minutes
|
||||
== base_for(FIRST, EARNERS).gaming_minutes
|
||||
== 180
|
||||
)
|
||||
|
||||
146
earned_time/earned_time/tests/test_tutor.py
Normal file
146
earned_time/earned_time/tests/test_tutor.py
Normal file
@ -0,0 +1,146 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The tutor cutover: Anki retired, Automation paid per 15-minute block."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from earned_time import (
|
||||
AUTOMATION,
|
||||
AUTOMATION_TUTOR,
|
||||
EARNERS,
|
||||
LADDER,
|
||||
TUTOR_EARNERS,
|
||||
TUTOR_FROM,
|
||||
TUTOR_LADDER,
|
||||
WORKOUT,
|
||||
Rung,
|
||||
all_earners,
|
||||
base_for,
|
||||
earners_for,
|
||||
extra_shutdown_minutes_for,
|
||||
ladder_for,
|
||||
resolve,
|
||||
)
|
||||
|
||||
EVE = TUTOR_FROM - timedelta(days=1)
|
||||
OTHERS = {"workout": 1, "leetcode": 1, "reading": 1}
|
||||
|
||||
|
||||
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)
|
||||
return day.gaming_minutes, _hhmm(day.shutdown_minutes)
|
||||
|
||||
|
||||
def test_tutor_from_is_after_the_ladder_and_not_today() -> None:
|
||||
assert date(2026, 10, 10) < TUTOR_FROM
|
||||
assert date(2026, 10, 9) < TUTOR_FROM # never the day it was prepared
|
||||
|
||||
|
||||
def test_registry_switches_on_tutor_from() -> None:
|
||||
assert earners_for(EVE) is not TUTOR_EARNERS # the waiver (ANKI_WAIVED_FROM)
|
||||
assert earners_for(None) is earners_for(datetime.now().astimezone().date())
|
||||
assert earners_for(TUTOR_FROM) is TUTOR_EARNERS
|
||||
assert "anki" not in {e.name for e in TUTOR_EARNERS}
|
||||
assert ladder_for(EVE) is LADDER
|
||||
assert ladder_for(TUTOR_FROM) is TUTOR_LADDER
|
||||
assert ladder_for(date(2026, 10, 1)) is None
|
||||
|
||||
|
||||
def test_all_earners_lists_each_once() -> None:
|
||||
names = [e.name for e in all_earners()]
|
||||
assert names.count("workout") == 1
|
||||
assert AUTOMATION in all_earners()
|
||||
assert AUTOMATION_TUTOR in all_earners()
|
||||
|
||||
|
||||
def test_tutor_ladder_still_sums_to_the_old_one() -> None:
|
||||
old = sum(r.minutes(4) for r in LADDER.values())
|
||||
new = sum(r.minutes(4) for r in TUTOR_LADDER.values())
|
||||
assert old == new == 240
|
||||
assert TUTOR_LADDER["automation"].minutes(4) == 50
|
||||
|
||||
|
||||
def test_nothing_done_is_three_hours_and_19() -> None:
|
||||
assert _day(0) == (180, "19:00")
|
||||
assert base_for(TUTOR_FROM).gaming_minutes == 180
|
||||
|
||||
|
||||
def test_everything_but_the_tutor() -> None:
|
||||
assert _day(0, **OTHERS) == (420, "22:10")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("blocks", "gaming", "shutdown"),
|
||||
[(1, 435, "22:23"), (2, 450, "22:36"), (3, 465, "22:48"), (4, 480, "23:00")],
|
||||
)
|
||||
def test_each_block_pays_15_gaming_and_its_rung(
|
||||
blocks: int, gaming: int, shutdown: str
|
||||
) -> None:
|
||||
assert _day(blocks, **OTHERS) == (gaming, shutdown)
|
||||
|
||||
|
||||
def test_partial_credit_without_the_others() -> None:
|
||||
assert _day(1) == (195, "19:13")
|
||||
assert _day(2) == (210, "19:26")
|
||||
assert _day(4) == (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, 50)
|
||||
|
||||
|
||||
def test_anki_is_neither_penalised_nor_paid_from_tutor_from() -> None:
|
||||
with_anki = resolve({**OTHERS, "anki": 1, "automation": 4}, 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}
|
||||
|
||||
|
||||
def test_the_eve_resolves_as_before() -> None:
|
||||
eve = resolve({**OTHERS, "anki": 0, "automation": 0}, EVE, EARNERS)
|
||||
assert eve.base.gaming_minutes == 180
|
||||
assert (eve.gaming_minutes, _hhmm(eve.shutdown_minutes)) == (420, "22:10")
|
||||
done = resolve({**OTHERS, "anki": 1, "automation": 1}, EVE, EARNERS)
|
||||
assert (done.gaming_minutes, _hhmm(done.shutdown_minutes)) == (480, "23:00")
|
||||
assert done.term("automation").earner is AUTOMATION
|
||||
|
||||
|
||||
def test_unknown_names_still_raise() -> None:
|
||||
with pytest.raises(KeyError, match="piano"):
|
||||
resolve({"piano": 1}, TUTOR_FROM)
|
||||
|
||||
|
||||
def test_rung_steps_and_extras() -> None:
|
||||
rung = Rung(13, steps=(13, 12, 12))
|
||||
assert [rung.minutes(n) for n in range(6)] == [0, 13, 26, 38, 50, 50]
|
||||
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) == 13
|
||||
|
||||
|
||||
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,
|
||||
]
|
||||
assert AUTOMATION_TUTOR.max_gaming_minutes == 60
|
||||
assert WORKOUT.max_gaming_minutes == WORKOUT.gaming_minutes
|
||||
assert WORKOUT.gaming_for(3) == WORKOUT.gaming_minutes
|
||||
|
||||
|
||||
def test_tutor_shutdown_before_the_ladder_uses_its_own_fields() -> None:
|
||||
old = date(2026, 10, 1)
|
||||
assert AUTOMATION_TUTOR.shutdown_for(2, old) == 26
|
||||
assert AUTOMATION_TUTOR.shutdown_for(0, old) == 0
|
||||
111
earned_time/earned_time/tests/test_tutor_ledger.py
Normal file
111
earned_time/earned_time/tests/test_tutor_ledger.py
Normal file
@ -0,0 +1,111 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The tutor ledger: which signed block rows count, and how many."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from earned_time import (
|
||||
AUTOMATION_TUTOR,
|
||||
credit_units,
|
||||
day_window,
|
||||
entry_signature,
|
||||
first_credit_at,
|
||||
)
|
||||
from earned_time._match import tutor_match
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
KEY = b"test-key"
|
||||
DAY = date(2026, 10, 12)
|
||||
WINDOW = day_window(DAY)
|
||||
|
||||
|
||||
def _at(hour: int, minute: int = 0, day: date = DAY) -> float:
|
||||
return datetime.combine(day, time(hour, minute)).astimezone().timestamp()
|
||||
|
||||
|
||||
def block(session: str, number: int, ended: object) -> dict[str, object]:
|
||||
"""One signed row in the contract the tutor writes."""
|
||||
row: dict[str, object] = {
|
||||
"kind": "credit",
|
||||
"entry_id": f"{session}-b{number}",
|
||||
"day": DAY.isoformat(),
|
||||
"created_at": _at(23, 59),
|
||||
"detail": {
|
||||
"session_id": session,
|
||||
"block": number,
|
||||
"active_seconds": 900,
|
||||
"ended_at": ended,
|
||||
"checks_passed": 3,
|
||||
"checks_total": 3,
|
||||
},
|
||||
}
|
||||
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 _ledger(tmp_path: Path, rows: list[object]) -> Path:
|
||||
path = tmp_path / "ledger.json"
|
||||
path.write_text(json.dumps({"entries": rows}), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_match_by_ended_at() -> None:
|
||||
assert tutor_match(block("s", 1, _at(18, 15)), WINDOW)
|
||||
yesterday = _at(23, 50, DAY - timedelta(days=1))
|
||||
assert not tutor_match(block("s", 1, yesterday), WINDOW)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ended", [None, "soon"])
|
||||
def test_unusable_ended_at_never_counts(
|
||||
ended: object, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
assert not tutor_match(block("s", 1, ended), WINDOW)
|
||||
assert "no usable ended_at" in caplog.text
|
||||
|
||||
|
||||
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(
|
||||
tmp_path: Path, key_file: Path
|
||||
) -> None:
|
||||
tampered = block("s1", 3, _at(19))
|
||||
tampered["detail"] = {**tampered["detail"], "active_seconds": 9000} # type: ignore[dict-item]
|
||||
rows: list[object] = [
|
||||
block("s1", 1, _at(18, 15)),
|
||||
block("s1", 2, _at(18, 30)),
|
||||
block("s1", 2, _at(18, 30)), # the same block written twice
|
||||
tampered,
|
||||
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
|
||||
assert first_credit_at(AUTOMATION_TUTOR, path, key_file, DAY) == _at(18, 15)
|
||||
|
||||
|
||||
def test_rows_without_entry_id_each_count(tmp_path: Path, key_file: Path) -> None:
|
||||
rows: list[object] = []
|
||||
for number in (1, 2):
|
||||
row = {k: v for k, v in block("s", number, _at(9)).items() if k != "hmac"}
|
||||
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
|
||||
|
||||
|
||||
def test_missing_tutor_ledger_is_zero(tmp_path: Path, key_file: Path) -> None:
|
||||
assert credit_units(AUTOMATION_TUTOR, tmp_path / "no.json", key_file, DAY) == 0
|
||||
67
earned_time/earned_time/tests/test_waiver.py
Normal file
67
earned_time/earned_time/tests/test_waiver.py
Normal file
@ -0,0 +1,67 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The gap-day waiver: Anki and the old Automation, from ANKI_WAIVED_FROM."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from earned_time import (
|
||||
ANKI_WAIVED_FROM,
|
||||
EARNERS,
|
||||
LADDER_FROM,
|
||||
TUTOR_EARNERS,
|
||||
TUTOR_FROM,
|
||||
earners_for,
|
||||
resolve,
|
||||
)
|
||||
|
||||
BEFORE = ANKI_WAIVED_FROM - timedelta(days=1)
|
||||
NAMES = ("workout", "leetcode", "reading", "anki", "automation")
|
||||
|
||||
|
||||
def test_the_waiver_sits_between_the_old_registry_and_the_tutor() -> None:
|
||||
assert BEFORE < ANKI_WAIVED_FROM < TUTOR_FROM
|
||||
assert earners_for(BEFORE) is EARNERS
|
||||
assert [e.name for e in earners_for(ANKI_WAIVED_FROM)] == list(NAMES[:3])
|
||||
assert earners_for(TUTOR_FROM) is TUTOR_EARNERS
|
||||
|
||||
|
||||
def test_nothing_done_is_raised_by_exactly_what_the_two_paid() -> None:
|
||||
zero = dict.fromkeys(NAMES, 0)
|
||||
# The first ladder day inside the waiver (the waiver began pre-ladder).
|
||||
assert ANKI_WAIVED_FROM <= LADDER_FROM < TUTOR_FROM
|
||||
gap = resolve(zero, LADDER_FROM)
|
||||
assert gap.base.gaming_minutes == 240
|
||||
assert gap.gaming_minutes == 240
|
||||
assert gap.shutdown_minutes == 23 * 60 - 190 # 19:50: workout+leetcode+reading
|
||||
|
||||
|
||||
@pytest.mark.parametrize("workout", [0, 1])
|
||||
@pytest.mark.parametrize("leetcode", [0, 1])
|
||||
@pytest.mark.parametrize("reading", [0, 1])
|
||||
@pytest.mark.parametrize("anki", [0, 1])
|
||||
def test_raise_only_on_every_combination(
|
||||
workout: int, leetcode: int, reading: int, anki: int
|
||||
) -> None:
|
||||
answers = {
|
||||
"workout": workout,
|
||||
"leetcode": leetcode,
|
||||
"reading": reading,
|
||||
"anki": anki,
|
||||
"automation": anki,
|
||||
}
|
||||
day = ANKI_WAIVED_FROM
|
||||
waived = resolve(answers, day)
|
||||
unwaived = resolve(answers, day, EARNERS)
|
||||
assert waived.gaming_minutes >= unwaived.gaming_minutes
|
||||
assert waived.shutdown_minutes >= unwaived.shutdown_minutes
|
||||
if anki:
|
||||
assert waived.gaming_minutes == unwaived.gaming_minutes
|
||||
assert waived.shutdown_minutes == unwaived.shutdown_minutes
|
||||
|
||||
|
||||
def test_all_done_still_8h_and_23() -> None:
|
||||
done = resolve(dict.fromkeys(NAMES, 1), ANKI_WAIVED_FROM)
|
||||
assert (done.gaming_minutes, done.shutdown_minutes) == (480, 23 * 60)
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "earned-time"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = []
|
||||
|
||||
2
earned_time/scripts/__init__.py
Normal file
2
earned_time/scripts/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Runnable demos of the registry; not part of the installed package."""
|
||||
123
earned_time/scripts/demo_tutor_cutover.py
Normal file
123
earned_time/scripts/demo_tutor_cutover.py
Normal file
@ -0,0 +1,123 @@
|
||||
# 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,
|
||||
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
|
||||
blocks and a tampered row. Touches no real ledger, key or schedule.
|
||||
|
||||
Run: ``PYTHONPATH=earned_time python3 earned_time/scripts/demo_tutor_cutover.py``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import earned_time
|
||||
from earned_time import AUTOMATION_TUTOR, TUTOR_FROM, credit_units, entry_signature
|
||||
|
||||
_KEY = b"demo-key-not-the-real-one"
|
||||
_OTHERS = {"workout": 1, "leetcode": 1, "reading": 1}
|
||||
_NONE = {"workout": 0, "leetcode": 0, "reading": 0}
|
||||
|
||||
|
||||
def _say(line: str) -> None:
|
||||
sys.stdout.write(line + "\n")
|
||||
|
||||
|
||||
def _hhmm(minutes: int) -> str:
|
||||
return f"{minutes // 60:02d}:{minutes % 60:02d}"
|
||||
|
||||
|
||||
def _block(session: str, number: int, day: date) -> dict[str, object]:
|
||||
start = datetime.combine(day, time(18)).astimezone()
|
||||
ended = (start + timedelta(minutes=15 * number)).timestamp()
|
||||
row: dict[str, object] = {
|
||||
"kind": "credit",
|
||||
"entry_id": f"{session}-b{number}",
|
||||
"day": day.isoformat(),
|
||||
"created_at": ended + 1,
|
||||
"detail": {
|
||||
"session_id": session,
|
||||
"block": number,
|
||||
"active_seconds": 900,
|
||||
"ended_at": ended,
|
||||
"checks_passed": 3,
|
||||
"checks_total": 3,
|
||||
},
|
||||
}
|
||||
return {**row, "hmac": entry_signature(row, _KEY)}
|
||||
|
||||
|
||||
def _units(folder: Path, rows: list[dict[str, object]], day: date) -> int:
|
||||
ledger = folder / "ledger.json"
|
||||
ledger.write_text(json.dumps({"entries": rows}), encoding="utf-8")
|
||||
key = folder / "hmac.key"
|
||||
key.write_bytes(_KEY)
|
||||
units = credit_units(AUTOMATION_TUTOR, ledger, key, day)
|
||||
if units is None:
|
||||
msg = "demo ledger unreadable"
|
||||
raise RuntimeError(msg)
|
||||
return units
|
||||
|
||||
|
||||
def _line(label: str, answers: dict[str, int], day: date) -> str:
|
||||
res = earned_time.resolve(answers, day)
|
||||
names = {t.earner.name for t in res.terms}
|
||||
if "automation" in names:
|
||||
term = res.term("automation")
|
||||
paid = f"automation +{term.gaming_minutes}g/+{term.shutdown_minutes}s"
|
||||
else:
|
||||
paid = "automation waived" # ANKI_WAIVED_FROM..TUTOR_FROM
|
||||
return (
|
||||
f"{label:<44} {res.gaming_minutes:>4} min ({res.gaming_minutes / 60:.2f} h)"
|
||||
f" {_hhmm(res.shutdown_minutes)}"
|
||||
f" [{paid},"
|
||||
f" base {res.base.gaming_minutes}g/{_hhmm(res.base.shutdown_minutes)}]"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Print the table."""
|
||||
eve = TUTOR_FROM - timedelta(days=1)
|
||||
_say(f"TUTOR_FROM = {TUTOR_FROM} (placeholder; set at go-live)")
|
||||
_say(f"{'case':<44} {'gaming':>15} shutdown")
|
||||
for done in (0, 1):
|
||||
answers = {**_OTHERS, "anki": done, "automation": done}
|
||||
_say(_line(f"{eve} eve: others + anki/automation={done}", answers, eve))
|
||||
nothing = {**_NONE, "anki": 0, "automation": 0}
|
||||
_say(_line(f"{eve} eve: nothing done", nothing, eve))
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
folder = Path(raw)
|
||||
for blocks in (0, 1, 2, 4, 5):
|
||||
rows = [_block("s1", n, TUTOR_FROM) for n in range(1, blocks + 1)]
|
||||
units = _units(folder, rows, TUTOR_FROM)
|
||||
for others, tag in ((_OTHERS, "+ all others"), (_NONE, "tutor only")):
|
||||
answers = {**others, "automation": units}
|
||||
label = f"{TUTOR_FROM}: {blocks} rows -> {units} units, {tag}"
|
||||
_say(_line(label, answers, TUTOR_FROM))
|
||||
rows = [_block("s2", n, TUTOR_FROM) for n in range(1, 5)]
|
||||
detail = rows[2]["detail"]
|
||||
if not isinstance(detail, dict):
|
||||
msg = "demo row lost its detail"
|
||||
raise TypeError(msg)
|
||||
rows[2]["detail"] = {**detail, "active_seconds": 9000} # edited after signing
|
||||
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}
|
||||
_say(
|
||||
_line(
|
||||
f"{TUTOR_FROM}: all + a stale anki answer (ignored)", anki, TUTOR_FROM
|
||||
)
|
||||
)
|
||||
_say(f"gaming ceiling {earned_time.GAMING_CEILING_MINUTES} min (8 h)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue
Block a user