cctally 1.89.2 → 1.90.1

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.
@@ -115,9 +115,42 @@ CACHE_REPORT_MIN_BASELINE_DAYS = 5
115
115
  CACHE_REPORT_MIN_BASELINE_SESSIONS = 10
116
116
 
117
117
 
118
- # Literal alias mirroring TS `CacheAnomalyReason` at
119
- # dashboard/web/src/types/envelope.ts:71 keeps the two surfaces in
120
- # lockstep so a typo on either side fails type-check.
118
+ # Anomaly-threshold bounds and default. #443 S3 F17: four sites used to
119
+ # decide independently what `cache_report.anomaly_threshold_pp` means
120
+ # the Claude read path, the Codex read path, the persistence gate, and
121
+ # the client form. The three Python sites now resolve through here; the
122
+ # TypeScript form's bounds are pinned by
123
+ # tests/test_cache_report_constant_parity.py.
124
+ CACHE_REPORT_DEFAULT_THRESHOLD_PP = 15
125
+ CACHE_REPORT_THRESHOLD_MIN_PP = 1
126
+ CACHE_REPORT_THRESHOLD_MAX_PP = 100
127
+
128
+
129
+ def cache_report_threshold_is_valid(raw: object) -> bool:
130
+ """True when ``raw`` is an in-range int. ``bool`` is not an int here."""
131
+ return (
132
+ isinstance(raw, int)
133
+ and not isinstance(raw, bool)
134
+ and CACHE_REPORT_THRESHOLD_MIN_PP <= raw <= CACHE_REPORT_THRESHOLD_MAX_PP
135
+ )
136
+
137
+
138
+ def resolve_cache_report_threshold(raw: object) -> int:
139
+ """Read-path resolution: strict and silent.
140
+
141
+ Anything not an in-range int resolves to the default. Callers run
142
+ inside the per-tick dashboard build loop, so this never warns.
143
+ """
144
+ if cache_report_threshold_is_valid(raw):
145
+ return int(raw)
146
+ return CACHE_REPORT_DEFAULT_THRESHOLD_PP
147
+
148
+
149
+ # Mirrored in TypeScript as the `CacheAnomalyReason` union in
150
+ # dashboard/web/src/types/envelope.ts. The two are kept in lockstep by
151
+ # test_ts_anomaly_reason_union_matches_the_python_literal in
152
+ # tests/test_cache_report_constant_parity.py — NOT by type-check: a
153
+ # Literal-only change on this side compiles fine on both.
121
154
  CacheAnomalyReason = Literal["net_negative", "cache_drop"]
122
155
  # Every predicate _classify_anomalies can run, in reason-append order. The
123
156
  # TypeScript mirror is CACHE_ANOMALY_PREDICATES in cacheReportVerdict.ts.
@@ -219,6 +252,14 @@ class _Bucket:
219
252
  zero. The breakdown aggregator only populates the token + cache-$
220
253
  fields (``output_tokens`` / ``cost`` stay zero); that's fine — the
221
254
  by-project / by-model paths don't surface them.
255
+
256
+ The three cache-dollar totals are NOT accumulated with ``+=``.
257
+ Producers append each addend via ``add_cache_dollars`` and call
258
+ ``resolve_cache_dollars`` once (#443 S3 F20/F21): a left-to-right
259
+ float fold over near-cancelling addends is order-dependent, and
260
+ ``net_usd`` feeds the strict ``< 0`` ``net_negative`` predicate, so
261
+ entry order alone could flip an anomaly verdict. The token and
262
+ ``cost`` accumulators stay ``+=`` — ints, or a float no verdict reads.
222
263
  """
223
264
  input_tokens: int = 0
224
265
  output_tokens: int = 0
@@ -228,6 +269,21 @@ class _Bucket:
228
269
  saved_usd: float = 0.0
229
270
  wasted_usd: float = 0.0
230
271
  net_usd: float = 0.0
272
+ saved_parts: list[float] = field(default_factory=list)
273
+ wasted_parts: list[float] = field(default_factory=list)
274
+ net_parts: list[float] = field(default_factory=list)
275
+
276
+ def add_cache_dollars(self, saved: float, wasted: float, net: float) -> None:
277
+ """Retain one addend triple; nothing is folded until resolve."""
278
+ self.saved_parts.append(saved)
279
+ self.wasted_parts.append(wasted)
280
+ self.net_parts.append(net)
281
+
282
+ def resolve_cache_dollars(self) -> None:
283
+ """Collapse the retained addends into order-independent totals."""
284
+ self.saved_usd = stable_sum(self.saved_parts)
285
+ self.wasted_usd = stable_sum(self.wasted_parts)
286
+ self.net_usd = stable_sum(self.net_parts)
231
287
 
232
288
 
233
289
  @dataclass(frozen=True)
@@ -462,9 +518,7 @@ def _aggregate_cache_by_day(
462
518
  b.cache_creation_tokens += create_tok
463
519
  b.cache_read_tokens += read_tok
464
520
  b.cost += cost
465
- b.saved_usd += saved
466
- b.wasted_usd += wasted
467
- b.net_usd += net
521
+ b.add_cache_dollars(saved, wasted, net)
468
522
 
469
523
  result: list[CacheRow] = []
470
524
  for day_key in sorted(day_model_buckets.keys()):
@@ -472,6 +526,7 @@ def _aggregate_cache_by_day(
472
526
  row = CacheRow(date=day_key)
473
527
  for model_name in sorted(models.keys()):
474
528
  b = models[model_name]
529
+ b.resolve_cache_dollars()
475
530
  mb = CacheModelBreakdown(
476
531
  model_name=model_name,
477
532
  input_tokens=b.input_tokens,
@@ -492,9 +547,12 @@ def _aggregate_cache_by_day(
492
547
  row.cache_creation_tokens += mb.cache_creation_tokens
493
548
  row.cache_read_tokens += mb.cache_read_tokens
494
549
  row.cost += mb.cost
495
- row.saved_usd += mb.saved_usd
496
- row.wasted_usd += mb.wasted_usd
497
- row.net_usd += mb.net_usd
550
+ # #443 S3 F21: the model-to-row fold, stably summed. Both layers,
551
+ # or neither works — the order-dependence originates one layer
552
+ # below, in the same-model entry fold above.
553
+ row.saved_usd = stable_sum(mb.saved_usd for mb in row.model_breakdowns)
554
+ row.wasted_usd = stable_sum(mb.wasted_usd for mb in row.model_breakdowns)
555
+ row.net_usd = stable_sum(mb.net_usd for mb in row.model_breakdowns)
498
556
  result.append(row)
499
557
  return result
500
558
 
@@ -632,9 +690,7 @@ def _aggregate_cache_by_session(
632
690
  cache_1h_tokens=getattr(entry, "cache_1h_tokens", None),
633
691
  speed=getattr(entry, "speed", None),
634
692
  )
635
- mb_raw.saved_usd += saved
636
- mb_raw.wasted_usd += wasted
637
- mb_raw.net_usd += net
693
+ mb_raw.add_cache_dollars(saved, wasted, net)
638
694
 
639
695
  row = CacheRow(
640
696
  session_id=sid,
@@ -644,6 +700,7 @@ def _aggregate_cache_by_session(
644
700
  )
645
701
  for model_name in sorted(model_buckets.keys()):
646
702
  mb_raw = model_buckets[model_name]
703
+ mb_raw.resolve_cache_dollars()
647
704
  mb = CacheModelBreakdown(
648
705
  model_name=model_name,
649
706
  input_tokens=mb_raw.input_tokens,
@@ -666,9 +723,10 @@ def _aggregate_cache_by_session(
666
723
  row.cache_creation_tokens += mb.cache_creation_tokens
667
724
  row.cache_read_tokens += mb.cache_read_tokens
668
725
  row.cost += mb.cost
669
- row.saved_usd += mb.saved_usd
670
- row.wasted_usd += mb.wasted_usd
671
- row.net_usd += mb.net_usd
726
+ # #443 S3 F21 — the model-to-row fold; see the day aggregator.
727
+ row.saved_usd = stable_sum(mb.saved_usd for mb in row.model_breakdowns)
728
+ row.wasted_usd = stable_sum(mb.wasted_usd for mb in row.model_breakdowns)
729
+ row.net_usd = stable_sum(mb.net_usd for mb in row.model_breakdowns)
672
730
  result.append(row)
673
731
 
674
732
  # Initial ordering descending by last_activity; the CLI's
@@ -693,6 +751,13 @@ def _row_anchor(r: CacheRow) -> dt.datetime | None:
693
751
  gives the correct offset for the given date — avoids DST drift on
694
752
  dates that straddle a DST boundary. Mirrors the idiom in
695
753
  ``_parse_cli_date_range``.
754
+
755
+ Since #443 S3 F22 the BASELINE only reaches this for session rows:
756
+ daily windowing compares calendar dates directly, precisely because
757
+ a per-date correct offset and an elapsed ``timedelta(days=N)`` bound
758
+ disagree across a transition. The daily branch stays as the shared
759
+ definition of "where a daily row sits in time" — it is what
760
+ ``_sort_cache_rows``'s tiebreaker mirrors.
696
761
  """
697
762
  if r.last_activity is not None:
698
763
  return r.last_activity
@@ -702,45 +767,82 @@ def _row_anchor(r: CacheRow) -> dt.datetime | None:
702
767
  return None
703
768
 
704
769
 
705
- def _compute_baseline_median(
770
+ def _baseline_samples(
706
771
  rows: list[CacheRow],
707
772
  *,
708
- anchor: dt.datetime,
709
773
  window_days: int,
710
- min_samples: int,
774
+ anchor: dt.datetime | None = None,
775
+ anchor_date: dt.date | None = None,
711
776
  exclude_row: CacheRow | None = None,
712
777
  is_session_mode: bool = False,
713
- ) -> float | None:
714
- """Median ``cache_hit_percent`` across rows whose anchor falls in
715
- ``[anchor − window_days, anchor − upper_offset]``.
716
-
717
- Returns ``None`` when fewer than ``min_samples`` rows qualify. The
718
- upper offset is ``1s`` in session mode (recent sessions stay
719
- eligible even when they collide on the second) and ``1d`` in daily
720
- mode (yesterday IS in the baseline but today is excluded).
721
-
722
- ``exclude_row`` lets the per-row classifier skip the focal row when
723
- computing the baseline median for that row — without this, a row's
724
- own hit % would self-include in its baseline. Callers passing the
725
- cross-row "median over the whole window" (e.g. the dashboard
726
- spotlight) leave ``exclude_row=None``.
727
- """
728
- import statistics
778
+ ) -> list[float]:
779
+ """Every ``cache_hit_percent`` admitted to the baseline window.
729
780
 
730
- upper_offset = (
731
- dt.timedelta(seconds=1) if is_session_mode else dt.timedelta(days=1)
732
- )
733
- lower_bound = anchor - dt.timedelta(days=window_days)
734
- upper_bound = anchor - upper_offset
781
+ ONE definition of the population, so a published sample count and the
782
+ median cannot disagree (#443 S3 F22).
783
+
784
+ ``exclude_row`` lets the per-row classifier skip the focal row —
785
+ without it, a row's own hit % would self-include in its baseline.
786
+ Callers wanting the cross-row "median over the whole window" (the
787
+ dashboard spotlight) leave it ``None``.
788
+ """
735
789
  values: list[float] = []
790
+ if is_session_mode:
791
+ # Session anchors are real timestamps, so elapsed-time windowing
792
+ # is correct here. The upper offset is 1s rather than 1d so
793
+ # recent sessions stay eligible even when they collide on the
794
+ # second.
795
+ if anchor is None:
796
+ raise ValueError("session mode requires an anchor instant")
797
+ lower = anchor - dt.timedelta(days=window_days)
798
+ upper = anchor - dt.timedelta(seconds=1)
799
+ for r in rows:
800
+ if exclude_row is not None and r is exclude_row:
801
+ continue
802
+ ra = _row_anchor(r)
803
+ if ra is not None and lower <= ra <= upper:
804
+ values.append(r.cache_hit_percent)
805
+ return values
806
+
807
+ # Daily mode compares CALENDAR DATES. Daily anchors are date
808
+ # midnights that follow DST, so an elapsed ``timedelta(days=N)`` bound
809
+ # excluded a row exactly N calendar days old whenever the UTC offset
810
+ # changed inside the window — the row landed one hour outside and the
811
+ # panel then read "baseline sufficient" from a count the median did
812
+ # not share (#443 S3 F22). ``anchor_date - 1`` is the upper bound
813
+ # because yesterday IS in the baseline but today is excluded.
814
+ if anchor_date is None:
815
+ raise ValueError("daily mode requires an anchor_date")
816
+ lower_date = anchor_date - dt.timedelta(days=window_days)
817
+ upper_date = anchor_date - dt.timedelta(days=1)
736
818
  for r in rows:
737
819
  if exclude_row is not None and r is exclude_row:
738
820
  continue
739
- ra = _row_anchor(r)
740
- if ra is None:
821
+ if not r.date:
741
822
  continue
742
- if lower_bound <= ra <= upper_bound:
823
+ rd = dt.date.fromisoformat(r.date)
824
+ if lower_date <= rd <= upper_date:
743
825
  values.append(r.cache_hit_percent)
826
+ return values
827
+
828
+
829
+ def _compute_baseline_median(
830
+ rows: list[CacheRow],
831
+ *,
832
+ window_days: int,
833
+ min_samples: int,
834
+ anchor: dt.datetime | None = None,
835
+ anchor_date: dt.date | None = None,
836
+ exclude_row: CacheRow | None = None,
837
+ is_session_mode: bool = False,
838
+ ) -> float | None:
839
+ """Median over ``_baseline_samples``; ``None`` below ``min_samples``."""
840
+ import statistics
841
+
842
+ values = _baseline_samples(
843
+ rows, window_days=window_days, anchor=anchor, anchor_date=anchor_date,
844
+ exclude_row=exclude_row, is_session_mode=is_session_mode,
845
+ )
744
846
  if len(values) < min_samples:
745
847
  return None
746
848
  return statistics.median(values)
@@ -787,8 +889,11 @@ def _classify_anomalies(
787
889
  else CACHE_REPORT_MIN_BASELINE_DAYS
788
890
  )
789
891
 
790
- # Pre-compute anchors once to avoid O(n²·datetime-parse) overhead.
791
- anchors: list[dt.datetime | None] = [_row_anchor(r) for r in rows]
892
+ # Session-mode only: the daily path anchors on the row's own date
893
+ # string instead (#443 S3 F22), so it has nothing to pre-compute.
894
+ anchors: list[dt.datetime | None] = (
895
+ [_row_anchor(r) for r in rows] if is_session_mode else []
896
+ )
792
897
 
793
898
  for i, row in enumerate(rows):
794
899
  reasons: list[CacheAnomalyReason] = []
@@ -802,13 +907,20 @@ def _classify_anomalies(
802
907
  unevaluated.append("net_negative")
803
908
 
804
909
  # Trigger 2: cache_drop (requires baseline).
805
- anchor = anchors[i]
806
910
  median = None
807
- if anchor is not None:
911
+ if is_session_mode:
912
+ anchor = anchors[i]
913
+ if anchor is not None:
914
+ median = _compute_baseline_median(
915
+ rows, anchor=anchor,
916
+ window_days=window_days, min_samples=min_baseline,
917
+ exclude_row=row, is_session_mode=True,
918
+ )
919
+ elif row.date:
808
920
  median = _compute_baseline_median(
809
- rows, anchor=anchor,
921
+ rows, anchor_date=dt.date.fromisoformat(row.date),
810
922
  window_days=window_days, min_samples=min_baseline,
811
- exclude_row=row, is_session_mode=is_session_mode,
923
+ exclude_row=row, is_session_mode=False,
812
924
  )
813
925
  if median is None:
814
926
  unevaluated.append("cache_drop")
@@ -921,12 +1033,11 @@ def _aggregate_cache_breakdown(
921
1033
  cache_1h_tokens=getattr(e, "cache_1h_tokens", None),
922
1034
  speed=getattr(e, "speed", None),
923
1035
  )
924
- b.saved_usd += saved
925
- b.wasted_usd += wasted
926
- b.net_usd += net
1036
+ b.add_cache_dollars(saved, wasted, net)
927
1037
 
928
1038
  out: list[CacheBreakdownRow] = []
929
1039
  for key, b in buckets.items():
1040
+ b.resolve_cache_dollars() # #443 S3 F21 — the per-entry fold.
930
1041
  out.append(CacheBreakdownRow(
931
1042
  key=key,
932
1043
  cache_hit_percent=_compute_cache_hit_percent(
@@ -972,10 +1083,13 @@ def _aggregate_cache_breakdown_from_rows(
972
1083
  b.input_tokens += mb.input_tokens
973
1084
  b.cache_creation_tokens += mb.cache_creation_tokens
974
1085
  b.cache_read_tokens += mb.cache_read_tokens
975
- b.net_usd += mb.net_usd
1086
+ # #443 S3 F21 — the cross-row model fold, retained then
1087
+ # stably summed below.
1088
+ b.add_cache_dollars(mb.saved_usd, mb.wasted_usd, mb.net_usd)
976
1089
 
977
1090
  out: list[CacheBreakdownRow] = []
978
1091
  for key, b in buckets.items():
1092
+ b.resolve_cache_dollars()
979
1093
  out.append(CacheBreakdownRow(
980
1094
  key=key,
981
1095
  cache_hit_percent=_compute_cache_hit_percent(
@@ -1319,8 +1433,14 @@ class _CacheReportResult:
1319
1433
  "other" rows (excluding today's row) over the trailing
1320
1434
  ``anomaly_window_days`` — populated in day mode only (session mode
1321
1435
  has no equivalent "today" concept). Surfaced here so the dashboard
1322
- snapshot builder can read it without re-running
1323
- ``_compute_baseline_median`` over the same data (EFF-3).
1436
+ snapshot builder can read it without re-running the baseline over the
1437
+ same data (EFF-3).
1438
+
1439
+ ``today_baseline_sample_count`` is how many rows that median was
1440
+ taken over. Both publishers used to count every non-today row instead,
1441
+ unbounded by the baseline window, so the count could report a
1442
+ sufficient baseline while the median was absent (#443 S3 F22). Derived
1443
+ from one population here so the two cannot diverge again.
1324
1444
  """
1325
1445
  rows: list[CacheRow]
1326
1446
  mode: Literal["day", "session"]
@@ -1329,6 +1449,7 @@ class _CacheReportResult:
1329
1449
  anomaly_window_days: int
1330
1450
  display_tz_key: str | None
1331
1451
  today_baseline_median: float | None = None
1452
+ today_baseline_sample_count: int = 0
1332
1453
 
1333
1454
 
1334
1455
  def _aggregate_cache_report_rows(
@@ -1397,30 +1518,37 @@ def classify_and_summarize(
1397
1518
  )
1398
1519
 
1399
1520
  # EFF-3: surface today's baseline median directly on the result so
1400
- # the dashboard snapshot builder doesn't have to re-run
1401
- # _compute_baseline_median over the same row set. Day-mode only —
1402
- # session mode has no equivalent "today" anchor concept. Anchor
1403
- # construction mirrors the pre-EFF-3 adapter byte-for-byte —
1404
- # the strptime + astimezone(display_tz_or_UTC) pair treats the
1405
- # naive parsed datetime as host-local before shifting, which IS
1406
- # the prior contract; do not change without re-verifying the
1407
- # dashboard envelope's today.baseline_median_percent stays stable
1408
- # against the existing golden fixtures.
1521
+ # the dashboard snapshot builder doesn't have to re-run the baseline
1522
+ # over the same row set. Day-mode only — session mode has no
1523
+ # equivalent "today" concept.
1524
+ #
1525
+ # One current day per invocation (#443 S3 F23): ``today_iso`` resolves
1526
+ # through the SAME ``_resolve_bucket_tz`` the row keys were bucketed by,
1527
+ # so the focal day cannot name a date the rows are not keyed by. It
1528
+ # previously fell back to UTC while bucketing fell back to host-local,
1529
+ # which diverged on every non-UTC host.
1530
+ #
1531
+ # One baseline population per invocation (#443 S3 F22): the published
1532
+ # sample count is derived from the samples the median was taken over,
1533
+ # so "count says the baseline is sufficient, median says it is absent"
1534
+ # is unrepresentable rather than merely absent.
1409
1535
  today_baseline_median: float | None = None
1536
+ today_baseline_sample_count = 0
1410
1537
  if mode == "day":
1411
1538
  today_iso = now_utc.astimezone(
1412
- display_tz if display_tz is not None else dt.timezone.utc
1539
+ _resolve_bucket_tz(display_tz)
1413
1540
  ).strftime("%Y-%m-%d")
1414
- today_anchor = dt.datetime.strptime(today_iso, "%Y-%m-%d").astimezone(
1415
- display_tz if display_tz is not None else dt.timezone.utc
1416
- )
1417
1541
  other_rows = [r for r in rows if r.date != today_iso]
1418
- today_baseline_median = _compute_baseline_median(
1542
+ samples = _baseline_samples(
1419
1543
  other_rows,
1420
- anchor=today_anchor,
1544
+ anchor_date=dt.date.fromisoformat(today_iso),
1421
1545
  window_days=anomaly_window_days,
1422
- min_samples=CACHE_REPORT_MIN_BASELINE_DAYS,
1546
+ is_session_mode=False,
1423
1547
  )
1548
+ today_baseline_sample_count = len(samples)
1549
+ if len(samples) >= CACHE_REPORT_MIN_BASELINE_DAYS:
1550
+ import statistics
1551
+ today_baseline_median = statistics.median(samples)
1424
1552
 
1425
1553
  return _CacheReportResult(
1426
1554
  rows=rows,
@@ -1430,6 +1558,7 @@ def classify_and_summarize(
1430
1558
  anomaly_window_days=anomaly_window_days,
1431
1559
  display_tz_key=display_tz.key if display_tz is not None else None,
1432
1560
  today_baseline_median=today_baseline_median,
1561
+ today_baseline_sample_count=today_baseline_sample_count,
1433
1562
  )
1434
1563
 
1435
1564
 
@@ -0,0 +1,184 @@
1
+ """The one builder for every cache-report envelope block.
2
+
3
+ Before #443 S2 this dict was hand-built at three sites — the Claude
4
+ serializer and both returns of the Codex wire — with nothing enforcing a
5
+ shared key set, which is how the Codex empty return and its populated
6
+ return drifted apart (#443 F18).
7
+
8
+ Provider parameterization resolves exactly four things and nothing else:
9
+ the percent key(s), whether the not-applicable metadata is emitted, the
10
+ applicable predicate set, and the reason text. Everything else is
11
+ provider-independent by construction.
12
+
13
+ Every field this module adds for Codex is OPTIONAL and CODEX-ONLY.
14
+ Absence carries the Claude meaning. That is what keeps the Claude
15
+ serializer byte-identical and the dashboard goldens' Claude blocks
16
+ unmoved, which is the safety property the F18 refactor rests on.
17
+ """
18
+
19
+ CLAUDE_PREDICATES = ("net_negative", "cache_drop")
20
+ CODEX_PREDICATES = ("cache_drop",)
21
+
22
+ # Every predicate any provider can carry. Claude happens to span all of
23
+ # them today, but the filter below must key on THIS, not on
24
+ # CLAUDE_PREDICATES: if Claude's set ever shrank, another provider's set
25
+ # could become a superset of it and the filter would silently degrade to a
26
+ # no-op for that provider — a filter that discards nothing, which is the
27
+ # exact failure `filter_inapplicable` exists to prevent.
28
+ ALL_PREDICATES = tuple(
29
+ dict.fromkeys(CLAUDE_PREDICATES + CODEX_PREDICATES)
30
+ )
31
+
32
+ # Codex figures that are structurally absent rather than unmeasured.
33
+ # OpenAI charges no cache-write premium, so there is nothing to waste and
34
+ # therefore no ratio of saved to wasted. The VALUES stay numeric through
35
+ # the transition release (a pre-S2 tab calls .toFixed on the first and
36
+ # Math.round on the second); this map is the authoritative signal a
37
+ # current client renders from.
38
+ #
39
+ # Switching these to None, removing the transitional `cache_hit_percent`
40
+ # in _percent, and bumping SOURCE_SCHEMA_VERSION to 4 are ONE release's
41
+ # work, tracked in cctally-dev#465. Doing any of them alone reintroduces
42
+ # the stale-tab crash this retention exists to avoid.
43
+ CODEX_NOT_APPLICABLE = {
44
+ "wasted_usd": "OpenAI charges no cache-write premium, so Codex has no wasted-cache figure.",
45
+ "fourteen_day_efficiency_ratio": "Efficiency compares saved against wasted, and Codex has no wasted-cache figure.",
46
+ }
47
+
48
+ _PREDICATES = {"claude": CLAUDE_PREDICATES, "codex": CODEX_PREDICATES}
49
+
50
+
51
+ def applicable_predicates(provider):
52
+ """Return the anomaly predicates that can apply to ``provider``."""
53
+ try:
54
+ return _PREDICATES[provider]
55
+ except KeyError:
56
+ raise ValueError(f"unknown cache-report provider: {provider!r}") from None
57
+
58
+
59
+ def filter_inapplicable(provider, row):
60
+ """Drop predicates that do not apply to ``provider`` and re-derive.
61
+
62
+ Filtering is an ACTIVE step, not an assumption. Today no Codex row can
63
+ carry `net_negative` — saved is floored at zero and wasted is
64
+ hard-zero — but that is a property of how entries are built, not of
65
+ this builder, and a test that only observed the absence would pass
66
+ over a builder that discards nothing.
67
+
68
+ `anomaly_triggered` is RECOMPUTED from the surviving reasons rather
69
+ than carried through: keeping a True flag whose only reason was
70
+ filtered away would publish a verdict with nothing behind it.
71
+ """
72
+ applicable = set(applicable_predicates(provider))
73
+ if applicable.issuperset(ALL_PREDICATES):
74
+ return row
75
+ out = dict(row)
76
+ out["anomaly_reasons"] = [r for r in row["anomaly_reasons"] if r in applicable]
77
+ out["anomaly_unevaluated"] = [
78
+ r for r in row["anomaly_unevaluated"] if r in applicable
79
+ ]
80
+ out["anomaly_triggered"] = bool(out["anomaly_reasons"])
81
+ return out
82
+
83
+
84
+ def _percent(provider, row):
85
+ """Emit the percent key(s) for one row-shaped mapping.
86
+
87
+ Codex dual-publishes: `cached_input_percent` is authoritative and
88
+ `cache_hit_percent` carries the identical value for one release, so a
89
+ tab that spans a `cctally update` keeps reading a true number instead
90
+ of `Math.floor(NaN)`.
91
+ """
92
+ value = row["cache_hit_percent"]
93
+ if provider == "codex":
94
+ return {"cached_input_percent": value, "cache_hit_percent": value}
95
+ return {"cache_hit_percent": value}
96
+
97
+
98
+ def _today_block(provider, today):
99
+ today = filter_inapplicable(provider, today)
100
+ block = {"date": today["date"]}
101
+ block.update(_percent(provider, today))
102
+ block.update({
103
+ "baseline_median_percent": today["baseline_median_percent"],
104
+ "delta_pp": today["delta_pp"],
105
+ "net_usd": today["net_usd"],
106
+ "saved_usd": today["saved_usd"],
107
+ "wasted_usd": today["wasted_usd"],
108
+ "anomaly_triggered": today["anomaly_triggered"],
109
+ "anomaly_reasons": list(today["anomaly_reasons"]),
110
+ "baseline_daily_row_count": today["baseline_daily_row_count"],
111
+ "anomaly_unevaluated": list(today["anomaly_unevaluated"]),
112
+ "observed": today["observed"],
113
+ })
114
+ return block
115
+
116
+
117
+ def _day_block(provider, d):
118
+ d = filter_inapplicable(provider, d)
119
+ block = {"date": d["date"]}
120
+ block.update(_percent(provider, d))
121
+ block.update({
122
+ "input_tokens": d["input_tokens"],
123
+ "output_tokens": d["output_tokens"],
124
+ "cache_creation_tokens": d["cache_creation_tokens"],
125
+ "cache_read_tokens": d["cache_read_tokens"],
126
+ "saved_usd": d["saved_usd"],
127
+ "wasted_usd": d["wasted_usd"],
128
+ "net_usd": d["net_usd"],
129
+ "anomaly_triggered": d["anomaly_triggered"],
130
+ "anomaly_reasons": list(d["anomaly_reasons"]),
131
+ "anomaly_unevaluated": list(d["anomaly_unevaluated"]),
132
+ "observed": d["observed"],
133
+ })
134
+ return block
135
+
136
+
137
+ def _breakdown_block(provider, b):
138
+ block = {"key": b["key"]}
139
+ block.update(_percent(provider, b))
140
+ block["net_usd"] = b["net_usd"]
141
+ return block
142
+
143
+
144
+ def build_cache_report_wire(
145
+ *, provider, window_days, anomaly_threshold_pp, anomaly_window_days,
146
+ today, days, by_project, by_model, seven_day_net_usd,
147
+ seven_day_anomaly_count, fourteen_day_counterfactual_usd,
148
+ fourteen_day_efficiency_ratio, is_empty,
149
+ ):
150
+ """Serialize one cache-report block for ``provider``.
151
+
152
+ ``days`` MUST arrive newest-first and already capped at
153
+ ``window_days`` — the Codex ``seven_day_anomaly_count`` reconciliation
154
+ below slices ``[:7]`` positionally, so an oldest-first caller would get
155
+ a wrong count with no error raised.
156
+ """
157
+ applicable_predicates(provider) # validates
158
+ day_blocks = [_day_block(provider, d) for d in days]
159
+ out = {
160
+ "window_days": window_days,
161
+ "anomaly_threshold_pp": anomaly_threshold_pp,
162
+ "anomaly_window_days": anomaly_window_days,
163
+ "today": _today_block(provider, today),
164
+ "days": day_blocks,
165
+ "by_project": [_breakdown_block(provider, b) for b in by_project],
166
+ "by_model": [_breakdown_block(provider, b) for b in by_model],
167
+ "seven_day_net_usd": seven_day_net_usd,
168
+ "seven_day_anomaly_count": seven_day_anomaly_count,
169
+ "fourteen_day_counterfactual_usd": fourteen_day_counterfactual_usd,
170
+ "fourteen_day_efficiency_ratio": fourteen_day_efficiency_ratio,
171
+ "is_empty": is_empty,
172
+ }
173
+ if provider == "codex":
174
+ out["not_applicable"] = dict(CODEX_NOT_APPLICABLE)
175
+ out["anomaly_predicates"] = list(CODEX_PREDICATES)
176
+ # The caller counted anomalies BEFORE inapplicable predicates were
177
+ # dropped, so its number can outlive every verdict behind it.
178
+ # Reconciled only where the filter can actually change something:
179
+ # on Claude the filter is the identity, and re-deriving there
180
+ # would risk moving a value the byte-stability pin protects.
181
+ out["seven_day_anomaly_count"] = sum(
182
+ bool(block["anomaly_triggered"]) for block in day_blocks[:7]
183
+ )
184
+ return out
package/bin/cctally CHANGED
@@ -967,6 +967,7 @@ ProdMigrationRefused = _cctally_db.ProdMigrationRefused
967
967
  StatsDbCorruptError = _cctally_db.StatsDbCorruptError
968
968
  StatsDbMaintenanceError = _cctally_db.StatsDbMaintenanceError
969
969
  StatsEpochMismatchError = _cctally_db.StatsEpochMismatchError
970
+ StatsEpochRebuildDeferred = _cctally_db.StatsEpochRebuildDeferred
970
971
  _is_sqlite_corruption_error = _cctally_db._is_sqlite_corruption_error
971
972
  _stats_corruption_guidance = _cctally_db._stats_corruption_guidance
972
973
  _STATS_MIGRATIONS = _cctally_db._STATS_MIGRATIONS
@@ -988,6 +989,14 @@ _reconcile_durable_applied_migration_errors = (
988
989
  )
989
990
  _render_migration_error_banner = _cctally_db._render_migration_error_banner
990
991
  _BANNER_SUPPRESSED_COMMANDS = _cctally_db._BANNER_SUPPRESSED_COMMANDS
992
+
993
+ # Readable wrong-epoch stats indexes are converged only by this dedicated
994
+ # hidden worker. Core imports the same module at call time, so parser dispatch,
995
+ # ordinary open deferral, and the worker share one scheduler state.
996
+ _cctally_store = _load_sibling("_cctally_store")
997
+ cmd_stats_epoch_rebuild_internal = (
998
+ _cctally_store.cmd_stats_epoch_rebuild_internal
999
+ )
991
1000
  _print_migration_error_banner_if_needed = _cctally_db._print_migration_error_banner_if_needed
992
1001
  cmd_db_status = _cctally_db.cmd_db_status
993
1002
  _db_status_for = _cctally_db._db_status_for
@@ -3341,6 +3350,9 @@ def main(argv: list[str] | None = None) -> int:
3341
3350
  # generic DatabaseError so it never renders as a raw traceback.
3342
3351
  eprint(f"cctally: {exc}")
3343
3352
  return 3
3353
+ except StatsEpochRebuildDeferred as exc:
3354
+ eprint(f"cctally: {exc}")
3355
+ return 3
3344
3356
  except AccountAttributionUnavailable as exc:
3345
3357
  # #341: a `--account`-scoped entry read hit a query-time cache degrade
3346
3358
  # (open failure / concurrent ingest) that would drop the account
@@ -3460,8 +3472,8 @@ def _post_command_update_hooks(command: str | None, args) -> None:
3460
3472
  # still cctally-dev at this point). Same rationale class as doctor.
3461
3473
  return
3462
3474
  if command in ("_update-check", "_telemetry-beat", "_codex-quota-verify",
3463
- "_codex-replay-drain"):
3464
- # All four hidden workers are detached and have already done their one job
3475
+ "_stats-epoch-rebuild", "_codex-replay-drain"):
3476
+ # All five hidden workers are detached and have already done their one job
3465
3477
  # in their own command handler; none must re-enter this hook.
3466
3478
  # Without the guard the ``_update-check`` worker would fall through to
3467
3479
  # the telemetry gate below and (throttle-bounded) spawn a