From f1378e97f1287d40f9de8038c864adc805b7857c Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 17 Oct 2026 17:27:34 +0200 Subject: [PATCH] earned_time 0.13.0: sick day = 20:00 + gaming pay of what was done (was max(20:00, earned)) Co-Authored-By: Claude Opus 5.5 (1M context) --- earned_time/README.md | 13 ++++---- earned_time/earned_time/__init__.py | 4 +-- earned_time/earned_time/_resolve.py | 8 +++-- earned_time/earned_time/_sick.py | 23 +++++++------ earned_time/earned_time/tests/test_sick.py | 39 +++++++++++++++------- earned_time/pyproject.toml | 2 +- 6 files changed, 55 insertions(+), 34 deletions(-) diff --git a/earned_time/README.md b/earned_time/README.md index d671b58..d8f1f6b 100644 --- a/earned_time/README.md +++ b/earned_time/README.md @@ -45,12 +45,13 @@ its earner pays in gaming (workout 120, LeetCode 60, reading 60, tutor 1/unit to 60), so the floor drops 18:50 -> 18:00 under the same 23:00 ceiling: nothing 3 h / 18:00, workout 5 h / 20:00, +LeetCode 21:00, +reading 22:00. -**Sick days (0.11.0, `_sick.py`).** `resolve(..., sick=True)` floors the -*final* shutdown at 20:00 (`SICK_SHUTDOWN_FLOOR_MINUTES`), capped at the -ceiling: 18:50 becomes 20:00, 21:30 stays 21:30 (nothing stacks on the floor). +**Sick days (0.13.0, `_sick.py`).** `resolve(..., sick=True)` shuts down at +20:00 (`SICK_SHUTDOWN_FLOOR_MINUTES`) plus every term's *gaming* pay, capped +at the ceiling: LeetCode + reading is 22:00 (0.11.0's non-stacking max gave +20:10). Never below 0.11.0's `max(20:00, earned)`, so it landed same-day. Base, terms, gaming, waivers and penalties are unchanged; earners still earn. `sick` is the consumer's answer (`sickdays.is_sick_day`, never imported here). -Live credits are paid out of `Resolution.sick_lift_minutes` first (a grace lift). +A live consumer raises the config to the sick resolution after each credit. **The tutor cutover (`TUTOR_FROM`, one constant in `_ladder.py`).** The registry is per day: `earners_for(day)` is `EARNERS` before it and @@ -156,7 +157,7 @@ How far to trust a gate is computed from its ledger, never listed by name: ## API - `resolve(answers, day=None, earners=None, *, first_credits=None, sick=False)` - `-> Resolution` — the one sum. `sick_shutdown_minutes(minutes, day) -> int`. + `-> Resolution` — the one sum. `sick_shutdown_from(paid_minutes, day) -> int`. - `base_for(day=None, earners=EARNERS) -> Base` — the day's floors. - `done_today(earner, ledger, key_file, *, now=None) -> bool | None` - `first_credit_at(earner, ledger, key_file, day) -> float | None` — unix time @@ -240,7 +241,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.12.1#subdirectory=earned_time" +pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.13.0#subdirectory=earned_time" ``` The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports diff --git a/earned_time/earned_time/__init__.py b/earned_time/earned_time/__init__.py index 69dccc3..489a797 100644 --- a/earned_time/earned_time/__init__.py +++ b/earned_time/earned_time/__init__.py @@ -84,7 +84,7 @@ from earned_time._policy import ( from earned_time._quota import TUTOR_QUOTA_FROM, TUTOR_QUOTA_MINUTES, tutor_quota_for from earned_time._registry import all_earners, earners_for, registries from earned_time._resolve import Base, Resolution, Term, base_for, resolve -from earned_time._sick import SICK_SHUTDOWN_FLOOR_MINUTES, sick_shutdown_minutes +from earned_time._sick import SICK_SHUTDOWN_FLOOR_MINUTES, sick_shutdown_from __all__ = [ "ANKI", @@ -139,7 +139,7 @@ __all__ = [ "resolve", "shutdown_ceiling_for", "shutdown_minutes_for", - "sick_shutdown_minutes", + "sick_shutdown_from", "today_window", "tutor_quota_for", "verified", diff --git a/earned_time/earned_time/_resolve.py b/earned_time/earned_time/_resolve.py index 9498b70..feb74c8 100644 --- a/earned_time/earned_time/_resolve.py +++ b/earned_time/earned_time/_resolve.py @@ -35,7 +35,7 @@ from earned_time._policy import ( ) from earned_time._registry import all_earners, earners_for from earned_time._resolution import Base, Resolution, Term -from earned_time._sick import sick_shutdown_minutes +from earned_time._sick import sick_shutdown_from if TYPE_CHECKING: from collections.abc import Mapping @@ -151,7 +151,8 @@ def resolve( for never, or could not check), e.g. ``Maturity.first_credit``. Only ever delays a penalty; omitted, 0.5.0's ``penalty_from``. sick: The day is a sick day (the consumer asks ``sickdays``): shutdown - is at least 20:00 (:func:`~earned_time._sick.sick_shutdown_minutes`). + is 20:00 plus the terms' gaming pay + (:func:`~earned_time._sick.sick_shutdown_from`, 0.13.0). Gaming and every term are unchanged. Returns: @@ -186,7 +187,8 @@ def resolve( shutdown_ceiling_for(target), base.shutdown_minutes + sum(t.shutdown_minutes for t in terms), ) - shutdown = sick_shutdown_minutes(earned, target) if sick else earned + paid = sum(t.gaming_minutes for t in terms) + shutdown = max(earned, sick_shutdown_from(paid, target)) if sick else earned return Resolution( day=target, base=base, diff --git a/earned_time/earned_time/_sick.py b/earned_time/earned_time/_sick.py index 4342d2c..d7a19db 100644 --- a/earned_time/earned_time/_sick.py +++ b/earned_time/earned_time/_sick.py @@ -1,10 +1,13 @@ # Copyright (c) 2026 Krzysztof Rudnicki -"""The sick-day shutdown floor (0.11.0). +"""The sick-day shutdown: 20:00 plus what the day earned, at gaming pay. -On a sick day shutdown is never earlier than 20:00: the resolved shutdown is -``max(SICK_SHUTDOWN_FLOOR_MINUTES, the normal ladder result)``, still capped -at the day's ceiling. It is applied to the *final* sum, never to the base -- -raising the base would stack every earner's minutes on top of 20:00. +0.11.0 floored the final shutdown at 20:00 without stacking, so a sick day +that did LeetCode and reading still shut down at 20:10 (kuhy, 2026-10-17: +"+1 h from book and +1 h from LeetCode" was expected). Since 0.13.0 a sick +day shuts down at ``SICK_SHUTDOWN_FLOOR_MINUTES`` plus every term's *gaming* +minutes (shutdown follows the gaming bonus), capped at the day's ceiling. +Never below 0.11.0's ``max(20:00, earned)``: every earner's gaming pay is at +least its rung and the ladder floor is below 20:00, so it may land same-day. Only shutdown moves. Gaming time, the registry, waivers and penalties are untouched, and every earner still earns on a sick day. @@ -26,14 +29,14 @@ if TYPE_CHECKING: SICK_SHUTDOWN_FLOOR_MINUTES: Final = 20 * 60 -def sick_shutdown_minutes(minutes: int, day: date) -> int: - """``minutes`` (a resolved shutdown) with the sick-day floor applied. +def sick_shutdown_from(paid_minutes: int, day: date) -> int: + """A sick day's shutdown: 20:00 plus ``paid_minutes``, capped. Args: - minutes: The normal resolved shutdown, minutes after midnight. + paid_minutes: The day's terms at gaming pay (``sum(t.gaming_minutes)``). day: The day, which picks the ceiling. Returns: - ``min(ceiling, max(SICK_SHUTDOWN_FLOOR_MINUTES, minutes))``. + ``min(ceiling, SICK_SHUTDOWN_FLOOR_MINUTES + paid_minutes)``. """ - return min(shutdown_ceiling_for(day), max(SICK_SHUTDOWN_FLOOR_MINUTES, minutes)) + return min(shutdown_ceiling_for(day), SICK_SHUTDOWN_FLOOR_MINUTES + paid_minutes) diff --git a/earned_time/earned_time/tests/test_sick.py b/earned_time/earned_time/tests/test_sick.py index 69e8945..7cc8faa 100644 --- a/earned_time/earned_time/tests/test_sick.py +++ b/earned_time/earned_time/tests/test_sick.py @@ -1,5 +1,5 @@ # Copyright (c) 2026 Krzysztof Rudnicki -"""The sick-day shutdown floor (0.11.0): 20:00 at least, gaming untouched.""" +"""The sick-day shutdown (0.13.0): 20:00 plus gaming pay, gaming untouched.""" from __future__ import annotations @@ -11,7 +11,7 @@ from earned_time import ( SICK_SHUTDOWN_FLOOR_MINUTES, resolve, shutdown_ceiling_for, - sick_shutdown_minutes, + sick_shutdown_from, ) LADDER_DAY = date(2026, 10, 16) @@ -47,13 +47,27 @@ def test_a_sick_day_that_earned_nothing_shuts_down_at_eight() -> None: assert normal.sick_lift_minutes == 0 -def test_earning_past_the_floor_is_not_stacked_on_it() -> None: - """max(20:00, earned) -- never 20:00 plus what was earned.""" - normal = resolve(SOME, LADDER_DAY) - sick = resolve(SOME, LADDER_DAY, sick=True) - assert normal.shutdown_minutes > 20 * 60 - assert sick.shutdown_minutes == normal.shutdown_minutes - assert sick.sick_lift_minutes == 0 +def test_earning_stacks_on_the_floor_at_gaming_pay() -> None: + """0.13.0: 20:00 plus every term's gaming pay (LeetCode + reading = +2 h).""" + answers = {**NOTHING, "leetcode": True, "reading": True} + sick = resolve(answers, LADDER_DAY, sick=True) + assert sick.shutdown_minutes == 22 * 60 + assert sick.shutdown_minutes == sick_shutdown_from(120, LADDER_DAY) + some = resolve(SOME, LADDER_DAY, sick=True) # workout 120 + LeetCode 60 + assert some.shutdown_minutes == 23 * 60 + + +@pytest.mark.parametrize("answers", [NOTHING, SOME, EVERYTHING]) +@pytest.mark.parametrize("day", [LADDER_DAY, PRE_LADDER_DAY]) +def test_never_below_the_old_max_rule( + answers: dict[str, int | bool | None], day: date +) -> None: + """Raise-only against 0.11.0's max(20:00, earned), so it may land same-day.""" + if day == PRE_LADDER_DAY: + answers = {k: v for k, v in answers.items() if k != "tutor"} + normal = resolve(answers, day).shutdown_minutes + old = min(shutdown_ceiling_for(day), max(20 * 60, normal)) + assert resolve(answers, day, sick=True).shutdown_minutes >= old def test_a_fully_done_sick_day_still_stops_at_the_ceiling() -> None: @@ -81,9 +95,10 @@ def test_gaming_and_every_term_are_unchanged( def test_the_helper_is_capped_at_the_ceiling(monkeypatch: pytest.MonkeyPatch) -> None: - assert sick_shutdown_minutes(18 * 60, LADDER_DAY) == 20 * 60 - assert sick_shutdown_minutes(21 * 60, LADDER_DAY) == 21 * 60 + assert sick_shutdown_from(0, LADDER_DAY) == 20 * 60 + assert sick_shutdown_from(60, LADDER_DAY) == 21 * 60 + assert sick_shutdown_from(600, LADDER_DAY) == 23 * 60 monkeypatch.setattr( "earned_time._sick.shutdown_ceiling_for", lambda _day: 19 * 60 + 30 ) - assert sick_shutdown_minutes(18 * 60, LADDER_DAY) == 19 * 60 + 30 + assert sick_shutdown_from(0, LADDER_DAY) == 19 * 60 + 30 diff --git a/earned_time/pyproject.toml b/earned_time/pyproject.toml index d22c4e2..540c7a2 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.12.1" +version = "0.13.0" description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time" requires-python = ">=3.14" dependencies = []