earned_time 0.6.0: maturity-aware penalty start (first_credits); grace floor covers every gate earner
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (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-09 21:55:21 +02:00
parent bbc14ec04c
commit 215f03ecfc
8 changed files with 359 additions and 18 deletions

View File

@ -16,18 +16,16 @@ midnight, so a 30-minute earner is applied exactly.
from __future__ import annotations
from datetime import datetime
from datetime import date, datetime
import functools
import logging
from pathlib import Path
from typing import TYPE_CHECKING, Final
from typing import Final
import earned_time
from screen_locker._constants import HMAC_KEY_FILE
if TYPE_CHECKING:
from datetime import date
from screen_locker._day import today_str
_logger: Final = logging.getLogger(__name__)
@ -86,6 +84,11 @@ def flat_earners(day: date | None = None) -> tuple[earned_time.Earner, ...]:
return tuple(e for e in registry(day) if e.kind == "flat" and is_gate(e))
def gate_earners(day: date | None = None) -> tuple[earned_time.Earner, ...]:
"""Every gate earner in force on ``day``, flat and counted, in registry order."""
return tuple(e for e in registry(day) if is_gate(e))
def counted_gate_earners(day: date | None = None) -> tuple[earned_time.Earner, ...]:
"""Gate earners paid per unit on ``day`` (the Automation tutor's blocks)."""
return tuple(e for e in registry(day) if e.kind == "counted" and is_gate(e))
@ -187,3 +190,42 @@ def _warn_no_first_credit() -> None:
"earned_time has no first_credit_at (needs >= 0.4); the grace floor "
"sees no ledger earner, only the workout log"
)
def first_credits(
earners: tuple[earned_time.Earner, ...], day: date
) -> dict[str, date | None] | None:
"""Each penalised ledger earner's first real credit up to ``day``, for resolve.
``earned_time.resolve`` / ``base_for`` take it as ``first_credits=`` (>= 0.6)
and then start a penalty no earlier than the day after the gate first
paid out. Keyed by name over ``earners`` -- the registry the answers came
from, so the two ``automation`` earners (either side of ``TUTOR_FROM``)
never collide. An earner without ``penalty_from``, a ledger or a matcher
is left out (``resolve`` falls back to its ``confirmed_on``).
Returns:
The map; ``None`` for a day before today -- history keeps the shutdown
it was enforced at, never re-resolved -- and on an earned_time
without ``maturity`` (< 0.6, logged), where callers resolve as before.
"""
if day < date.fromisoformat(today_str()):
return None
fn = getattr(earned_time, "maturity", None)
if fn is None:
_warn_no_maturity()
return None
return {
e.name: fn(e, ledger_file(e), HMAC_KEY_FILE, day).first_credit
for e in earners
if e.penalty_from is not None and e.ledger is not None and e.match is not None
}
@functools.cache
def _warn_no_maturity() -> None:
"""Once per process: the installed earned_time predates maturity."""
_logger.warning(
"earned_time has no maturity (needs >= 0.6); every penalty starts on "
"its penalty_from, even for a gate that never paid out"
)

View File

@ -2,10 +2,11 @@
Someone who gets home at 18:10 and finishes their first task at 18:55 is not
cut off at 19:00. The floor is ``first done + GRACE_MINUTES``, capped at the
day's ceiling. "First done" is the earliest of every ledger earner's first
verified credit (``earned_time.first_credit_at``) and the workout's own
completion time -- the end of the run or walk, never the moment a sync
happened to log it, so a late upload does not move the floor later.
day's ceiling. "First done" is the earliest of every gate earner's first
verified credit, flat or counted (``earned_time.first_credit_at``), and the
workout's own completion time -- the end of the run or walk, never the
moment a sync happened to log it, so a late upload does not move the floor
later.
**A floor, not a bonus.** The config is otherwise read-add-write: every
earner adds its minutes to whatever is there. Lifting it to the floor and
@ -30,7 +31,7 @@ import earned_time
from screen_locker._bonus_lock import bonus_lock
from screen_locker._constants import GRACE_MINUTES, GRACE_STATE_FILE
from screen_locker._day import today_str
from screen_locker._earned import ceiling, first_credit_time, flat_earners, hhmm
from screen_locker._earned import ceiling, first_credit_time, gate_earners, hhmm
from screen_locker._log_io import load_workout_log
from screen_locker._rest_day import is_rest_day
from screen_locker._sick_tracker import is_sick_day, load_history
@ -80,8 +81,12 @@ def _workout_time(day: date, log_file: Path | None, *, rest_day: bool) -> float
def first_done_at(
day: date, log_file: Path | None, *, rest_day: bool = False
) -> float | None:
"""The earliest moment any earner was done on ``day``; ``None`` if none."""
times = [first_credit_time(e, day) for e in flat_earners()]
"""The earliest moment any earner was done on ``day``; ``None`` if none.
Every gate in force on ``day`` counts, flat or counted: the Automation
tutor's first verified block starts the floor like a LeetCode solve does.
"""
times = [first_credit_time(e, day) for e in gate_earners(day)]
times.append(_workout_time(day, log_file, rest_day=rest_day))
on_day = [
t

View File

@ -36,6 +36,7 @@ from screen_locker._day import today_str
from screen_locker._earned import (
counted_gate_earners,
earned_today,
first_credits,
first_minutes,
flat_earners,
hhmm,
@ -60,10 +61,15 @@ _DEFAULT_MORNING_END = 5 * 60
def base_minutes(day: date | None = None) -> int:
"""The base shutdown for ``day`` (default today), in minutes after midnight.
Every penalty in force is already taken off.
Every penalty in force is already taken off; from today on, a gate's not
before it has paid out (:func:`~screen_locker._earned.first_credits`).
"""
target = day or datetime.now().astimezone().date()
return earned_time.base_for(target, registry(target)).shutdown_minutes
earners = registry(target)
starts = first_credits(earners, target)
if starts is None:
return earned_time.base_for(target, earners).shutdown_minutes
return earned_time.base_for(target, earners, first_credits=starts).shutdown_minutes
def today_credit_count(log_file: Path, today: str) -> int:

View File

@ -19,7 +19,7 @@ from typing import TYPE_CHECKING
import earned_time
from screen_locker._earned import ceiling, gate_answers, registry
from screen_locker._earned import ceiling, first_credits, gate_answers, registry
from screen_locker._grace_floor import first_done_at, grace_for, sick_on
from screen_locker._log_io import load_workout_log
from screen_locker._rest_day import is_rest_day
@ -77,6 +77,9 @@ class DayInputs:
rest_day: Declared rest day.
first_done: Unix time the day's first earner was done, if any.
sick_day: Sick day: the sick-day shutdown wins, no grace floor.
first_credits: Each penalised gate's first real credit
(:func:`~screen_locker._earned.first_credits`); ``None`` resolves
on ``penalty_from`` alone, as before earned_time 0.6.
"""
day: date
@ -85,6 +88,7 @@ class DayInputs:
rest_day: bool = False
first_done: float | None = None
sick_day: bool = False
first_credits: Mapping[str, date | None] | None = None
def derive(inputs: DayInputs) -> Target:
@ -96,7 +100,13 @@ def derive(inputs: DayInputs) -> Target:
)
# The registry is passed explicitly: gate_answers iterated this same
# day's registry, so the earners asked and the earners summed match.
resolution = earned_time.resolve(answers, day, registry(day))
earners = registry(day)
starts = inputs.first_credits
resolution = (
earned_time.resolve(answers, day, earners)
if starts is None
else earned_time.resolve(answers, day, earners, first_credits=starts)
)
grace = None if inputs.sick_day else grace_for(inputs.first_done, day)
minutes = resolution.shutdown_minutes
if grace is not None:
@ -130,5 +140,6 @@ def gather(day: date, log_file: Path | None) -> Target:
rest_day=rest_day,
first_done=first_done_at(day, log_file, rest_day=rest_day),
sick_day=sick_on(day),
first_credits=first_credits(registry(day), day),
)
)

View File

@ -0,0 +1,160 @@
"""Tests for ``_earned.first_credits``: maturity only delays a penalty.
A stand-in earner (``piano``) is registered with a penalty from well before
the pinned day. Its signed ledger lives under the suite's redirected
``LEDGER_HOME``; whether it has ever paid out decides whether its cut applies
(earned_time 0.6.0). Every date is fixed and "today" is pinned on
``_earned.today_str``, so the real calendar cannot move a result.
"""
from __future__ import annotations
from dataclasses import replace
from datetime import date, datetime, time, timedelta
import logging
from typing import TYPE_CHECKING
from unittest.mock import patch
import earned_time
import pytest
from screen_locker import _earned
from screen_locker._earned import first_credits, registry
from screen_locker._shutdown_base import base_minutes
from screen_locker._shutdown_target import DayInputs, derive, gather
from screen_locker.tests._earned_fixtures import (
EXTRA,
credit,
register,
signing_key,
write_ledger,
)
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
# Before the sleep ladder (2026-10-10), where a penalty still lowers the
# shutdown base; before the Anki waiver too, so the registry is EARNERS.
_DAY = date(2026, 10, 8)
_ONE = timedelta(days=1)
def _any_credit(row: dict[str, object], window: tuple[float, float]) -> bool:
"""Every verified credit counts: the dating is earned_time's own."""
del row, window
return True
# Penalised for days already, never confirmed by kuhy: a new gate.
PIANO = earned_time.Earner(
name="piano",
label="Piano",
gaming_minutes=30,
shutdown_minutes=30,
ledger=".local/share/piano_guard/ledger.json",
match=_any_credit,
penalty_from=_DAY - 5 * _ONE,
)
@pytest.fixture
def today() -> Iterator[None]:
"""Pin "today" to ``_DAY`` for the history check.
Yields:
None, with ``_earned.today_str`` answering ``_DAY``.
"""
with patch.object(_earned, "today_str", return_value=_DAY.isoformat()):
yield
@pytest.fixture
def piano(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[None]:
"""Register ``piano`` and sign its ledger with a throwaway key.
Args:
monkeypatch: Patches the registries.
tmp_path: Where the key is written.
Yields:
None, with ``piano`` in every registry and the key patched.
"""
register(monkeypatch, PIANO)
with signing_key(tmp_path):
yield
def _paid(day: date) -> None:
"""``piano`` paid out once, at noon on ``day``."""
write_ledger(PIANO, [credit(PIANO, datetime.combine(day, time(12)).astimezone())])
@pytest.mark.usefixtures("today", "piano")
class TestTheMap:
def test_each_penalised_reader_maps_to_its_first_credit(self) -> None:
"""A bonus, a ledger-less and a matcher-less earner are left out."""
_paid(_DAY - _ONE)
never = replace(PIANO, name="never", ledger=".local/share/never/l.json")
earners = (
PIANO,
never,
EXTRA,
replace(PIANO, name="bare", ledger=None, match=None),
replace(PIANO, name="blind", match=None),
)
assert first_credits(earners, _DAY) == {"piano": _DAY - _ONE, "never": None}
def test_a_future_day_is_resolved_too(self) -> None:
assert first_credits((PIANO,), _DAY + _ONE) == {"piano": None}
def test_history_is_never_re_resolved(self) -> None:
assert first_credits((PIANO,), _DAY - _ONE) is None
def test_an_earned_time_without_maturity_says_so(
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
monkeypatch.delattr(earned_time, "maturity")
_earned._warn_no_maturity.cache_clear()
with caplog.at_level(logging.WARNING):
assert first_credits((PIANO,), _DAY) is None
_earned._warn_no_maturity.cache_clear()
assert "no maturity" in caplog.text
class TestTheBase:
"""``base_minutes`` and the derived target agree on who is penalised."""
@pytest.mark.usefixtures("today")
def test_only_a_gate_that_paid_out_is_penalised(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Never paid out: free. Paid yesterday: cut. Paid today: from tomorrow."""
with signing_key(tmp_path):
without = base_minutes(_DAY)
register(monkeypatch, PIANO)
assert base_minutes(_DAY) == without
assert gather(_DAY, None).resolution.base.shutdown_minutes == without
_paid(_DAY - _ONE)
assert base_minutes(_DAY) == without - PIANO.shutdown_minutes
_paid(_DAY)
assert base_minutes(_DAY) == without
@pytest.mark.usefixtures("piano")
def test_a_past_day_keeps_its_penalty_from_base(self) -> None:
"""History: priced on ``penalty_from`` alone, as it was enforced."""
later = (_DAY + _ONE).isoformat()
strict = earned_time.base_for(_DAY, registry(_DAY)).shutdown_minutes
with patch.object(_earned, "today_str", return_value=later):
assert base_minutes(_DAY) == strict
@pytest.mark.usefixtures("piano")
class TestDerive:
def test_first_credits_reach_the_resolution(self) -> None:
answers = {e.name: False for e in registry(_DAY)}
lenient = derive(DayInputs(_DAY, answers, first_credits={"piano": None}))
strict = derive(DayInputs(_DAY, answers))
assert lenient.resolution.base.shutdown_minutes == (
strict.resolution.base.shutdown_minutes + PIANO.shutdown_minutes
)

View File

@ -84,7 +84,7 @@ class TestWhenTheDayStarted:
patch.object(_grace_floor, "first_credit_time", return_value=at("00:00")),
):
assert first_done_at(_DAY, None) == at("00:00")
with patch.object(_grace_floor, "flat_earners", return_value=()):
with patch.object(_grace_floor, "gate_earners", return_value=()):
assert first_done_at(_DAY, None, rest_day=True) is None
def test_without_a_workout_ledger_only_the_log_answers(self) -> None:

View File

@ -158,8 +158,10 @@ class TestRealLedgers:
mixin = _mixin()
with signing_key(tmp_path):
assert reset_to_base_if_new_day(state, mixin) is True
# Under the same key: the base reads the ledgers too (maturity).
base = base_minutes()
mixin._write_shutdown_config.assert_called_once_with(
base_minutes() + 120, base_minutes() + 120, 300, restore=True
base + 120, base + 120, 300, restore=True
)
assert json.loads(state.read_text()) == {
"last_reset_date": today_str(),

View File

@ -0,0 +1,115 @@
"""The Automation tutor on a simulated cutover: penalty start and grace floor.
``earned_time.TUTOR_FROM`` is a far-future sentinel, so the cutover is
simulated on a fixed day: the registry's cutover (bound by value in
``_registry`` and ``_ladder``) and the tutor's own ``penalty_from`` move to
:data:`_CUTOVER`. Nothing is written outside the suite's redirected home.
"""
from __future__ import annotations
from dataclasses import replace
from datetime import date, datetime, time
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
import earned_time
import pytest
from screen_locker import _earned
from screen_locker._earned import first_credits, registry
from screen_locker._grace_floor import first_done_at
from screen_locker._shutdown_target import DayInputs, derive
from screen_locker.tests._earned_fixtures import signed, signing_key, write_ledger
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_CUTOVER = date(2026, 10, 10)
@pytest.fixture
def tutor(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[Any]:
"""The tutor registry from ``_CUTOVER``, its ledger signed, today pinned.
Args:
monkeypatch: Moves the cutover.
tmp_path: Where the key is written.
Yields:
The tutor earner, penalised from ``_CUTOVER``.
"""
item = replace(earned_time.AUTOMATION_TUTOR, penalty_from=_CUTOVER)
swapped = tuple(
item if e is earned_time.AUTOMATION_TUTOR else e
for e in earned_time.TUTOR_EARNERS
)
monkeypatch.setattr(earned_time, "TUTOR_EARNERS", swapped)
monkeypatch.setattr("earned_time._registry.TUTOR_FROM", _CUTOVER)
monkeypatch.setattr("earned_time._ladder.TUTOR_FROM", _CUTOVER)
with (
signing_key(tmp_path),
patch.object(_earned, "today_str", return_value=_CUTOVER.isoformat()),
):
yield item
def _block(hhmm: str) -> dict[str, Any]:
hours, minutes = map(int, hhmm.split(":"))
ended = datetime.combine(_CUTOVER, time(hours, minutes)).astimezone()
return signed(
{
"kind": "credit",
"entry_id": f"s-b{hhmm}",
"day": _CUTOVER.isoformat(),
"detail": {"block": 1, "ended_at": ended.timestamp()},
}
)
def test_the_simulated_cutover_registers_the_tutor(tutor: Any) -> None:
assert tutor in registry(_CUTOVER)
assert tutor in _earned.gate_earners(_CUTOVER)
assert tutor not in _earned.flat_earners(_CUTOVER)
@pytest.mark.usefixtures("tutor")
class TestNeverCredited:
"""A tutor that never paid out is not penalised once first_credits is wired."""
def test_the_map_says_never(self) -> None:
starts = first_credits(registry(_CUTOVER), _CUTOVER)
assert starts is not None
assert starts["automation"] is None
def test_no_tutor_penalty_on_the_gaming_base(self) -> None:
answers = {e.name: 0 for e in registry(_CUTOVER)}
starts = first_credits(registry(_CUTOVER), _CUTOVER)
wired = derive(DayInputs(_CUTOVER, answers, first_credits=starts))
unwired = derive(DayInputs(_CUTOVER, answers))
cut = earned_time.AUTOMATION_TUTOR.max_gaming_minutes
assert wired.resolution.base.gaming_minutes == (
unwired.resolution.base.gaming_minutes + cut
)
# KNOWN GAP (earned_time 0.6.0): on the sleep ladder the shutdown
# floor is the ceiling minus every registered earner's full pay and
# never asks whether a penalty is in force, so a never-credited tutor
# still costs its shutdown minutes. A fix there flips this assertion.
assert wired.resolution.base.shutdown_minutes == (
unwired.resolution.base.shutdown_minutes
)
@pytest.mark.usefixtures("tutor")
class TestTheGraceFloor:
"""A tutor block is a first task like any other."""
def test_a_tutor_block_starts_the_floor(self, tutor: Any) -> None:
write_ledger(tutor, [_block("18:55"), _block("19:10")])
expected = datetime.combine(_CUTOVER, time(18, 55)).astimezone()
assert first_done_at(_CUTOVER, None) == expected.timestamp()
def test_no_block_no_floor(self, tutor: Any) -> None:
write_ledger(tutor, [])
assert first_done_at(_CUTOVER, None) is None