earned_time 0.17.0: a LeetCode day paid from banked credits earns the bonus
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-23 11:25:00 +02:00
parent e74aa7b0ea
commit 20b2cdbc04
5 changed files with 127 additions and 6 deletions

View File

@ -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

View File

@ -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)
)

View File

@ -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"})}
)

View File

@ -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

View File

@ -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 = []