cctally 1.97.0 → 1.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,10 +37,12 @@ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
37
37
  """
38
38
  from __future__ import annotations
39
39
 
40
+ import contextlib
40
41
  import datetime as dt
41
42
  import os
42
43
  import pathlib
43
44
  import sys
45
+ import threading
44
46
  from dataclasses import dataclass
45
47
  from typing import Any, Callable, Iterable
46
48
 
@@ -476,13 +478,16 @@ def _aggregate_codex_buckets(
476
478
  })
477
479
  order = models_order.setdefault(key, [])
478
480
 
479
- cost = _calculate_codex_entry_cost(
480
- entry.model,
481
- entry.input_tokens,
482
- entry.cached_input_tokens,
483
- entry.output_tokens,
484
- entry.reasoning_output_tokens,
485
- speed=speed,
481
+ cost = (
482
+ entry.cost_usd if entry.cost_usd is not None
483
+ else _calculate_codex_entry_cost(
484
+ entry.model,
485
+ entry.input_tokens,
486
+ entry.cached_input_tokens,
487
+ entry.output_tokens,
488
+ entry.reasoning_output_tokens,
489
+ speed=speed,
490
+ )
486
491
  )
487
492
 
488
493
  bucket["input"] += entry.input_tokens
@@ -588,9 +593,108 @@ def _aggregate_codex_weekly(
588
593
  return _aggregate_codex_buckets(entries, key_fn=_week_key, speed=speed)
589
594
 
590
595
 
596
+ _CODEX_PATH_SCOPE = threading.local()
597
+
598
+
599
+ class CodexPathScope:
600
+ """One source build's Codex path-identity memo (#566 §5.1 item 1).
601
+
602
+ Holds the ordered session roots captured when the build opened, plus one
603
+ parsed identity per distinct ``source_path``. It holds plain tuples only —
604
+ never a connection, cursor, iterator or open transaction (spec §5.4) — and
605
+ dies with the ``codex_path_scope`` block that created it, so a
606
+ ``$CODEX_HOME`` or configuration change between builds can never be served
607
+ from it.
608
+
609
+ ``misses`` counts the parses actually performed, which is the bounded-work
610
+ quantity the suite asserts on: it can never exceed the number of distinct
611
+ session paths the build touched.
612
+ """
613
+
614
+ __slots__ = ("_roots", "_parts", "misses")
615
+
616
+ def __init__(self, roots: "Iterable[Any] | None" = None) -> None:
617
+ self._roots: "tuple[Any, ...] | None" = (
618
+ None if roots is None else tuple(roots)
619
+ )
620
+ self._parts: dict[str, tuple[str, str, str]] = {}
621
+ self.misses = 0
622
+
623
+ @property
624
+ def roots(self) -> "tuple[Any, ...]":
625
+ """The session roots this build resolved, resolved on FIRST USE.
626
+
627
+ Lazy, not eager. ``_codex_session_roots()`` runs an ``is_dir()`` syscall
628
+ per configured root, and the rooted fallback session view is
629
+ contractually filesystem-free — it derives identity lexically from
630
+ ``(source_root_key, source_path)`` and must never discover anything.
631
+ Resolving at scope entry made every build touch the filesystem even
632
+ when nothing in it ever asked for a path identity, which broke that
633
+ contract. Resolving on the first miss still pins one answer for the
634
+ whole build, which is what the memo key needs.
635
+ """
636
+ if self._roots is None:
637
+ self._roots = tuple(_cctally()._codex_session_roots())
638
+ return self._roots
639
+
640
+ def parts(self, source_path: str) -> tuple[str, str, str]:
641
+ hit = self._parts.get(source_path)
642
+ if hit is None:
643
+ hit = _session_path_parts_for_roots(self.roots, source_path)
644
+ self._parts[source_path] = hit
645
+ self.misses += 1
646
+ return hit
647
+
648
+
649
+ def active_codex_path_scope() -> "CodexPathScope | None":
650
+ """The scope this thread is building under, or None outside a build."""
651
+ return getattr(_CODEX_PATH_SCOPE, "scope", None)
652
+
653
+
654
+ @contextlib.contextmanager
655
+ def codex_path_scope(roots: "Iterable[Any] | None" = None):
656
+ """Open a build-scoped Codex path memo on this thread.
657
+
658
+ The roots are resolved ONCE per scope rather than once per entry, which is
659
+ the whole point: ``_codex_session_roots()`` runs an ``is_dir()`` syscall per
660
+ configured root, and the per-entry form ran it 191,304 times on a store
661
+ holding 2,324 distinct session files. Resolution is deferred to the first
662
+ path the scope is actually asked about, so a build that never needs a path
663
+ identity — the rooted fallback session view, which is contractually
664
+ filesystem-free — still touches nothing.
665
+
666
+ Scopes nest and restore, and the memo is thread-local, so the dashboard's
667
+ sync thread and its HTTP threads never share one.
668
+ """
669
+ scope = CodexPathScope(roots)
670
+ previous = getattr(_CODEX_PATH_SCOPE, "scope", None)
671
+ _CODEX_PATH_SCOPE.scope = scope
672
+ try:
673
+ yield scope
674
+ finally:
675
+ _CODEX_PATH_SCOPE.scope = previous
676
+
677
+
591
678
  def _session_path_parts(source_path: str) -> tuple[str, str, str]:
592
679
  """Return (session_id_path, session_file, directory) from a full path.
593
680
 
681
+ Inside a ``codex_path_scope`` the answer comes from that build's memo,
682
+ keyed on ``(scope roots, source_path)``. Outside one the roots are
683
+ resolved per call, which is the historic behaviour every CLI caller keeps.
684
+ """
685
+ scope = getattr(_CODEX_PATH_SCOPE, "scope", None)
686
+ if scope is not None:
687
+ return scope.parts(source_path)
688
+ return _session_path_parts_for_roots(
689
+ _cctally()._codex_session_roots(), source_path,
690
+ )
691
+
692
+
693
+ def _session_path_parts_for_roots(
694
+ roots: Iterable[Any], source_path: str,
695
+ ) -> tuple[str, str, str]:
696
+ """Return (session_id_path, session_file, directory) under ``roots``.
697
+
594
698
  session_id_path = relative path under the matched $CODEX_HOME session
595
699
  root with .jsonl stripped (e.g. "2025/12/25/rollout-...").
596
700
  session_file = basename without .jsonl extension.
@@ -604,7 +708,6 @@ def _session_path_parts(source_path: str) -> tuple[str, str, str]:
604
708
  files stay free of maintainer absolute paths), then basename. Direct-JSONL
605
709
  roots yield an id relative to <entry> itself (no sessions/ prefix).
606
710
  """
607
- roots = _cctally()._codex_session_roots()
608
711
  p = pathlib.Path(source_path)
609
712
  rel: pathlib.PurePath | None = None
610
713
  for root in roots:
@@ -659,9 +762,12 @@ def _aggregate_codex_sessions_keyed(
659
762
  "cost": 0.0, "models": {}, "models_order": [],
660
763
  "last": entry.timestamp,
661
764
  })
662
- cost = _calculate_codex_entry_cost(
663
- entry.model, entry.input_tokens, entry.cached_input_tokens,
664
- entry.output_tokens, entry.reasoning_output_tokens, speed=speed,
765
+ cost = (
766
+ entry.cost_usd if entry.cost_usd is not None
767
+ else _calculate_codex_entry_cost(
768
+ entry.model, entry.input_tokens, entry.cached_input_tokens,
769
+ entry.output_tokens, entry.reasoning_output_tokens, speed=speed,
770
+ )
665
771
  )
666
772
  sess["input"] += entry.input_tokens
667
773
  sess["cached_input"] += entry.cached_input_tokens
@@ -55,13 +55,18 @@ AXIS_REGISTRY: "tuple[AlertAxisDescriptor, ...]" = (
55
55
  "project_budget", "PROJECT", "Project budget", "project_budget_milestones"
56
56
  ),
57
57
  # Per-vendor Codex budget alerts (calendar-period; calendar-period-codex-budgets
58
- # feature). Distinct "CODEX" chip vs the global "BUDGET" / per-project
59
- # "PROJECT" chips. As of #143 it shares the unified vendor-tagged
58
+ # feature). #556 S3 §4.3: the chip reads "BUDGET", the same as the Claude
59
+ # budget axis, because it names the METRIC. Attribution is the row's source
60
+ # chip, which under All reads "Codex" — a chip reading "CODEX" beside a
61
+ # source chip reading "Codex" said the same thing twice and named neither
62
+ # the metric nor anything the source chip did not already say. The two
63
+ # BUDGET chips stay visually distinct through `chip--codex_budget`, which
64
+ # keeps its own colour. As of #143 it shares the unified vendor-tagged
60
65
  # `budget_milestones` table with the Claude `budget` axis; the envelope
61
66
  # mapper's `WHERE vendor=?` filter does the row-level split (keyed on the
62
67
  # resolved period-window start instant period_start_at, threshold).
63
68
  AlertAxisDescriptor(
64
- "codex_budget", "CODEX", "Codex budget", "budget_milestones", vendor="codex"
69
+ "codex_budget", "BUDGET", "Codex budget", "budget_milestones", vendor="codex"
65
70
  ),
66
71
  )
67
72
 
@@ -187,3 +187,63 @@ def compute_budget_status(inputs: BudgetInputs) -> BudgetStatus:
187
187
  low_confidence=low_confidence,
188
188
  crossed_thresholds=crossed,
189
189
  )
190
+
191
+
192
+ def budget_status_payload(
193
+ *,
194
+ period: str,
195
+ window_start_at: dt.datetime,
196
+ window_end_at: dt.datetime,
197
+ target_usd: float,
198
+ spent_usd: float,
199
+ recent_24h_usd: float,
200
+ now: dt.datetime,
201
+ alert_thresholds,
202
+ ) -> dict:
203
+ """The one dashboard-wire budget status, from ALREADY-RESOLVED bounds.
204
+
205
+ #556 S5 §3.1/§3.3. Both providers publish this exact object, so a single
206
+ client component renders either — which is only true if there is one
207
+ producer. It is a pure operation over resolved bounds and supplied spend
208
+ facts: it opens no database, reads no configuration, and never calls a bare
209
+ host-local ``astimezone()``.
210
+
211
+ Window resolution deliberately stays OUTSIDE this kernel. ``display.tz =
212
+ local`` has no DST-aware stdlib handle, so its per-instant resolution lives
213
+ in ``_cctally_forecast._resolve_local_calendar_window``, and
214
+ subscription-week resolution opens stats.db. Both are injected by the
215
+ caller; re-deriving either here would shift a window that straddles a DST
216
+ transition (issue #136) or make the kernel impure.
217
+
218
+ ``alert_thresholds`` is emitted as the normalized tuple the kernel consumed,
219
+ not as the caller's raw sequence.
220
+ """
221
+ inputs = BudgetInputs(
222
+ target_usd=float(target_usd),
223
+ spent_usd=float(spent_usd),
224
+ recent_24h_usd=float(recent_24h_usd),
225
+ week_start_at=window_start_at,
226
+ week_end_at=window_end_at,
227
+ now=now,
228
+ alert_thresholds=tuple(int(value) for value in alert_thresholds),
229
+ )
230
+ status = compute_budget_status(inputs)
231
+ return {
232
+ "period": period,
233
+ "budget_usd": inputs.target_usd,
234
+ "spent_usd": status.spent_usd,
235
+ "remaining_usd": status.remaining_usd,
236
+ "consumption_pct": status.consumption_pct,
237
+ "verdict": status.verdict,
238
+ "low_confidence": status.low_confidence,
239
+ "window_start_at": window_start_at.astimezone(dt.timezone.utc).isoformat(),
240
+ "window_end_at": window_end_at.astimezone(dt.timezone.utc).isoformat(),
241
+ "recent_24h_usd": inputs.recent_24h_usd,
242
+ "alert_thresholds": inputs.alert_thresholds,
243
+ "pace": {
244
+ "daily_usd": status.daily_pace_usd,
245
+ "projected_low_usd": status.projected_eow_low_usd,
246
+ "projected_high_usd": status.projected_eow_high_usd,
247
+ "week_avg_projection_usd": status.week_avg_projection_usd,
248
+ },
249
+ }
@@ -0,0 +1,259 @@
1
+ """Operator attribution of recorded Codex quota windows (pure kernel).
2
+
3
+ Spec: ``docs/superpowers/specs/2026-08-14-500-codex-window-attribution-design.md``
4
+
5
+ The operator asserts that a physical Codex quota window group belongs to an
6
+ account. This kernel decides, and only decides: which current group each
7
+ assertion owns, and what each observation's account becomes as a result. Every
8
+ SQL read and every write stays in the glue layer.
9
+
10
+ Two phases, and they are not interchangeable (spec §6.4.1). RESOLUTION decides
11
+ which current group an assertion owns and must always run against COMPLETE group
12
+ evidence, because witness matching is population-dependent. APPLICATION maps the
13
+ resolved ownership onto whatever rows the caller actually asked for. A bounded
14
+ read may show fewer rows than a full read; it must never show a different owner.
15
+
16
+ Binding is the four normalized axes plus the tolerance-connected component
17
+ witnessed by raw reset values (spec §5.1). The canonical anchor is NEVER matched
18
+ on: a later bridging observation can union two components and retire it.
19
+
20
+ A pure leaf: stdlib only, no ``_cctally_*`` import, no I/O, no writes — the same
21
+ contract ``_lib_codex_account_adoption`` carries, and for the same reason.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass, replace
26
+ from typing import Iterable, Mapping
27
+
28
+ # The ONE leaf import this module makes, and it exists to avoid a fourth
29
+ # spelling of two constants that already have three (#500 review round 2,
30
+ # finding R2-3). ``_lib_codex_account_adoption`` imports nothing but the
31
+ # standard library, so binding it here introduces no cycle, no I/O and no
32
+ # ``_cctally_*`` dependency — the leaf contract is about those, not about
33
+ # whether one leaf may name another. ``_lib_journal`` and
34
+ # ``_lib_codex_account_adoption`` must stay respelled with respect to each other
35
+ # (the journal module is loaded on paths this kernel is not), which is what
36
+ # ``test_the_two_window_constants_have_one_value_across_both_leaves`` pins.
37
+ import _lib_codex_account_adoption as _adoption
38
+
39
+
40
+ #: Native length of the account-level Codex weekly quota window, in minutes.
41
+ ACCOUNT_WEEKLY_WINDOW_MINUTES = _adoption.ACCOUNT_WEEKLY_WINDOW_MINUTES
42
+
43
+ #: The reserved "account could not be determined" sentinel
44
+ #: (``_lib_accounts.UNATTRIBUTED``).
45
+ UNATTRIBUTED_SENTINEL = _adoption.UNATTRIBUTED_SENTINEL
46
+
47
+ #: Resolution outcomes. ``RESOLVED`` is the only one that applies anything.
48
+ RESOLVED = "resolved"
49
+ DORMANT = "dormant"
50
+ SPLIT = "split"
51
+ SUPPRESSED_NATIVE = "suppressed_native"
52
+ SUPPRESSED_CONFLICT = "suppressed_conflict"
53
+ #: Covers BOTH out-of-scope shapes the spec's precedence table pairs on one row:
54
+ #: a model-scoped pool (#373) and a window that is not account weekly quota.
55
+ #: They are one outcome because they have one remedy and one meaning — "this
56
+ #: window is not account-level standard quota, so no assertion can file it as
57
+ #: such" — and separating them would put two codes on a distinction no operator
58
+ #: acts on differently.
59
+ SUPPRESSED_MODEL_SCOPED = "suppressed_model_scoped"
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class WindowAssertion:
64
+ """One active operator assertion, as the derived table holds it."""
65
+
66
+ op_id: str
67
+ account_key: str
68
+ source_root_key: str
69
+ logical_limit_key: str
70
+ observed_slot: str
71
+ window_minutes: int
72
+ raw_resets_at_utc: "frozenset[str]"
73
+
74
+ def __post_init__(self) -> None:
75
+ object.__setattr__(
76
+ self, "raw_resets_at_utc", frozenset(self.raw_resets_at_utc))
77
+ if not self.raw_resets_at_utc:
78
+ raise ValueError("an assertion must carry at least one witness")
79
+
80
+ @property
81
+ def axes(self) -> tuple:
82
+ return (self.source_root_key, self.logical_limit_key,
83
+ self.observed_slot, self.window_minutes)
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class WindowGroup:
88
+ """One current physical window group, as the loader's complete evidence
89
+ describes it.
90
+
91
+ ``identified_accounts`` is the set of non-sentinel accounts natively present.
92
+ ``model_scoped`` is the authoritative ``_lib_codex_pools`` verdict, re-checked
93
+ at every evaluation because ``limit_name`` sits outside identity equality and
94
+ can change on re-materialization (spec §7).
95
+ """
96
+
97
+ group_key: tuple
98
+ source_root_key: str
99
+ logical_limit_key: str
100
+ observed_slot: str
101
+ window_minutes: int
102
+ raw_resets_at_utc: "frozenset[str]"
103
+ identified_accounts: "frozenset[str]" = frozenset()
104
+ model_scoped: bool = False
105
+
106
+ def __post_init__(self) -> None:
107
+ object.__setattr__(
108
+ self, "raw_resets_at_utc", frozenset(self.raw_resets_at_utc))
109
+ object.__setattr__(
110
+ self, "identified_accounts", frozenset(self.identified_accounts))
111
+
112
+ @property
113
+ def axes(self) -> tuple:
114
+ return (self.source_root_key, self.logical_limit_key,
115
+ self.observed_slot, self.window_minutes)
116
+
117
+ @property
118
+ def in_scope(self) -> bool:
119
+ return (self.window_minutes == ACCOUNT_WEEKLY_WINDOW_MINUTES
120
+ and not self.model_scoped)
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class AssertionResolution:
125
+ """What one assertion resolves to against the current population."""
126
+
127
+ op_id: str
128
+ account_key: str
129
+ outcome: str
130
+ group_key: "tuple | None" = None
131
+ matched_group_count: int = 0
132
+ conflicting_account_keys: "frozenset[str]" = frozenset()
133
+
134
+ def __post_init__(self) -> None:
135
+ object.__setattr__(
136
+ self, "conflicting_account_keys",
137
+ frozenset(self.conflicting_account_keys))
138
+
139
+ @property
140
+ def applies(self) -> bool:
141
+ return self.outcome == RESOLVED
142
+
143
+
144
+ def resolve_window_attributions(
145
+ assertions: Iterable[WindowAssertion],
146
+ groups: Iterable[WindowGroup],
147
+ ) -> "tuple[tuple[AssertionResolution, ...], Mapping[tuple, str]]":
148
+ """Resolve every assertion against COMPLETE current group evidence.
149
+
150
+ Returns ``(resolutions, ownership)`` where ``ownership`` maps a group key to
151
+ the account that assertion supplies. Only ``RESOLVED`` assertions appear in
152
+ ``ownership``.
153
+
154
+ Precedence, in order (spec §7):
155
+
156
+ 1. Zero matching groups is DORMANT — recorded, honest, applying to nothing.
157
+ This is the correct outcome when the underlying rollout evidence has
158
+ evaporated; the alternative would be fabricating a window to attach to.
159
+ 2. More than one matching group is SPLIT. The component has since divided in
160
+ a way the assertion cannot adjudicate, so it applies nothing.
161
+ 3. Model-scoped and non-weekly groups are out of scope entirely (#373).
162
+ 4. Native evidence always wins. A group already naming a real account is
163
+ authoritative and is never re-assigned, so the assertion is suppressed
164
+ rather than fighting it. This holds whether the native account agrees or
165
+ disagrees; a matching one simply has nothing to do.
166
+ 5. Two assertions naming DIFFERENT accounts for one group FAIL CLOSED and
167
+ neither applies. Journal order is the wrong tiebreaker for an operator
168
+ assertion: a stale or mistaken second assertion would silently displace a
169
+ correct first one with no signal. A visible conflict forces a retraction.
170
+
171
+ Order-independent in its verdicts: the conflict pass below runs after every
172
+ assertion has been matched, so two assertions disagreeing about one group
173
+ reach ``SUPPRESSED_CONFLICT`` whichever order they arrive in.
174
+ """
175
+ group_list = tuple(groups)
176
+ resolutions: "list[AssertionResolution]" = []
177
+ claims: "dict[tuple, set[str]]" = {}
178
+
179
+ for assertion in assertions:
180
+ candidates = [
181
+ group for group in group_list
182
+ if group.axes == assertion.axes
183
+ and (group.raw_resets_at_utc & assertion.raw_resets_at_utc)
184
+ ]
185
+ if not candidates:
186
+ resolutions.append(AssertionResolution(
187
+ op_id=assertion.op_id, account_key=assertion.account_key,
188
+ outcome=DORMANT))
189
+ continue
190
+ if len(candidates) > 1:
191
+ resolutions.append(AssertionResolution(
192
+ op_id=assertion.op_id, account_key=assertion.account_key,
193
+ outcome=SPLIT, matched_group_count=len(candidates)))
194
+ continue
195
+ group = candidates[0]
196
+ if not group.in_scope:
197
+ resolutions.append(AssertionResolution(
198
+ op_id=assertion.op_id, account_key=assertion.account_key,
199
+ outcome=SUPPRESSED_MODEL_SCOPED, group_key=group.group_key,
200
+ matched_group_count=1))
201
+ continue
202
+ if group.identified_accounts:
203
+ resolutions.append(AssertionResolution(
204
+ op_id=assertion.op_id, account_key=assertion.account_key,
205
+ outcome=SUPPRESSED_NATIVE, group_key=group.group_key,
206
+ matched_group_count=1,
207
+ conflicting_account_keys=group.identified_accounts))
208
+ continue
209
+ claims.setdefault(group.group_key, set()).add(assertion.account_key)
210
+ resolutions.append(AssertionResolution(
211
+ op_id=assertion.op_id, account_key=assertion.account_key,
212
+ outcome=RESOLVED, group_key=group.group_key, matched_group_count=1))
213
+
214
+ final: "list[AssertionResolution]" = []
215
+ ownership: "dict[tuple, str]" = {}
216
+ for resolution in resolutions:
217
+ if not resolution.applies:
218
+ final.append(resolution)
219
+ continue
220
+ claimants = claims.get(resolution.group_key, set())
221
+ if len(claimants) > 1:
222
+ final.append(replace(
223
+ resolution, outcome=SUPPRESSED_CONFLICT,
224
+ conflicting_account_keys=frozenset(claimants)))
225
+ continue
226
+ ownership[resolution.group_key] = resolution.account_key
227
+ final.append(resolution)
228
+ return tuple(final), ownership
229
+
230
+
231
+ def apply_resolution(
232
+ ownership: "Mapping[tuple, str]",
233
+ observations: Iterable,
234
+ group_key_of,
235
+ account_key_of,
236
+ with_account,
237
+ ) -> tuple:
238
+ """Stamp resolved ownership onto currently-unattributed observations.
239
+
240
+ Deliberately caller-parameterized on ``group_key_of`` / ``account_key_of`` /
241
+ ``with_account`` so this leaf never imports the observation type. Runs
242
+ BEFORE the ordinary continuity fold, which then finds nothing left to adopt
243
+ in an attributed group.
244
+
245
+ An already-identified observation is never re-stamped, matching
246
+ ``adopt_unidentified_observations``. ``None`` and the empty string are
247
+ admitted as unattributed alongside the literal sentinel, because the cache
248
+ column is nullable and all three spellings mean the same thing on the read
249
+ path (``entry_is_unattributed`` states the same rule for the spend axis).
250
+ """
251
+ result = []
252
+ for observation in observations:
253
+ account = account_key_of(observation)
254
+ if account and account != UNATTRIBUTED_SENTINEL:
255
+ result.append(observation)
256
+ continue
257
+ owner = ownership.get(group_key_of(observation))
258
+ result.append(with_account(observation, owner) if owner else observation)
259
+ return tuple(result)