utils/earned_time/earned_time/_ladder.py
Krzysztof kuhy Rudnicki fd6c772eaf
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
earned_time 0.6.1: tutor cutover 2026-10-10; new-gate rungs spare the shutdown floor
TUTOR_FROM = 2026-10-10 (the day after the deploy). The automation tutor
keeps confirmed_on=None, so it stays "new".

Ladder floor: a rung comes off the floor only for a pure bonus (no
penalty_from) or an earner whose penalty has started -- the same
_penalised() rule (first_credits -> penalty_start, confirmed_on fail-closed
fallback, penalty_from without first_credits) the gaming base uses. Before
this, a never-credited tutor would have lowered 2026-10-10's floor by its
full 50-minute rung (19:50 -> 19:00) even with first_credits. Maturity can
only raise time: new property test against the 0.6.0 floor formula.

ruff 0.17.0 (dependency-freshness): CPY001 ignored -- personal repo, no
copyright policy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WdfmsGMht9ypyvxGPmNF1V
2026-10-09 22:11:35 +02:00

157 lines
5.6 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""The shutdown ladder: from ``LADDER_FROM``, shutdown is read down from sleep.
The ceiling is ``WAKE_MINUTES`` minus eight hours (23:00 for a 07:00 wake);
the floor is that ceiling minus every earner's first-unit :class:`Rung`, so
doing everything lands exactly on the ceiling. Move the alarm and the whole
ladder moves with it. Days before ``LADDER_FROM`` keep their own values and a
frozen ceiling, so the switch never rewrites how an old day resolved. Gaming
is not on the ladder.
From ``TUTOR_FROM`` the rungs are :data:`TUTOR_LADDER`: Anki is retired and
its 25 minutes fold into the Automation tutor's, paid per 15-minute block.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from types import MappingProxyType
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
from collections.abc import Mapping
from earned_time._policy import Earner
# The shutdown ceiling of every day before ``LADDER_FROM``. Frozen: a later
# ``WAKE_MINUTES`` change must not rewrite how an old day resolved.
SHUTDOWN_CEILING_MINUTES: Final = 23 * 60
# From this day shutdown follows the sleep ladder. A date, never a same-day
# cut: the day it was approved (2026-10-09) still resolves the old way.
LADDER_FROM: Final = date(2026, 10, 10)
# Alarm time, minutes after midnight. The ladder's ceiling is eight hours of
# sleep before it, so moving the alarm moves the whole ladder with it.
WAKE_MINUTES: Final = 7 * 60
# From this day the Anki earner is retired and Automation is paid per tutor
# block (``earned_time._policy.AUTOMATION_TUTOR``). The day after the deploy
# (0.6.1, 2026-10-09) -- never the deploy day itself (a same-day cut,
# 2026-09-26). The tutor is still unconfirmed (no ``confirmed_on``), so given
# ``first_credits`` it costs nothing -- neither gaming base nor ladder floor
# -- until the day after it first pays out.
TUTOR_FROM: Final = date(2026, 10, 10)
# From this day until TUTOR_FROM the Anki and old Automation earners are
# waived: neither penalised nor paid, since both are being retired and the
# tutor that replaces them is not yet confirmed. Raise-only on every day (the
# floor rises by exactly what the two could have paid back), so it may land
# on its deploy day: 0.5.0 landed 2026-10-09.
ANKI_WAIVED_FROM: Final = date(2026, 10, 9)
_SLEEP_MINUTES: Final = 8 * 60
_DAY_MINUTES: Final = 24 * 60
@dataclass(frozen=True)
class Rung:
"""One earner's step on the shutdown ladder.
Attributes:
first: Shutdown minutes the first unit earns.
extra: Each further unit (counted earners) once ``steps`` run out.
steps: What the second, third, ... unit earn, in order, when they are
not all the same (the tutor's 13/13/12/12 split).
"""
first: int
extra: int = 0
steps: tuple[int, ...] = ()
@property
def second(self) -> int:
"""What the second unit earns."""
return self.steps[0] if self.steps else self.extra
def minutes(self, units: int) -> int:
"""Shutdown minutes ``units`` earn on this rung (``0`` for none)."""
if units <= 0:
return 0
further = units - 1
listed = sum(self.steps[:further])
return self.first + listed + max(0, further - len(self.steps)) * self.extra
# The rungs, keyed by earner name: they sum to the gap between the floor and
# the ceiling, so doing everything lands exactly on 23:00. A second workout
# earns nothing more.
LADDER: Final[Mapping[str, Rung]] = MappingProxyType(
{
"workout": Rung(110),
"leetcode": Rung(50),
"reading": Rung(30),
"anki": Rung(25),
"automation": Rung(25),
}
)
# From ``TUTOR_FROM``: Anki's 25 folded into Automation's, which is paid per
# 15-minute tutor block. Still sums to 240, so doing everything is 23:00.
TUTOR_LADDER: Final[Mapping[str, Rung]] = MappingProxyType(
{
"workout": Rung(110),
"leetcode": Rung(50),
"reading": Rung(30),
"automation": Rung(13, steps=(13, 12, 12)),
}
)
def on_ladder(day: date) -> bool:
"""Whether ``day`` resolves shutdown on the sleep ladder."""
return day >= LADDER_FROM
def ladder_for(day: date) -> Mapping[str, Rung] | None:
"""The rungs in force on ``day``; ``None`` before the ladder."""
if not on_ladder(day):
return None
return TUTOR_LADDER if day >= TUTOR_FROM else LADDER
def _rung(item: Earner, day: date) -> Rung | None:
rungs = ladder_for(day)
return None if rungs is None else rungs.get(item.name)
def shutdown_minutes_for(item: Earner, day: date) -> int:
"""What ``item``'s first unit pushes shutdown later by on ``day``.
Its ladder rung from ``LADDER_FROM`` on; before that, or for an earner
without a rung, its own ``shutdown_minutes``.
"""
rung = _rung(item, day)
return item.shutdown_minutes if rung is None else rung.first
def extra_shutdown_minutes_for(item: Earner, day: date) -> int:
"""What ``item``'s second unit earns on ``day`` (counted only)."""
rung = _rung(item, day)
return item.extra_shutdown_minutes if rung is None else rung.second
def shutdown_units_minutes(item: Earner, units: int, day: date) -> int:
"""What ``units`` (already capped by the caller) earn on ``day``."""
if units <= 0:
return 0
rung = _rung(item, day)
if rung is not None:
return rung.minutes(units)
return item.shutdown_minutes + (units - 1) * item.extra_shutdown_minutes
def shutdown_ceiling_for(day: date) -> int:
"""The latest shutdown ``day`` can earn, minutes after its midnight."""
if on_ladder(day):
return _DAY_MINUTES + WAKE_MINUTES - _SLEEP_MINUTES
return SHUTDOWN_CEILING_MINUTES