From 20b2cdbc04e2c66f983add402af91f71e8ed9052 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 23 Oct 2026 11:25:00 +0200 Subject: [PATCH] earned_time 0.17.0: a LeetCode day paid from banked credits earns the bonus Co-Authored-By: Claude Opus 5.5 (1M context) --- earned_time/README.md | 4 +- earned_time/earned_time/_ledger.py | 11 ++- earned_time/earned_time/_match.py | 19 ++++ .../tests/test_settled_by_credit.py | 97 +++++++++++++++++++ earned_time/pyproject.toml | 2 +- 5 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 earned_time/earned_time/tests/test_settled_by_credit.py diff --git a/earned_time/README.md b/earned_time/README.md index 2c1dc36..ffdb50a 100644 --- a/earned_time/README.md +++ b/earned_time/README.md @@ -10,7 +10,7 @@ gaming time and shutdown time, read by both consumers. A gate (leetcode-guard, book-guard, anki-guard, screen-locker's workout credit, ...) never touches either. It publishes a fact — HMAC-signed `credit` -rows in its ledger — and is registered here once. +rows in its ledger — and is registered here once. LeetCode also counts its signed `source == "leetcode"` `charge` for the day (0.17.0, `ROW_KINDS`): a day paid ahead from banked credits earns like a solve; escape and outage charges never do. ## The model @@ -243,7 +243,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.16.0#subdirectory=earned_time" +pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.17.0#subdirectory=earned_time" ``` The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports diff --git a/earned_time/earned_time/_ledger.py b/earned_time/earned_time/_ledger.py index a6220f1..d9fedba 100644 --- a/earned_time/earned_time/_ledger.py +++ b/earned_time/earned_time/_ledger.py @@ -35,9 +35,11 @@ if TYPE_CHECKING: from earned_time._policy import Earner, Window +from earned_time._match import ROW_KINDS + _logger: Final = logging.getLogger(__name__) -_CREDIT: Final = "credit" +_CREDIT_ONLY: Final = frozenset({"credit"}) def entry_signature(entry: dict[str, object], key: bytes) -> str: @@ -119,7 +121,9 @@ def read_rows(ledger: Path, earner: Earner) -> list[object] | None: def counting_rows( earner: Earner, ledger: Path, key_file: Path, window: Window ) -> Iterator[dict[str, object]] | None: - """The verified ``credit`` rows that ``earner.match`` counts in ``window``. + """The verified rows that ``earner.match`` counts in ``window``. + + ``credit`` rows, plus whatever else ``ROW_KINDS`` shows that matcher. ``None`` when the key or the ledger cannot be read. @@ -136,11 +140,12 @@ def counting_rows( if rows is None: return None match = earner.match + kinds = ROW_KINDS.get(match, _CREDIT_ONLY) return ( row for row in rows if isinstance(row, dict) - and row.get("kind") == _CREDIT + and row.get("kind") in kinds and verified(row, key) and match(row, window) ) diff --git a/earned_time/earned_time/_match.py b/earned_time/earned_time/_match.py index dbb71f9..42a599d 100644 --- a/earned_time/earned_time/_match.py +++ b/earned_time/earned_time/_match.py @@ -37,8 +37,21 @@ def leetcode_match(row: dict[str, object], window: Window) -> bool: ``day`` is the *harvesting* run's date and runs late, so it is only the fallback for a row without a usable stamp: it can miss a late solve but never invent one. + + A day paid ahead from banked credits counts too (0.17.0, kuhy 2026-10-23: + working ahead must not forfeit the bonus). leetcode-guard writes a + ``charge`` with ``source == "leetcode"`` only when verified credits cover + the day; its escape hatch and outage path write other sources and pay + nothing. The charge counts for the day it names. """ detail = row.get("detail") + if row.get("kind") == "charge": + local = datetime.fromtimestamp(window[0], tz=UTC).astimezone().date() + return ( + isinstance(detail, dict) + and detail.get("source") == "leetcode" + and row.get("day") == local.isoformat() + ) raw = detail.get("submitted_at") if isinstance(detail, dict) else None if raw is not None: landed = _within(raw, window) @@ -172,3 +185,9 @@ CREDIT_STAMPS: Final[Mapping[CreditMatch, str]] = MappingProxyType( ROW_UNITS: Final[Mapping[CreditMatch, Callable[[dict[str, object]], int | None]]] = ( MappingProxyType({tutor_match: tutor_minutes}) ) +# Which ledger row kinds each matcher is shown; a matcher without an entry +# sees only ``credit`` rows. LeetCode also sees ``charge`` rows, so a day +# settled from banked credits counts (``leetcode_match``). +ROW_KINDS: Final[Mapping[CreditMatch, frozenset[str]]] = MappingProxyType( + {leetcode_match: frozenset({"credit", "charge"})} +) diff --git a/earned_time/earned_time/tests/test_settled_by_credit.py b/earned_time/earned_time/tests/test_settled_by_credit.py new file mode 100644 index 0000000..33527b4 --- /dev/null +++ b/earned_time/earned_time/tests/test_settled_by_credit.py @@ -0,0 +1,97 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""A LeetCode day settled from banked credits earns the bonus like a solve. + +kuhy, 2026-10-23: five solves on 10-22 paid 10-23's gate in advance, and the +bonus (gaming, leisure, shutdown) still said "no solve today" -- the system +punished working ahead. A signed ``charge`` with ``source == "leetcode"`` is +written only when verified credits cover the day (leetcode-guard +``_gate.decide``); the escape hatch and the outage path write their own +sources and must never pay. +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +import json +from typing import TYPE_CHECKING + +import pytest + +from earned_time import ( + LEETCODE, + READING, + done_today, + entry_signature, + first_credit_at, +) + +if TYPE_CHECKING: + from pathlib import Path + +KEY = b"test-key" +NOON = datetime(2026, 10, 23, 12, tzinfo=UTC) +TODAY = NOON.astimezone().date() +CHARGED_AT = "2026-10-23T09:00:00+00:00" + + +def _charge(day: str, source: str = "leetcode") -> dict[str, object]: + row: dict[str, object] = { + "entry_id": f"charge:{day}", + "kind": "charge", + "day": day, + "created_at": CHARGED_AT, + "amount": 4, + "detail": {"source": source}, + } + 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_a_day_paid_from_banked_credits_counts(tmp_path: Path, key_file: Path) -> None: + path = _ledger(tmp_path, [_charge(TODAY.isoformat())]) + assert done_today(LEETCODE, path, key_file, now=NOON) is True + + +@pytest.mark.parametrize("source", ["escape", "leetcode-outage", "seed"]) +def test_a_forgiven_day_never_counts( + tmp_path: Path, key_file: Path, source: str +) -> None: + path = _ledger(tmp_path, [_charge(TODAY.isoformat(), source)]) + assert done_today(LEETCODE, path, key_file, now=NOON) is False + + +def test_a_forged_charge_never_counts(tmp_path: Path, key_file: Path) -> None: + forged = {**_charge(TODAY.isoformat()), "hmac": "forged"} + path = _ledger(tmp_path, [forged]) + assert done_today(LEETCODE, path, key_file, now=NOON) is False + + +def test_yesterdays_charge_pays_yesterday_only(tmp_path: Path, key_file: Path) -> None: + path = _ledger(tmp_path, [_charge("2026-10-22")]) + assert done_today(LEETCODE, path, key_file, now=NOON) is False + + +def test_only_leetcode_reads_charges(tmp_path: Path, key_file: Path) -> None: + path = _ledger(tmp_path, [_charge(TODAY.isoformat())]) + assert done_today(READING, path, key_file, now=NOON) is False + + +def test_the_settling_charge_is_timed_at_its_writing( + tmp_path: Path, key_file: Path +) -> None: + path = _ledger(tmp_path, [_charge(TODAY.isoformat())]) + expected = datetime.fromisoformat(CHARGED_AT).timestamp() + assert first_credit_at(LEETCODE, path, key_file, TODAY) == expected + assert first_credit_at(LEETCODE, path, key_file, date(2026, 10, 22)) is None diff --git a/earned_time/pyproject.toml b/earned_time/pyproject.toml index bc1b584..94184e6 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.16.0" +version = "0.17.0" description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time" requires-python = ">=3.14" dependencies = []