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) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-17 17:27:34 +02:00
parent 2ec1c6e9b1
commit f1378e97f1
6 changed files with 55 additions and 34 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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