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.
- package/CHANGELOG.md +47 -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 +960 -55
- package/bin/_cctally_dashboard_envelope.py +43 -23
- package/bin/_cctally_dashboard_share.py +50 -1
- package/bin/_cctally_dashboard_sources.py +1580 -131
- 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 +811 -67
- package/bin/_lib_aggregators.py +117 -11
- package/bin/_lib_alert_axes.py +8 -3
- package/bin/_lib_budget.py +60 -0
- package/bin/_lib_codex_window_attribution.py +259 -0
- package/bin/_lib_dashboard_sources.py +488 -19
- 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 +248 -7
- package/bin/_lib_source_analytics.py +20 -2
- package/bin/cctally +25 -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-BSESoPIK.css +0 -1
- package/dashboard/static/assets/index-DgsMz5hA.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_alert_axes.py
CHANGED
|
@@ -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).
|
|
59
|
-
#
|
|
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", "
|
|
69
|
+
"codex_budget", "BUDGET", "Codex budget", "budget_milestones", vendor="codex"
|
|
65
70
|
),
|
|
66
71
|
)
|
|
67
72
|
|
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)
|