cctally 1.98.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.
- package/CHANGELOG.md +23 -0
- package/README.md +4 -4
- package/bin/_cctally_account.py +925 -0
- package/bin/_cctally_cache.py +829 -31
- package/bin/_cctally_core.py +52 -0
- package/bin/_cctally_dashboard.py +375 -19
- package/bin/_cctally_dashboard_share.py +50 -1
- package/bin/_cctally_dashboard_sources.py +1501 -118
- package/bin/_cctally_db.py +810 -34
- package/bin/_cctally_doctor.py +184 -1
- package/bin/_cctally_journal.py +732 -76
- package/bin/_cctally_parser.py +65 -0
- package/bin/_cctally_quota.py +896 -17
- package/bin/_cctally_rederive.py +157 -5
- package/bin/_cctally_source_analytics.py +60 -6
- package/bin/_cctally_tui.py +508 -47
- package/bin/_lib_aggregators.py +117 -11
- package/bin/_lib_budget.py +60 -0
- package/bin/_lib_codex_window_attribution.py +259 -0
- package/bin/_lib_dashboard_sources.py +26 -1
- package/bin/_lib_doctor.py +71 -0
- package/bin/_lib_journal.py +212 -0
- package/bin/_lib_jsonl.py +6 -0
- package/bin/_lib_rederive.py +8 -0
- package/bin/_lib_snapshot_cache.py +236 -7
- package/bin/_lib_source_analytics.py +20 -2
- package/bin/cctally +12 -0
- package/dashboard/static/assets/index-Bcbm-DNP.js +97 -0
- package/dashboard/static/assets/index-hJP4wlIO.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-CC8TTZUC.css +0 -1
- package/dashboard/static/assets/index-CChXFhs_.js +0 -97
package/bin/_lib_aggregators.py
CHANGED
|
@@ -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 =
|
|
480
|
-
entry.
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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 =
|
|
663
|
-
entry.
|
|
664
|
-
|
|
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
|
package/bin/_lib_budget.py
CHANGED
|
@@ -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)
|
|
@@ -68,7 +68,32 @@ CapabilityStatus = Literal[
|
|
|
68
68
|
# remains present, so a pre-v7 client reading `created_at` keeps working — but
|
|
69
69
|
# the VALUE it reads changed on two of the three Codex legs, which is why this
|
|
70
70
|
# is a version bump and not a silent addition.
|
|
71
|
-
|
|
71
|
+
# 7 -> 8 (#556 S5): the Claude provider's `capabilities.budget` detail changed
|
|
72
|
+
# MEANING. It said `subscription-week` unconditionally, as a constant, while
|
|
73
|
+
# Codex advertised `calendar-period`; it now names the CONFIGURED period, so on
|
|
74
|
+
# an install with `budget.period = calendar-month` the same field reads
|
|
75
|
+
# `calendar-month`. `docs/cli-contract.md:58` says an optional additive key
|
|
76
|
+
# alone does not bump but a changed value or meaning does, which is exactly how
|
|
77
|
+
# S1, S2 and S3 justified theirs. The Claude source also gained an optional
|
|
78
|
+
# `data.budget.status` — the same object Codex publishes — plus its
|
|
79
|
+
# `status_unavailable` / `not_configured` siblings, and the Codex budget domain
|
|
80
|
+
# gained the same optional `status_unavailable` sibling. Those are additive and
|
|
81
|
+
# omitted when inapplicable, so an install with no budget configured publishes
|
|
82
|
+
# byte-identical bytes and the bump rests on the capability detail alone.
|
|
83
|
+
# `sources.all.capabilities.budget` is a DIFFERENT field and is unchanged at
|
|
84
|
+
# `not_applicable` / `provider-native`: rendering two provider-native child
|
|
85
|
+
# budgets side by side does not create an All-level budget quantity.
|
|
86
|
+
# 8 -> 9 (#564): on a decorated Codex provider, a card whose totals do not come
|
|
87
|
+
# from a live weekly cycle — a real account whose boundary is not live, and the
|
|
88
|
+
# unattributed sentinel — now covers one native cycle width ending at `now`
|
|
89
|
+
# instead of the whole ~30-day accounting range. `accounts[].spendUsd`, its five
|
|
90
|
+
# token siblings, and the decorated `hero.cost_usd` / `hero.total_tokens` summed
|
|
91
|
+
# from them therefore changed VALUE without changing shape, which is the same
|
|
92
|
+
# class of change the 4 -> 5 entry records. Those cards additionally publish the
|
|
93
|
+
# optional `spendWindow` bounds, which a cycle-bounded card omits. No client
|
|
94
|
+
# branches on this number; after an in-place `execvp` update a still-loaded old
|
|
95
|
+
# client renders the new figures under old copy until it reloads.
|
|
96
|
+
SOURCE_SCHEMA_VERSION = 9
|
|
72
97
|
DEFAULT_SOURCE = "claude"
|
|
73
98
|
SOURCE_ORDER = ("claude", "codex", "all")
|
|
74
99
|
SOURCE_FRESHNESS_DOMAINS = ("hero", "quota", "sessions")
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -3351,6 +3351,75 @@ def _check_accounts_codex_reset_anchors(s: DoctorState) -> CheckResult:
|
|
|
3351
3351
|
)
|
|
3352
3352
|
|
|
3353
3353
|
|
|
3354
|
+
#: The `accounts.codex_window_attribution` conditions, in the order the summary
|
|
3355
|
+
#: names them. Each is resolved by retracting or re-asserting, which is why the
|
|
3356
|
+
#: leg is WARN in every case rather than FAIL: none of them is a broken install.
|
|
3357
|
+
_WINDOW_ATTRIBUTION_CONDITIONS: tuple[tuple[str, str], ...] = (
|
|
3358
|
+
("cursor_behind", "the derived index is behind the journal"),
|
|
3359
|
+
("dormant", "dormant assertion(s) matching no current window group"),
|
|
3360
|
+
("split", "assertion(s) matching more than one group after a split"),
|
|
3361
|
+
("conflicting", "assertion(s) in conflict with native evidence or each other"),
|
|
3362
|
+
("model_scoped", "assertion(s) over a window that is not account weekly quota"),
|
|
3363
|
+
("unrecoverable_baselines",
|
|
3364
|
+
"rollout file(s) whose per-file decision cannot be recovered"),
|
|
3365
|
+
)
|
|
3366
|
+
|
|
3367
|
+
|
|
3368
|
+
def _check_accounts_codex_window_attribution(s: DoctorState) -> CheckResult:
|
|
3369
|
+
"""Operator attribution of recorded Codex windows (#500 §9).
|
|
3370
|
+
|
|
3371
|
+
WARN in every case. The four spec conditions are a stale derived cursor
|
|
3372
|
+
(attribution is being UNDER-applied), a dormant assertion, a split
|
|
3373
|
+
assertion, and an assertion in conflict; the fifth and sixth are a
|
|
3374
|
+
model-scoped window an assertion can never file as account weekly quota, and
|
|
3375
|
+
a rollout file whose per-file baseline cannot be recovered — which matters
|
|
3376
|
+
because a retraction restores spend to that baseline, so an unrecoverable
|
|
3377
|
+
one is silently indistinguishable from "no decision was ever made".
|
|
3378
|
+
"""
|
|
3379
|
+
st = (s.accounts_state or {}).get("codex_window_attribution")
|
|
3380
|
+
if not isinstance(st, dict):
|
|
3381
|
+
# No cache.db, a cache too old to carry the derived table, or a probe
|
|
3382
|
+
# this run declined. Nothing to under-apply, so nothing to report.
|
|
3383
|
+
return CheckResult(
|
|
3384
|
+
id="accounts.codex_window_attribution",
|
|
3385
|
+
title="Codex window attribution", severity="ok",
|
|
3386
|
+
summary="no operator window attribution recorded",
|
|
3387
|
+
remediation=None, details={"active": 0, "retracted": 0},
|
|
3388
|
+
)
|
|
3389
|
+
details = dict(st)
|
|
3390
|
+
findings = []
|
|
3391
|
+
for name, phrase in _WINDOW_ATTRIBUTION_CONDITIONS:
|
|
3392
|
+
value = st.get(name)
|
|
3393
|
+
if name == "cursor_behind":
|
|
3394
|
+
if value:
|
|
3395
|
+
findings.append(phrase)
|
|
3396
|
+
continue
|
|
3397
|
+
try:
|
|
3398
|
+
count = int(value or 0)
|
|
3399
|
+
except (TypeError, ValueError):
|
|
3400
|
+
count = 0
|
|
3401
|
+
if count > 0:
|
|
3402
|
+
findings.append(f"{count} {phrase}")
|
|
3403
|
+
if not findings:
|
|
3404
|
+
return CheckResult(
|
|
3405
|
+
id="accounts.codex_window_attribution",
|
|
3406
|
+
title="Codex window attribution", severity="ok",
|
|
3407
|
+
summary=(f"{int(st.get('active') or 0)} active window "
|
|
3408
|
+
"attribution(s), all applying cleanly"),
|
|
3409
|
+
remediation=None, details=details,
|
|
3410
|
+
)
|
|
3411
|
+
return CheckResult(
|
|
3412
|
+
id="accounts.codex_window_attribution",
|
|
3413
|
+
title="Codex window attribution", severity="warn",
|
|
3414
|
+
summary="; ".join(findings),
|
|
3415
|
+
remediation=(
|
|
3416
|
+
"Run `cctally account attribute <ref> --since <iso> --retract` to "
|
|
3417
|
+
"clear an assertion that no longer applies, or re-assert it over "
|
|
3418
|
+
"the group it should name"),
|
|
3419
|
+
details=details,
|
|
3420
|
+
)
|
|
3421
|
+
|
|
3422
|
+
|
|
3354
3423
|
_CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...] = (
|
|
3355
3424
|
("install", "Install", (
|
|
3356
3425
|
("install.mode", "_check_install_dev_mode"),
|
|
@@ -3423,6 +3492,8 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
3423
3492
|
("accounts.freshness", "_check_accounts_freshness"),
|
|
3424
3493
|
("accounts.attribution", "_check_accounts_attribution"),
|
|
3425
3494
|
("accounts.codex_reset_anchors", "_check_accounts_codex_reset_anchors"),
|
|
3495
|
+
("accounts.codex_window_attribution",
|
|
3496
|
+
"_check_accounts_codex_window_attribution"),
|
|
3426
3497
|
)),
|
|
3427
3498
|
("pricing", "Pricing", (
|
|
3428
3499
|
("pricing.coverage", "_check_pricing_coverage"),
|