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.
- package/CHANGELOG.md +25 -0
- package/bin/_cctally_cache_report.py +10 -5
- package/bin/_cctally_core.py +22 -4
- package/bin/_cctally_dashboard.py +158 -1
- package/bin/_cctally_dashboard_cache_report.py +56 -46
- package/bin/_cctally_dashboard_envelope.py +8 -0
- package/bin/_cctally_dashboard_share.py +2 -0
- package/bin/_cctally_dashboard_sources.py +82 -37
- package/bin/_cctally_db.py +27 -0
- package/bin/_cctally_parser.py +20 -0
- package/bin/_cctally_record.py +8 -38
- package/bin/_cctally_statusline.py +21 -2
- package/bin/_cctally_store.py +200 -0
- package/bin/_cctally_tui.py +33 -9
- package/bin/_lib_cache_report.py +199 -70
- package/bin/_lib_cache_report_wire.py +184 -0
- package/bin/cctally +14 -2
- package/dashboard/static/assets/{index-Ub8vwz1M.css → index-Bar8-S1i.css} +1 -1
- package/dashboard/static/assets/index-CRogVlEC.js +92 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-BgoYXdus.js +0 -92
package/bin/_lib_cache_report.py
CHANGED
|
@@ -115,9 +115,42 @@ CACHE_REPORT_MIN_BASELINE_DAYS = 5
|
|
|
115
115
|
CACHE_REPORT_MIN_BASELINE_SESSIONS = 10
|
|
116
116
|
|
|
117
117
|
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
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.
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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.
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
|
770
|
+
def _baseline_samples(
|
|
706
771
|
rows: list[CacheRow],
|
|
707
772
|
*,
|
|
708
|
-
anchor: dt.datetime,
|
|
709
773
|
window_days: int,
|
|
710
|
-
|
|
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
|
|
714
|
-
"""
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
740
|
-
if ra is None:
|
|
821
|
+
if not r.date:
|
|
741
822
|
continue
|
|
742
|
-
|
|
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
|
-
#
|
|
791
|
-
|
|
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
|
|
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,
|
|
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=
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1402
|
-
#
|
|
1403
|
-
#
|
|
1404
|
-
#
|
|
1405
|
-
#
|
|
1406
|
-
# the
|
|
1407
|
-
#
|
|
1408
|
-
#
|
|
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
|
|
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
|
-
|
|
1542
|
+
samples = _baseline_samples(
|
|
1419
1543
|
other_rows,
|
|
1420
|
-
|
|
1544
|
+
anchor_date=dt.date.fromisoformat(today_iso),
|
|
1421
1545
|
window_days=anomaly_window_days,
|
|
1422
|
-
|
|
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
|
|
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
|