From c8f824a11f0dd6460176c59e742740478897c449 Mon Sep 17 00:00:00 2001 From: rootkiller6788 <17553215+rootkiller6788@user.noreply.gitee.com> Date: Mon, 14 Sep 2026 22:33:41 +0800 Subject: [PATCH 1/2] fix(quota): clamp window slot spend against the voids that target it build_usage_summary summed a signed per-run slot value, so a quota_slot_voided run subtracted its slots from every window that contained the void, whether or not the spend it targets was still in that window. A void whose spend had aged out drove quota_spend_slots_24h negative, and a void cancelled an unrelated spend it never referenced. The canonical spend ledger clamps the other way: it keys voids by the run they target and reduces each in-window spend by max(0, spent - voided), so an out-of-window void contributes nothing. Key spends and voids the same way the ledger does, via the shared load_quota_event_from_run loader, and clamp per spend key before summing. This also lets a run whose quota event is only reachable through json_path contribute, as it already does in the ledger. Signed-off-by: rootkiller6788 <17553215+rootkiller6788@user.noreply.gitee.com> --- loopx/control_plane/quota/usage_summary.py | 75 ++++++++++++++++++---- tests/control_plane/test_usage_summary.py | 72 +++++++++++++++++++++ 2 files changed, 136 insertions(+), 11 deletions(-) diff --git a/loopx/control_plane/quota/usage_summary.py b/loopx/control_plane/quota/usage_summary.py index 7840de1066..6fbbdff860 100644 --- a/loopx/control_plane/quota/usage_summary.py +++ b/loopx/control_plane/quota/usage_summary.py @@ -4,6 +4,7 @@ from datetime import timedelta from typing import Any, Callable +from .slot_accounting import load_quota_event_from_run from .spend_sources import VISIBLE_GOAL_SLOT_SPEND_SOURCE from .usage_collector import UsageRowError, UsageSample, collect_usage_for_run from ..runtime.time import now_utc @@ -23,19 +24,61 @@ ) -def quota_spend_slots(run: dict[str, Any]) -> int: +def quota_slot_contribution(run: dict[str, Any]) -> tuple[str, str, int] | None: + """Return the slot contribution a run makes to spend accounting. + + Mirrors the canonical spend ledger: a spend is keyed by the run it was + recorded against and a void by the run it targets, so a void can only + cancel the spend it names. + """ + classification = str(run.get("classification") or "") if classification not in {"quota_slot_spent", "quota_slot_voided"}: - return 0 - quota_event = run.get("quota_event") if isinstance(run.get("quota_event"), dict) else {} + return None + quota_event = load_quota_event_from_run(run) + if not quota_event: + return None raw_slots = quota_event.get("slots", 1) try: slots = max(0, int(raw_slots)) except (TypeError, ValueError): slots = 1 - if classification == "quota_slot_voided" or str(quota_event.get("event_type") or "") == "quota_slot_voided": - return -slots - return slots + if slots <= 0: + return None + if ( + classification == "quota_slot_voided" + or str(quota_event.get("event_type") or "") == "quota_slot_voided" + ): + voided_run_generated_at = str( + quota_event.get("voided_run_generated_at") or "" + ) + if not voided_run_generated_at: + return None + return ("voided", voided_run_generated_at, slots) + run_key = str( + quota_event.get("run_generated_at") or run.get("generated_at") or "" + ) + if not run_key: + return None + return ("spent", run_key, slots) + + +def _net_quota_spend_slots( + contributions: list[tuple[str, str, str, int]], +) -> tuple[dict[str, int], int]: + """Clamp each spend against the voids targeting it, as the ledger does.""" + + spent: dict[tuple[str, str], int] = {} + voided: dict[tuple[str, str], int] = {} + for goal_id, kind, key, slots in contributions: + bucket = spent if kind == "spent" else voided + bucket[(goal_id, key)] = bucket.get((goal_id, key), 0) + slots + per_goal: dict[str, int] = {} + for (goal_id, key), spent_slots in spent.items(): + per_goal[goal_id] = per_goal.get(goal_id, 0) + max( + 0, spent_slots - voided.get((goal_id, key), 0) + ) + return per_goal, sum(per_goal.values()) def is_automation_run(run: dict[str, Any]) -> bool: @@ -165,6 +208,8 @@ def build_usage_summary( goals: dict[str, dict[str, Any]] = {} observed_usage_metrics: set[str] = set() goal_usage_metrics: dict[str, set[str]] = {} + slot_contributions_24h: list[tuple[str, str, str, int]] = [] + slot_contributions_7d: list[tuple[str, str, str, int]] = [] sample_count = 0 for run in history.get("runs") or []: @@ -176,7 +221,7 @@ def build_usage_summary( continue goal_id = str(run.get("goal_id") or "unknown-goal") goal = goals.setdefault(goal_id, blank_usage_goal(goal_id)) - slots = quota_spend_slots(run) + slot_contribution = quota_slot_contribution(run) automation_event = is_automation_run(run) progress_signal = is_progress_signal_run(run) # Present-but-illegal usage fails closed inside collect_usage_for_run. @@ -186,8 +231,8 @@ def build_usage_summary( if generated_at >= cutoff_7d: totals["runs_7d"] += 1 goal["runs_7d"] += 1 - totals["quota_spend_slots_7d"] += slots - goal["quota_spend_slots_7d"] += slots + if slot_contribution is not None: + slot_contributions_7d.append((goal_id, *slot_contribution)) if automation_event: totals["automation_run_count_7d"] += 1 goal["automation_run_count_7d"] += 1 @@ -200,8 +245,8 @@ def build_usage_summary( if generated_at >= cutoff_24h: totals["runs_24h"] += 1 goal["runs_24h"] += 1 - totals["quota_spend_slots_24h"] += slots - goal["quota_spend_slots_24h"] += slots + if slot_contribution is not None: + slot_contributions_24h.append((goal_id, *slot_contribution)) if automation_event: totals["automation_run_count_24h"] += 1 goal["automation_run_count_24h"] += 1 @@ -212,6 +257,14 @@ def build_usage_summary( _accumulate_usage(totals, usage_sample, "24h", observed_usage_metrics) _accumulate_usage(goal, usage_sample, "24h", goal_metrics) + goal_slots_24h, total_slots_24h = _net_quota_spend_slots(slot_contributions_24h) + goal_slots_7d, total_slots_7d = _net_quota_spend_slots(slot_contributions_7d) + totals["quota_spend_slots_24h"] = total_slots_24h + totals["quota_spend_slots_7d"] = total_slots_7d + for goal_id, goal in goals.items(): + goal["quota_spend_slots_24h"] = goal_slots_24h.get(goal_id, 0) + goal["quota_spend_slots_7d"] = goal_slots_7d.get(goal_id, 0) + if totals["runs_24h"]: for goal in goals.values(): goal["project_share_24h"] = round(goal["runs_24h"] / totals["runs_24h"], 3) diff --git a/tests/control_plane/test_usage_summary.py b/tests/control_plane/test_usage_summary.py index 411305dbb5..58124b51a1 100644 --- a/tests/control_plane/test_usage_summary.py +++ b/tests/control_plane/test_usage_summary.py @@ -62,6 +62,25 @@ def _run( return run +def _slot_run( + goal_id: str, + *, + generated_at: datetime, + classification: str, + slots: int = 1, + voided_run_generated_at: str | None = None, +) -> dict[str, Any]: + quota_event: dict[str, Any] = {"event_type": classification, "slots": slots} + if voided_run_generated_at is not None: + quota_event["voided_run_generated_at"] = voided_run_generated_at + return { + "goal_id": goal_id, + "generated_at": generated_at, + "classification": classification, + "quota_event": quota_event, + } + + def _identity_parse(value: Any) -> Any: return value @@ -262,6 +281,59 @@ def test_build_usage_summary_keeps_old_runs_out_of_usage_windows() -> None: assert summary["totals"]["input_tokens_24h"] == 100 +def test_aged_out_void_does_not_reduce_a_later_window() -> None: + now = datetime.now(timezone.utc) + spent = _slot_run( + "g1", + generated_at=now - timedelta(days=2), + classification="quota_slot_spent", + ) + history = { + "runs": [ + spent, + _slot_run( + "g1", + generated_at=now, + classification="quota_slot_voided", + voided_run_generated_at=str(spent["generated_at"]), + ), + ] + } + + summary = build_usage_summary(history, parse_timestamp=_identity_parse) + + # The voided spend is outside the 24h window, so the void must not drive + # that window negative; the spend ledger clamps the same input to zero. + assert summary["totals"]["quota_spend_slots_24h"] == 0 + assert summary["goals"][0]["quota_spend_slots_24h"] == 0 + # Both runs are inside the 7d window, so the void cancels its spend. + assert summary["totals"]["quota_spend_slots_7d"] == 0 + + +def test_void_only_cancels_the_spend_it_targets() -> None: + now = datetime.now(timezone.utc) + history = { + "runs": [ + _slot_run( + "g1", + generated_at=now - timedelta(hours=2), + classification="quota_slot_spent", + ), + _slot_run( + "g1", + generated_at=now - timedelta(hours=1), + classification="quota_slot_voided", + voided_run_generated_at="some-other-run", + ), + ] + } + + summary = build_usage_summary(history, parse_timestamp=_identity_parse) + + assert summary["totals"]["quota_spend_slots_24h"] == 1 + assert summary["goals"][0]["quota_spend_slots_24h"] == 1 + + def test_build_usage_summary_is_provider_neutral() -> None: now = datetime.now(timezone.utc) history = { From 718dfbd1bf33107af332cb6cb3a4347c79f3ad05 Mon Sep 17 00:00:00 2001 From: rootkiller6788 <17553215+rootkiller6788@user.noreply.gitee.com> Date: Tue, 15 Sep 2026 19:19:44 +0800 Subject: [PATCH 2/2] fix(quota): give the slot rule one owner instead of two copies Review follow-up on the previous commit. It left two things open. The usage summary kept its own reading of the spend rule, which had drifted from the enforcement-side ledger: it decided spent versus voided from the run classification rather than the quota event's event_type, and it defaulted a missing or unreadable slot count to one where the ledger defaults to zero. The rule now lives in one place beside the shared quota-event loader -- slot_accounting.quota_slot_contribution and net_quota_slot_spend -- and both goal_quota_with_spend_ledger and build_usage_summary call it. The ledger keeps its behaviour and stays the enforcement owner; the summary is a read-model consumer of the same two functions, so the reported number cannot drift from the enforced one again. A parity test asserts equality per scenario, covering an aged-out void, a void of another run, an unreadable event, non-positive slots, and a classification that disagrees with event_type; two of those are red against the pre-fix implementation. Two fixtures in tracked public smokes were not producer-faithful, and correcting them changes reported numbers, so it is disclosed here: - examples/usage-summary-smoke.py built a quota_slot_spent run with no quota_event. The spend commit always stamps event_type and slots, so the ledger records no slot for such a run. The summary now agrees instead of inventing one: expected totals move from 3 to 2 in both windows, and the per-goal split is asserted explicitly. - status-runtime-summaries-readmodel-smoke.py omitted event_type from its quota_event. The fixture now carries what the producer writes, so its expectation of one slot is unchanged. Net effect for callers: a spend whose quota_event cannot be read contributes 0 slots instead of 1, a non-positive slots value contributes 0 instead of itself, and dispatch follows the quota event's event_type. The run still appears in the event ledger, so it is unquantified rather than hidden. Verified: tests/control_plane/test_usage_summary.py and test_quota_rolling_window_projection.py pass, with the four slot tests red at the parent commit and green here. examples/usage-summary-smoke.py and every other public smoke that consumes usage_summary pass in a clean worktree. A same-suite run over the quota-adjacent CLI tests shows the same failures before and after, all pre-existing Windows environment. Signed-off-by: rootkiller6788 <17553215+rootkiller6788@user.noreply.gitee.com> --- docs/status-data-contract.md | 7 +- ...tatus-runtime-summaries-readmodel-smoke.py | 8 +- examples/usage-summary-smoke.py | 12 +- loopx/control_plane/quota/slot_accounting.py | 54 ++++++++- loopx/control_plane/quota/usage_summary.py | 79 ++++--------- loopx/quota.py | 39 ++----- tests/control_plane/test_usage_summary.py | 104 ++++++++++++++++++ 7 files changed, 211 insertions(+), 92 deletions(-) diff --git a/docs/status-data-contract.md b/docs/status-data-contract.md index af617f6960..f47bd5f5b0 100644 --- a/docs/status-data-contract.md +++ b/docs/status-data-contract.md @@ -2323,7 +2323,12 @@ The summary currently reports: - `runs_24h` / `runs_7d`: observed compact run records in the current status sample. - `quota_spend_slots_24h` / `quota_spend_slots_7d`: slots from - `quota_slot_spent` events in that sample. + `quota_slot_spent` events in that sample, using the same rule as the quota + spend ledger (`goal_quota_with_spend_ledger`): the event's `event_type` + decides, a spend is keyed by the run it was recorded against, and a void is + clamped against the spend it names. A spend whose `quota_event` cannot be + read contributes no slot, and a void that targets a spend outside the window + does not reduce that window. - `automation_run_count_24h` / `automation_run_count_7d`: quota spend events whose compact `quota_event.source` is `heartbeat`, `automation`, or `cron`. If the compact run index does not retain a source, `quota_slot_spent` is diff --git a/examples/control_plane/status-runtime-summaries-readmodel-smoke.py b/examples/control_plane/status-runtime-summaries-readmodel-smoke.py index f5e0c66769..ce57af6ef4 100644 --- a/examples/control_plane/status-runtime-summaries-readmodel-smoke.py +++ b/examples/control_plane/status-runtime-summaries-readmodel-smoke.py @@ -67,7 +67,13 @@ def build_history() -> dict[str, Any]: "goal_id": GOAL_ID, "classification": "quota_slot_spent", "generated_at": utc_isoformat(now), - "quota_event": {"slots": 1, "source": "heartbeat"}, + # event_type is what the spend commit always stamps; the ledger and the + # usage summary both read the slot accounting from it. + "quota_event": { + "event_type": "quota_slot_spent", + "slots": 1, + "source": "heartbeat", + }, } decision_run = { "goal_id": GOAL_ID, diff --git a/examples/usage-summary-smoke.py b/examples/usage-summary-smoke.py index 46f39dd95f..3c8b10f830 100644 --- a/examples/usage-summary-smoke.py +++ b/examples/usage-summary-smoke.py @@ -141,6 +141,8 @@ def main() -> int: registry_path = write_registry(root) runtime = root / "runtime" append_run(runtime, goal_id="project-a", generated_at=now - timedelta(hours=1), classification="state_refreshed") + # No quota_event: the spend ledger records no slot for a spend whose + # event it cannot read, and the usage summary reports the same number. append_run(runtime, goal_id="project-a", generated_at=now - timedelta(minutes=30), classification="quota_slot_spent") append_run( runtime, @@ -226,8 +228,8 @@ def main() -> int: assert usage["sample_run_count"] == 9, usage assert totals["runs_24h"] == 6, totals assert totals["runs_7d"] == 7, totals - assert totals["quota_spend_slots_24h"] == 3, totals - assert totals["quota_spend_slots_7d"] == 3, totals + assert totals["quota_spend_slots_24h"] == 2, totals + assert totals["quota_spend_slots_7d"] == 2, totals assert totals["automation_run_count_24h"] == 2, totals assert totals["automation_run_count_7d"] == 2, totals assert totals["progress_signal_run_count_24h"] == 3, totals @@ -237,6 +239,12 @@ def main() -> int: assert goals["project-a"]["runs_24h"] == 3, goals assert goals["project-a"]["runs_7d"] == 4, goals assert goals["project-b"]["runs_24h"] == 3, goals + # project-a's spend run has no readable quota_event, so it contributes + # no slot; project-b's carries a 2-slot event. + assert goals["project-a"]["quota_spend_slots_24h"] == 0, goals + assert goals["project-a"]["quota_spend_slots_7d"] == 0, goals + assert goals["project-b"]["quota_spend_slots_24h"] == 2, goals + assert goals["project-b"]["quota_spend_slots_7d"] == 2, goals assert goals["project-a"]["progress_signal_run_count_24h"] == 1, goals assert goals["project-a"]["progress_signal_run_count_7d"] == 2, goals assert goals["project-b"]["progress_signal_run_count_24h"] == 2, goals diff --git a/loopx/control_plane/quota/slot_accounting.py b/loopx/control_plane/quota/slot_accounting.py index 3eaddf1700..6819f75d82 100644 --- a/loopx/control_plane/quota/slot_accounting.py +++ b/loopx/control_plane/quota/slot_accounting.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from collections.abc import Callable +from collections.abc import Callable, Iterable from copy import deepcopy from pathlib import Path from typing import Any @@ -910,3 +910,55 @@ def load_quota_event_from_run(run: dict[str, Any]) -> dict[str, Any] | None: return None event = record.get("quota_event") if isinstance(record.get("quota_event"), dict) else None return event + + +def quota_slot_contribution(run: dict[str, Any]) -> tuple[str, str, int] | None: + """Classify one run's contribution to the rolling-window slot ledger. + + ``goal_quota_with_spend_ledger`` enforces quota from this rule and the + usage summary reports from it, so both read an event the same way: the + quota event's ``event_type`` decides, a spend is keyed by the run it was + recorded against, and a void by the run it targets. A run with no usable + event contributes no slot rather than a default one, which is what the + ledger already assumed. + """ + + event = load_quota_event_from_run(run) + if not event: + return None + slots = max(0, _int_number(event.get("slots"), default=0)) + if slots <= 0: + return None + event_type = str(event.get("event_type") or "") + if event_type == QUOTA_SLOT_SPENT_CLASSIFICATION: + run_key = str(event.get("run_generated_at") or run.get("generated_at") or "") + if not run_key: + return None + return ("spent", run_key, slots) + if event_type == QUOTA_SLOT_VOIDED_CLASSIFICATION: + voided_run_generated_at = str(event.get("voided_run_generated_at") or "") + if not voided_run_generated_at: + return None + return ("voided", voided_run_generated_at, slots) + return None + + +def net_quota_slot_spend( + contributions: Iterable[tuple[Any, str, int]], +) -> dict[Any, int]: + """Clamp each spend bucket against the voids that target it. + + A void only cancels the spend recorded against the key it names, so a + window that no longer holds that spend is never pushed negative and a void + never cancels an unrelated spend. + """ + + spent: dict[Any, int] = {} + voided: dict[Any, int] = {} + for bucket, kind, slots in contributions: + target = spent if kind == "spent" else voided + target[bucket] = target.get(bucket, 0) + slots + return { + bucket: max(0, slots - voided.get(bucket, 0)) + for bucket, slots in spent.items() + } diff --git a/loopx/control_plane/quota/usage_summary.py b/loopx/control_plane/quota/usage_summary.py index 6fbbdff860..c149a228e4 100644 --- a/loopx/control_plane/quota/usage_summary.py +++ b/loopx/control_plane/quota/usage_summary.py @@ -4,7 +4,7 @@ from datetime import timedelta from typing import Any, Callable -from .slot_accounting import load_quota_event_from_run +from .slot_accounting import net_quota_slot_spend, quota_slot_contribution from .spend_sources import VISIBLE_GOAL_SLOT_SPEND_SOURCE from .usage_collector import UsageRowError, UsageSample, collect_usage_for_run from ..runtime.time import now_utc @@ -15,6 +15,7 @@ ) ParseTimestamp = Callable[[Any], Any] +SlotContribution = tuple[tuple[str, str], str, int] USAGE_METRIC_NAMES = ( "input_tokens", "output_tokens", @@ -24,61 +25,21 @@ ) -def quota_slot_contribution(run: dict[str, Any]) -> tuple[str, str, int] | None: - """Return the slot contribution a run makes to spend accounting. +def _goal_quota_spend_slots( + contributions: list[SlotContribution], +) -> tuple[dict[str, int], int]: + """Fold each run's slot contribution into per-goal window spend. - Mirrors the canonical spend ledger: a spend is keyed by the run it was - recorded against and a void by the run it targets, so a void can only - cancel the spend it names. + Buckets are keyed by ``(goal_id, run key)`` and clamped by the shared + ledger rule, so a goal only ever loses the spend a void actually targets. """ - classification = str(run.get("classification") or "") - if classification not in {"quota_slot_spent", "quota_slot_voided"}: - return None - quota_event = load_quota_event_from_run(run) - if not quota_event: - return None - raw_slots = quota_event.get("slots", 1) - try: - slots = max(0, int(raw_slots)) - except (TypeError, ValueError): - slots = 1 - if slots <= 0: - return None - if ( - classification == "quota_slot_voided" - or str(quota_event.get("event_type") or "") == "quota_slot_voided" - ): - voided_run_generated_at = str( - quota_event.get("voided_run_generated_at") or "" - ) - if not voided_run_generated_at: - return None - return ("voided", voided_run_generated_at, slots) - run_key = str( - quota_event.get("run_generated_at") or run.get("generated_at") or "" - ) - if not run_key: - return None - return ("spent", run_key, slots) - - -def _net_quota_spend_slots( - contributions: list[tuple[str, str, str, int]], -) -> tuple[dict[str, int], int]: - """Clamp each spend against the voids targeting it, as the ledger does.""" - - spent: dict[tuple[str, str], int] = {} - voided: dict[tuple[str, str], int] = {} - for goal_id, kind, key, slots in contributions: - bucket = spent if kind == "spent" else voided - bucket[(goal_id, key)] = bucket.get((goal_id, key), 0) + slots per_goal: dict[str, int] = {} - for (goal_id, key), spent_slots in spent.items(): - per_goal[goal_id] = per_goal.get(goal_id, 0) + max( - 0, spent_slots - voided.get((goal_id, key), 0) - ) - return per_goal, sum(per_goal.values()) + total = 0 + for (goal_id, _run_key), slots in net_quota_slot_spend(contributions).items(): + per_goal[goal_id] = per_goal.get(goal_id, 0) + slots + total += slots + return per_goal, total def is_automation_run(run: dict[str, Any]) -> bool: @@ -208,8 +169,8 @@ def build_usage_summary( goals: dict[str, dict[str, Any]] = {} observed_usage_metrics: set[str] = set() goal_usage_metrics: dict[str, set[str]] = {} - slot_contributions_24h: list[tuple[str, str, str, int]] = [] - slot_contributions_7d: list[tuple[str, str, str, int]] = [] + slot_contributions_24h: list[SlotContribution] = [] + slot_contributions_7d: list[SlotContribution] = [] sample_count = 0 for run in history.get("runs") or []: @@ -232,7 +193,8 @@ def build_usage_summary( totals["runs_7d"] += 1 goal["runs_7d"] += 1 if slot_contribution is not None: - slot_contributions_7d.append((goal_id, *slot_contribution)) + kind, run_key, slots = slot_contribution + slot_contributions_7d.append(((goal_id, run_key), kind, slots)) if automation_event: totals["automation_run_count_7d"] += 1 goal["automation_run_count_7d"] += 1 @@ -246,7 +208,8 @@ def build_usage_summary( totals["runs_24h"] += 1 goal["runs_24h"] += 1 if slot_contribution is not None: - slot_contributions_24h.append((goal_id, *slot_contribution)) + kind, run_key, slots = slot_contribution + slot_contributions_24h.append(((goal_id, run_key), kind, slots)) if automation_event: totals["automation_run_count_24h"] += 1 goal["automation_run_count_24h"] += 1 @@ -257,8 +220,8 @@ def build_usage_summary( _accumulate_usage(totals, usage_sample, "24h", observed_usage_metrics) _accumulate_usage(goal, usage_sample, "24h", goal_metrics) - goal_slots_24h, total_slots_24h = _net_quota_spend_slots(slot_contributions_24h) - goal_slots_7d, total_slots_7d = _net_quota_spend_slots(slot_contributions_7d) + goal_slots_24h, total_slots_24h = _goal_quota_spend_slots(slot_contributions_24h) + goal_slots_7d, total_slots_7d = _goal_quota_spend_slots(slot_contributions_7d) totals["quota_spend_slots_24h"] = total_slots_24h totals["quota_spend_slots_7d"] = total_slots_7d for goal_id, goal in goals.items(): diff --git a/loopx/quota.py b/loopx/quota.py index e8623c4d0d..d365f184bc 100644 --- a/loopx/quota.py +++ b/loopx/quota.py @@ -63,7 +63,8 @@ QUOTA_SLOT_VOIDED_CLASSIFICATION, build_quota_slot_preview_for_decision, build_quota_slot_spend_event as _build_quota_slot_spend_event, - load_quota_event_from_run, + net_quota_slot_spend, + quota_slot_contribution, record_quota_slot_spend_from_preview, ) from .control_plane.quota.spend_commit import replay_quota_spend_by_effect_ref @@ -356,10 +357,6 @@ def goal_quota_config(goal: dict[str, Any] | None) -> dict[str, Any]: return payload -def _quota_event_run_key(run: dict[str, Any], event: dict[str, Any]) -> str: - return str(event.get("run_generated_at") or run.get("generated_at") or "") - - def goal_quota_with_spend_ledger( goal: dict[str, Any] | None, runs: list[dict[str, Any]], @@ -372,8 +369,7 @@ def goal_quota_with_spend_ledger( if current_time.tzinfo is None: current_time = current_time.replace(tzinfo=timezone.utc) window_start = current_time - timedelta(hours=int(payload["window_hours"])) - spent_by_run: dict[str, int] = {} - voided_by_run: dict[str, int] = {} + contributions: list[tuple[str, str, int]] = [] spend_event_count = 0 void_event_count = 0 @@ -389,32 +385,17 @@ def goal_quota_with_spend_ledger( or generated_at > current_time ): continue - event = load_quota_event_from_run(run) - if not event: - continue - event_type = str(event.get("event_type") or "") - slots = max(0, _int_number(event.get("slots"), default=0)) - if slots <= 0: + contribution = quota_slot_contribution(run) + if contribution is None: continue - if event_type == QUOTA_SLOT_SPENT_CLASSIFICATION: - run_key = _quota_event_run_key(run, event) - if not run_key: - continue - spent_by_run[run_key] = spent_by_run.get(run_key, 0) + slots + kind, run_key, slots = contribution + contributions.append((run_key, kind, slots)) + if kind == "spent": spend_event_count += 1 - elif event_type == QUOTA_SLOT_VOIDED_CLASSIFICATION: - voided_run_generated_at = str(event.get("voided_run_generated_at") or "") - if not voided_run_generated_at: - continue - voided_by_run[voided_run_generated_at] = ( - voided_by_run.get(voided_run_generated_at, 0) + slots - ) + else: void_event_count += 1 - spent_slots = 0 - for run_key, slots in spent_by_run.items(): - spent_slots += max(0, slots - voided_by_run.get(run_key, 0)) - payload["spent_slots"] = spent_slots + payload["spent_slots"] = sum(net_quota_slot_spend(contributions).values()) payload["spend_source"] = "runtime_events" payload["spend_event_count"] = spend_event_count if void_event_count: diff --git a/tests/control_plane/test_usage_summary.py b/tests/control_plane/test_usage_summary.py index 58124b51a1..0e2429322f 100644 --- a/tests/control_plane/test_usage_summary.py +++ b/tests/control_plane/test_usage_summary.py @@ -18,6 +18,7 @@ blank_usage_goal, build_usage_summary, ) +from loopx.quota import goal_quota_with_spend_ledger def _usage( @@ -310,6 +311,109 @@ def test_aged_out_void_does_not_reduce_a_later_window() -> None: assert summary["totals"]["quota_spend_slots_7d"] == 0 +def test_spend_run_without_resolvable_quota_event_counts_no_slot() -> None: + now = datetime.now(timezone.utc) + history = { + "runs": [ + { + "goal_id": "g1", + "generated_at": now - timedelta(minutes=30), + "classification": "quota_slot_spent", + }, + _slot_run( + "g1", + generated_at=now - timedelta(minutes=20), + classification="quota_slot_spent", + slots=2, + ), + ] + } + + summary = build_usage_summary(history, parse_timestamp=_identity_parse) + + # The ledger records no slot for a spend whose event it cannot read, so the + # summary must not invent one either. The run still appears as an accounting + # event in the event ledger, so the spend is not hidden, only unquantified. + assert summary["totals"]["quota_spend_slots_24h"] == 2 + assert summary["goals"][0]["quota_spend_slots_24h"] == 2 + + +def test_window_slot_spend_matches_the_enforcement_ledger() -> None: + """The reported 24h spend must equal the spend the ledger enforces.""" + + now = datetime.now(timezone.utc) + spent = _slot_run( + "g1", + generated_at=now - timedelta(hours=2), + classification="quota_slot_spent", + slots=2, + ) + aged_out = _slot_run( + "g1", + generated_at=now - timedelta(days=2), + classification="quota_slot_spent", + ) + mismatch = _slot_run( + "g1", + generated_at=now - timedelta(hours=3), + classification="quota_slot_spent", + ) + mismatch["classification"] = "quota_slot_voided" + scenarios = { + "spend": [spent], + "spend_and_void": [ + spent, + _slot_run( + "g1", + generated_at=now - timedelta(hours=1), + classification="quota_slot_voided", + voided_run_generated_at=str(spent["generated_at"]), + ), + ], + "void_of_an_aged_out_spend": [ + aged_out, + _slot_run( + "g1", + generated_at=now, + classification="quota_slot_voided", + voided_run_generated_at=str(aged_out["generated_at"]), + ), + ], + "void_of_another_run": [ + spent, + _slot_run( + "g1", + generated_at=now - timedelta(hours=1), + classification="quota_slot_voided", + voided_run_generated_at="some-other-run", + ), + ], + "no_resolvable_event": [ + { + "goal_id": "g1", + "generated_at": now - timedelta(hours=4), + "classification": "quota_slot_spent", + } + ], + "non_positive_slots": [ + _slot_run( + "g1", + generated_at=now - timedelta(hours=5), + classification="quota_slot_spent", + slots=0, + ) + ], + "classification_disagrees_with_event_type": [mismatch], + } + + for name, runs in scenarios.items(): + summary = build_usage_summary({"runs": runs}, parse_timestamp=_identity_parse) + ledger = goal_quota_with_spend_ledger({"id": "g1"}, runs, now=now) + + assert summary["totals"]["quota_spend_slots_24h"] == ledger["spent_slots"], name + assert summary["goals"][0]["quota_spend_slots_24h"] == ledger["spent_slots"], name + + def test_void_only_cancels_the_spend_it_targets() -> None: now = datetime.now(timezone.utc) history = {