mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-13 01:10:59 +01:00
earners_for swaps a focus project's replaced gates for one focus earner paid per minute from the focus ledger (floor of summed minutes). The gaming cut equals what the replaced penalised gates cost, so a project day is neutral; maturity never delays it. An unusable focus_project means no project (logged once). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy
185 lines
6.7 KiB
Python
185 lines
6.7 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 tutor's, paid per active minute. From
|
|
``UNIFIED_FROM`` they are :data:`UNIFIED_LADDER`: each rung equals its
|
|
earner's gaming pay.
|
|
"""
|
|
|
|
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 by the
|
|
# tutor (``earned_time._policy.TUTOR``: per 15-minute block until
|
|
# 0.8.0, per active minute since). The day after the deploy (0.6.1,
|
|
# 2026-10-09) -- never the deploy day itself (a same-day cut, 2026-09-26).
|
|
# Confirmed in 0.7.0 (``confirmed_on`` 2026-10-09, kuhy's call on
|
|
# 2026-10-10), so its penalty bites from this day on.
|
|
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)
|
|
# From this day every rung pays exactly the gaming time its earner pays
|
|
# (:data:`UNIFIED_LADDER`, 0.12.0). It lowers the floor 18:50 -> 18:00, so it
|
|
# lands the day after the deploy, never on it. Parked (0.12.1) until
|
|
# screen-locker's late-credit rule shipped (46393fa, 2026-10-17): an 18:00
|
|
# floor without it locked out a credit landing after the shutdown.
|
|
UNIFIED_FROM: Final = date(2026, 10, 18)
|
|
_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.
|
|
"""
|
|
|
|
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 and Automation's 25 each folded into the tutor's
|
|
# rung (``tutor``, named ``automation`` until 0.9.0), paid per active minute
|
|
# (0.8.0; 0.7.0 paid 15 per 15-minute block). Each minute
|
|
# earns the minute it costs (the fairness rule), so the rungs sum to 250:
|
|
# doing everything is still 23:00, doing nothing 18:50.
|
|
TUTOR_LADDER: Final[Mapping[str, Rung]] = MappingProxyType(
|
|
{
|
|
"workout": Rung(110),
|
|
"leetcode": Rung(50),
|
|
"reading": Rung(30),
|
|
"tutor": Rung(1, extra=1),
|
|
}
|
|
)
|
|
|
|
|
|
# From ``UNIFIED_FROM``: shutdown and gaming pay the same -- workout 120,
|
|
# LeetCode 60, reading 60, tutor 1 per unit up to 60. The rungs sum to 300:
|
|
# the ceiling stays 23:00 and the floor drops to 18:00.
|
|
UNIFIED_LADDER: Final[Mapping[str, Rung]] = MappingProxyType(
|
|
{
|
|
"workout": Rung(120),
|
|
"leetcode": Rung(60),
|
|
"reading": Rung(60),
|
|
"tutor": Rung(1, extra=1),
|
|
}
|
|
)
|
|
|
|
|
|
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
|
|
if day >= UNIFIED_FROM:
|
|
return UNIFIED_LADDER
|
|
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_for(1, day) 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)
|
|
if rung is not None:
|
|
return rung.second
|
|
return item.shutdown_for(2, day) - item.shutdown_for(1, day)
|
|
|
|
|
|
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
|