mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-15 00:50:58 +01:00
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
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
157 lines
5.6 KiB
Python
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
|