steam-backlog-enforcer/steam_backlog_enforcer/_budget_resolve.py
Krzysztof kuhy Rudnicki 63321f16cc
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Pay an hour of gaming budget for a LeetCode solve
The budget was binary: 8h with a counted workout, 6h without. There was no
base/bonus structure anywhere -- resolve_budget_seconds returned one of two
absolute config values, and the "2h bonus" was emergent arithmetic (8 - 6) that
appeared in no line of code. A second earner could not be bolted onto that
shape without coupling the two.

It is now a sum: 5h floor + 2h workout + 1h LeetCode, so a day is worth 5, 6, 7
or 8 hours. Note the deliberate tightening -- a workout-only day drops 8h to
7h, and a day that earns nothing drops 6h to 5h.

The two earners are read independently and added. That is what "must not
interfere with the workout" means in code: an unreadable LeetCode ledger leaves
a workout day at 7h, not 5h, and there is a test asserting exactly that.

LeetCode is read from leetcode-guard's ledger directly -- it answers even when
leetcode-guard is not running, which is most of the day -- and falls back to its
loopback endpoint. Credits are signature-checked; _leetcode_ledger reproduces
gatelock's canonicalisation in eight lines because gatelock lives in the user's
site-packages and this daemon runs as root, and a pinned vector fails loudly if
that ever drifts.

Failing to get an answer grants nothing AND raises an incident: desktop
notification, warning log, and a line in leetcode_bonus_incidents.log
(gitignored, so it never becomes an uncommitted change), rate-limited to once
per reason per day. A silently missing hour looks exactly like an unearned one.

resolve_budget_seconds is *renamed*, not wrapped: two functions would recreate
the second resolution seam this module's docstring warns about. rules_for calls
it once and carries the breakdown on PlaytimeRules, so /api/budget reports why
today is what it is without resolving again.

Known limitation, accepted: leetcode-guard only writes credits from its lock
window's poll loop, so a voluntary extra solve after the day is settled never
reaches the ledger.

Verified live against real services -- all four cases (5/6/7/8h), the endpoint
fallback, the fail-closed path with its notification, and the rate limit.
1782 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fp6zWH9zW1AkGdDyKjrbi8
2026-08-30 15:22:18 +02:00

167 lines
5.8 KiB
Python

"""Today's gaming budget: a floor, plus whatever today earned.
The budget is a sum, not a choice between two values:
base (5h) + workout bonus (2h) + LeetCode bonus (1h)
so a day earns 5h, 6h, 7h or 8h. The two earners are read **independently** --
:mod:`steam_backlog_enforcer._workout_budget` and
:mod:`steam_backlog_enforcer._leetcode_bonus` share no state and neither can
fail in a way that changes the other's term. That is what "the LeetCode bonus
must not interfere with the workout" means in code.
**Fail closed.** An answer that could not be obtained contributes nothing, the
same as a "no". The difference is only in what gets reported: an unreadable
LeetCode ledger raises an incident, an honest "not solved yet" does not.
**Rising, in normal use -- but not guaranteed.** Both earners only ever go
false->true within a day, so in the ordinary case the budget starts at the floor
and rises. That is a property of the *inputs*, not something enforced here:
nothing persists a per-day high-water mark, so anything that changes a resolved
answer mid-day -- editing the config, or deploying a change onto a day already
in progress -- lowers the budget immediately and re-prices time already spent.
Seconds already accrued are then measured against the new, smaller budget, and
the cutoff fires on the next tick with no warning first, because
``warned_seconds`` records thresholds by seconds *remaining* and remaining has
already gone negative.
That was a deliberate call (2026-08-29): the alternative is a persisted
``granted_budget_seconds`` on ``PlaytimeState`` taking ``max(resolved,
granted)``. If a mid-day drop ever bites, that is the fix -- not a nudge to
these numbers.
**One seam.** :func:`resolve_budget` is called from ``rules_for`` and nowhere
else, so the enforcing daemon and the read-only HTTP/MCP views resolve the same
number. The daemon holds its ``Config`` for the process lifetime while
``_budget_view`` reloads it per request, so anything resolved at only one of
those two sites would let the UI report a budget the daemon was not enforcing.
Callers that want the breakdown read it off ``PlaytimeRules``, never by
resolving a second time.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._leetcode_bonus import leetcode_solved_today
from steam_backlog_enforcer._workout_budget import workout_logged_today
if TYPE_CHECKING:
from steam_backlog_enforcer.config import Config
logger = logging.getLogger(__name__)
_SECONDS_PER_HOUR: Final = 3600.0
@dataclass(frozen=True)
class BudgetResolution:
"""Today's budget, and what earned it.
Attributes:
seconds: The budget to enforce.
base_seconds: The floor before any bonus.
workout_seconds: Seconds added by a counted workout, or 0.
leetcode_seconds: Seconds added by a LeetCode solve, or 0.
reason: A human-readable account, for the journal and ``/api/budget``.
"""
seconds: float
base_seconds: float
workout_seconds: float
leetcode_seconds: float
reason: str
def _bonus_seconds(configured: int, label: str) -> float:
"""A configured bonus, refusing a negative one.
Args:
configured: The configured value.
label: The config field name, for the error message.
Returns:
The bonus in seconds, clamped at zero. A negative bonus would mean
earning something *cost* time, which is never what was meant.
"""
if configured < 0:
logger.error(
"%s is negative (%d); clamping to 0 so earning something can never "
"cost gaming time.",
label,
configured,
)
return 0.0
return float(configured)
def _describe(*, answer: bool | None, earned: str, missed: str, unknown: str) -> str:
"""Render one earner's answer for the reason string.
Args:
answer: True, False, or None for "could not check".
earned: Phrase for True.
missed: Phrase for False.
unknown: Phrase for None.
Returns:
The matching phrase.
"""
if answer is None:
return unknown
return earned if answer else missed
def resolve_budget(config: Config) -> BudgetResolution:
"""Return today's gaming budget, and what earned it.
Args:
config: Loaded user configuration.
Returns:
The floor plus a bonus for each of today's earners. An answer that
could not be obtained contributes nothing, exactly as a "no" does --
the difference is only in what gets logged and reported.
"""
base = _bonus_seconds(config.base_gaming_seconds, "base_gaming_seconds")
workout = workout_logged_today(config)
leetcode = leetcode_solved_today(config)
workout_seconds = (
_bonus_seconds(config.workout_bonus_seconds, "workout_bonus_seconds")
if workout
else 0.0
)
leetcode_seconds = (
_bonus_seconds(config.leetcode_bonus_seconds, "leetcode_bonus_seconds")
if leetcode
else 0.0
)
total = base + workout_seconds + leetcode_seconds
reason = "{:.1f}h: {}, {}".format(
total / _SECONDS_PER_HOUR,
_describe(
answer=workout,
earned="workout counted",
missed="no counted workout",
unknown="workout unknown (screen-locker unreachable)",
),
_describe(
answer=leetcode,
earned="LeetCode solve recorded",
missed="no LeetCode solve recorded",
unknown="LeetCode unknown (ledger and status API both unreadable)",
),
)
logger.info("Gaming budget %s", reason)
return BudgetResolution(
seconds=total,
base_seconds=base,
workout_seconds=workout_seconds,
leetcode_seconds=leetcode_seconds,
reason=reason,
)