utils/earned_time/scripts/demo_tutor_cutover.py
Krzysztof kuhy Rudnicki bce1f6d0cc 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
2026-10-10 15:47:15 +02:00

125 lines
4.6 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""Demo: what the tutor cutover pays, read from a signed temp ledger.
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
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} (no first_credits: an unwired consumer)")
_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": 60}
_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()