earned_time 0.16.0: leisure=None|True|False, so the gaming day decides (C6)
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
earned_time pre-commit / pre-commit (push) Has been cancelled
earned_time tests / test (push) Has been cancelled
file length / file-length (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled

resolve()/base_for(leisure=) is now tri-state: None (default) decides by
the switch file on `day`, True forces leisure pricing (the shadow budget),
False forces the old base and cap. A consumer billing on the 06:00 gaming
day passes leisure_active(gaming_day), so 00:00-05:59 on the go-live date
no longer prices the eve with the +2 h leisure base/cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
This commit is contained in:
Krzysztof Rudnicki 2026-10-19 16:58:27 +02:00
parent 3afa269e2e
commit 9733a29a65
7 changed files with 58 additions and 28 deletions

View File

@ -21,16 +21,16 @@ rows in its ledger — and is registered here once.
**Leisure budget (0.14.0, `_leisure.py`).** From the first leisure day the
budget covers all leisure (games, browsing, video, messaging): base 5 h -> 7 h,
cap 8 h -> 10 h; pay, penalties and shutdown untouched (full day 480 -> 600).
`resolve`/`base_for(..., leisure=True)` price any day so: the shadow budget.
`resolve`/`base_for(..., leisure=)` (0.16.0): `None` = by the switch file on
`day`, `True` = leisure pricing (shadow budget), `False` = old pricing; a
consumer billing on the 06:00 gaming day passes `leisure_active(gaming_day)`.
**The switch file (0.15.0).** The first leisure day is `leisure_from()`: the
date in `/etc/leisure-watch/leisure_from` (`LEISURE_FROM_PATH`, one line
`YYYY-MM-DD`, root:root 0644), else the parked `LEISURE_FROM` (2027-01-01).
Read afresh each call (`O_NOFOLLOW|O_NONBLOCK`, regular file <= 64 bytes,
strict format; anything else is parked; never raises), once per `resolve`.
`leisure_active(day)` (`day >= leisure_from()`, gaming day) is the ONLY way a
consumer asks "is Phase 2 on?"; none reads the file. `leisure-watch go-live`
writes it (always tomorrow's date, after its preflight); `--undo` removes it.
`YYYY-MM-DD` line in `/etc/leisure-watch/leisure_from` (`LEISURE_FROM_PATH`,
root:root 0644; read afresh, once per `resolve`, `O_NOFOLLOW|O_NONBLOCK`,
regular, <= 64 bytes, strict), else the parked `LEISURE_FROM` (2027-01-01);
never raises. Consumers ask only `leisure_active(day)` (gaming day); none
reads the file. `leisure-watch go-live` writes tomorrow's date; `--undo` removes it.
**Shutdown** has two regimes, switched by date (`LADDER_FROM`, 2026-10-10 —
never a same-day cut):
@ -160,9 +160,9 @@ 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,
leisure=False) -> Resolution` — the one sum. `sick_shutdown_from(paid_minutes,
leisure=None) -> Resolution` — the one sum. `sick_shutdown_from(paid_minutes,
day) -> int`. `base_for(day=None, earners=None, *, first_credits=None,
leisure=False) -> Base` — the day's floors. `gaming_base_minutes_for(day)`,
leisure=None) -> Base` — the day's floors. `gaming_base_minutes_for(day)`,
`gaming_ceiling_minutes_for(day) -> int` — the gaming base and cap.
`leisure_from(path=LEISURE_FROM_PATH) -> date`, `leisure_active(day, path=...)`.
- `done_today(earner, ledger, key_file, *, now=None) -> bool | None`
@ -243,7 +243,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.15.0#subdirectory=earned_time"
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.16.0#subdirectory=earned_time"
```
The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports

View File

@ -23,9 +23,10 @@ A consumer's whole integration::
apply(day.gaming_minutes)
``sick`` (0.11.0) only raises shutdown to at least 20:00; gaming is unchanged.
``leisure=True`` (0.14.0) prices gaming with the leisure base and cap
whatever the day: the shadow budget. The first real leisure day is
:func:`leisure_from` (0.15.0, the switch file); ask :func:`leisure_active`.
``leisure`` (0.16.0): ``None`` follows the switch file on ``day``, ``True``
prices gaming with the leisure base and cap (the shadow budget), ``False``
with the old ones. The first real leisure day is :func:`leisure_from`
(0.15.0, the switch file); ask :func:`leisure_active` for the gaming day.
How far to trust a gate is computed, not listed: :func:`maturity` (and
``python -m earned_time maturity``).

View File

@ -11,8 +11,10 @@ The first leisure day is the switch file ``LEISURE_FROM_PATH`` (0.15.0),
written by ``leisure-watch go-live`` (always tomorrow's date); without a
readable one it is the parked ``LEISURE_FROM``. Earlier days keep
``GAMING_BASE_MINUTES`` and ``GAMING_CEILING_MINUTES``, byte-identical.
``resolve(..., leisure=True)`` prices any day with the leisure base and cap:
the shadow budget the enforcer reports while it still enforces the old one.
``resolve(..., leisure=...)`` (0.16.0): ``None`` decides by the switch file
on ``day``, ``True`` forces the leisure base and cap (the shadow budget),
``False`` forces the old ones -- so a consumer billing on the 06:00 gaming day
passes ``leisure_active(gaming_day)`` instead of trusting the calendar day.
"""
from __future__ import annotations
@ -80,12 +82,12 @@ def leisure_active(day: date, path: Path = LEISURE_FROM_PATH) -> bool:
return day >= leisure_from(path)
def is_leisure(day: date, *, leisure: bool = False) -> bool:
"""``leisure`` (forced: the shadow budget) or ``day`` is a leisure day.
def is_leisure(day: date, *, leisure: bool | None = None) -> bool:
"""``leisure`` if forced (``True``/``False``), else is ``day`` a leisure day.
Looks ``LEISURE_FROM_PATH`` up at call time, so tests can redirect it.
"""
return leisure or leisure_active(day, LEISURE_FROM_PATH)
return leisure_active(day, LEISURE_FROM_PATH) if leisure is None else leisure
def base_minutes(*, leisure: bool) -> int:

View File

@ -88,7 +88,7 @@ def base_for(
earners: tuple[Earner, ...] | None = None,
*,
first_credits: Mapping[str, date | None] | None = None,
leisure: bool = False,
leisure: bool | None = None,
) -> Base:
"""The floor for ``day`` (default today) under ``earners`` (default its own).
@ -97,8 +97,9 @@ def base_for(
derived separately so the ladder can never move a gaming minute.
``first_credits`` (per earner name) delays each penalty, and the ladder
rung, to :func:`penalty_start`; omitted, 0.5.0's ``penalty_from`` applies.
The gaming base is :func:`gaming_base_minutes_for` the day, or with
``leisure`` the leisure base whatever the date (:func:`is_leisure`).
The gaming base: ``leisure`` ``None`` -- by the switch file on the day,
``True``/``False`` -- the leisure/old base whatever the date
(:func:`is_leisure`).
"""
target = day or _today()
registry = earners_for(target) if earners is None else earners
@ -166,7 +167,7 @@ def resolve(
*,
first_credits: Mapping[str, date | None] | None = None,
sick: bool = False,
leisure: bool = False,
leisure: bool | None = None,
) -> Resolution:
"""Sum the base and every earned term, each capped at its ceiling.
@ -186,9 +187,12 @@ def resolve(
is 20:00 plus the terms' gaming pay
(:func:`~earned_time._sick.sick_shutdown_from`, 0.13.0).
Gaming and every term are unchanged.
leisure: Price gaming as a leisure day (0.14.0): the leisure base and
cap whatever the day and the switch file say -- the shadow budget.
Terms, penalties and shutdown are unchanged.
leisure: ``None`` (0.16.0 default): the switch file decides on
``day``. ``True`` (0.14.0): the leisure base and cap whatever the
day says -- the shadow budget. ``False``: the old base and cap
whatever the file says -- a gaming day (06:00) still before the
switch while the calendar day is past it. Terms, penalties and
shutdown are unchanged.
Returns:
The resolution, with a term for every registered earner.

View File

@ -22,7 +22,7 @@ FULL = {"workout": 1, "leetcode": 1, "reading": 1, "tutor": 60}
NONE = dict.fromkeys(FULL, 0)
def _gaming(day: date, answers: dict[str, int], *, leisure: bool = False) -> int:
def _gaming(day: date, answers: dict[str, int], *, leisure: bool | None = None) -> int:
return resolve(answers, day, TUTOR_EARNERS, leisure=leisure).gaming_minutes

View File

@ -166,3 +166,26 @@ def test_leisure_flag_still_forces_past_a_later_file(
resolve(FULL, UNIFIED_FROM, TUTOR_EARNERS, leisure=True).gaming_minutes == 600
)
assert resolve(FULL, date(2027, 1, 2), TUTOR_EARNERS).gaming_minutes == 480
@pytest.mark.usefixtures("live")
def test_leisure_false_forces_old_pricing_past_the_file() -> None:
# 0.16.0: the consumer's 06:00 gaming day may still be the eve while the
# calendar day is already the switch day (00:00-05:59 on go-live).
assert base_for(DAY, leisure=False).gaming_minutes == 180
assert resolve(NONE, DAY, TUTOR_EARNERS, leisure=False).gaming_minutes == 180
assert resolve(FULL, DAY, TUTOR_EARNERS, leisure=False).gaming_minutes == 480
@pytest.mark.usefixtures("live")
def test_leisure_none_decides_by_the_file() -> None:
eve = DAY - timedelta(days=1)
assert base_for(eve, leisure=None).gaming_minutes == 180
assert base_for(DAY, leisure=None).gaming_minutes == 300
assert resolve(FULL, eve, TUTOR_EARNERS, leisure=None).gaming_minutes == 480
assert resolve(FULL, DAY, TUTOR_EARNERS, leisure=None).gaming_minutes == 600
def test_leisure_defaults_to_none() -> None:
for func in (base_for, resolve):
assert inspect.signature(func).parameters["leisure"].default is None

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "earned-time"
version = "0.15.0"
version = "0.16.0"
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
requires-python = ">=3.14"
dependencies = []