cctally 1.65.0 → 1.67.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +6 -4
  3. package/bin/_cctally_alerts.py +1 -1
  4. package/bin/_cctally_cache.py +203 -22
  5. package/bin/_cctally_cache_report.py +7 -5
  6. package/bin/_cctally_core.py +125 -14
  7. package/bin/_cctally_dashboard.py +495 -4893
  8. package/bin/_cctally_dashboard_cache_report.py +714 -0
  9. package/bin/_cctally_dashboard_conversation.py +830 -0
  10. package/bin/_cctally_dashboard_envelope.py +1520 -0
  11. package/bin/_cctally_dashboard_share.py +2012 -0
  12. package/bin/_cctally_db.py +158 -5
  13. package/bin/_cctally_doctor.py +104 -3
  14. package/bin/_cctally_five_hour.py +19 -3
  15. package/bin/_cctally_forecast.py +118 -161
  16. package/bin/_cctally_parser.py +1003 -777
  17. package/bin/_cctally_percent_breakdown.py +1 -1
  18. package/bin/_cctally_pricing_check.py +39 -0
  19. package/bin/_cctally_project.py +18 -42
  20. package/bin/_cctally_record.py +279 -274
  21. package/bin/_cctally_reporting.py +1 -1
  22. package/bin/_cctally_setup.py +150 -43
  23. package/bin/_cctally_sync_week.py +1 -1
  24. package/bin/_cctally_telemetry.py +3 -5
  25. package/bin/_cctally_transcript.py +191 -0
  26. package/bin/_cctally_tui.py +42 -18
  27. package/bin/_lib_alert_dispatch.py +1 -1
  28. package/bin/_lib_blocks.py +6 -1
  29. package/bin/_lib_conversation_anon.py +254 -0
  30. package/bin/_lib_conversation_query.py +66 -0
  31. package/bin/_lib_credit.py +133 -0
  32. package/bin/_lib_diff_kernel.py +19 -7
  33. package/bin/_lib_doctor.py +150 -1
  34. package/bin/_lib_five_hour.py +61 -0
  35. package/bin/_lib_forecast.py +144 -0
  36. package/bin/_lib_json_envelope.py +38 -0
  37. package/bin/_lib_jsonl.py +115 -73
  38. package/bin/_lib_log.py +96 -0
  39. package/bin/_lib_pricing.py +47 -1
  40. package/bin/_lib_pricing_check.py +25 -3
  41. package/bin/_lib_record.py +179 -0
  42. package/bin/_lib_render.py +26 -11
  43. package/bin/_lib_snapshot_cache.py +61 -0
  44. package/bin/_lib_view_models.py +7 -1
  45. package/bin/cctally +59 -7
  46. package/bin/cctally-dashboard +2 -2
  47. package/bin/cctally-statusline +1 -0
  48. package/bin/cctally-transcript +5 -0
  49. package/bin/cctally-tui +2 -2
  50. package/dashboard/static/assets/index-BybNp_Di.js +80 -0
  51. package/dashboard/static/assets/{index-DQWNrIqu.css → index-DgAmLK65.css} +1 -1
  52. package/dashboard/static/dashboard.html +2 -2
  53. package/package.json +13 -1
  54. package/dashboard/static/assets/index-CJTUCpKt.js +0 -80
@@ -0,0 +1,714 @@
1
+ """Dashboard/TUI cache-report snapshot builder (#279 S5 F3).
2
+
3
+ Consumer-only sibling of ``bin/_cctally_dashboard.py`` — it re-imports every
4
+ name below, so ``bin/cctally``'s re-exports and the direct
5
+ ``sys.modules["_cctally_dashboard"].X`` reaches (TUI ``bin/_cctally_tui.py``,
6
+ ``bin/cctally-reconcile-test``, the pytest sites) keep resolving unchanged
7
+ (spec §2 re-export continuity).
8
+
9
+ Distinct from two same-named neighbours — keep them straight:
10
+
11
+ - ``bin/_cctally_cache_report.py`` — the ``cache-report`` CLI command.
12
+ - ``bin/_lib_cache_report.py`` — that command's pure kernel, which THIS
13
+ module loads at call time via ``_cache_report_load_kernel`` →
14
+ ``sys.modules["cctally"]._load_sibling("_lib_cache_report")``.
15
+
16
+ Nothing here has module-level side effects. Kernel/pricing symbols import
17
+ from their decentralized homes (``_lib_fmt`` / ``_lib_pricing``); the sole
18
+ dashboard-module-object reach is ``get_claude_session_entries``, resolved
19
+ late-binding via ``sys.modules["_cctally_dashboard"]`` at call time so the
20
+ rebuild-parity tests' spy (they patch that name on the dashboard module
21
+ object) is preserved (spec §5 gate P1-2).
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import datetime as dt
26
+ import sys
27
+ from dataclasses import dataclass
28
+
29
+ from _lib_fmt import stable_sum
30
+ from _lib_pricing import _calculate_entry_cost
31
+
32
+
33
+ # === Cache-report settings validator (spec 2026-05-21 §6) ================
34
+ # Validates the optional ``config.json:cache_report`` block. Strict in
35
+ # v1: only ``anomaly_threshold_pp`` is settable, must be a plain int in
36
+ # ``[1, 100]`` (bool / float / string rejected — bool because it's an
37
+ # int subclass in Python and quietly accepting ``true`` for a numeric
38
+ # field is exactly the trip-up ``_validate_update_check_ttl_hours_value``
39
+ # protects against). HTTP write path raises ``_CacheReportConfigError``
40
+ # → ``_handle_post_settings`` maps to HTTP 400 + ``{error, field}``
41
+ # (matches the existing handler convention at lines 4587-4602; spec
42
+ # explicitly says 400, NOT 422).
43
+
44
+ @dataclass(frozen=True)
45
+ class _CacheReportSettings:
46
+ anomaly_threshold_pp: int
47
+
48
+
49
+ class _CacheReportConfigError(Exception):
50
+ """Validation error for the ``cache_report`` config block.
51
+
52
+ ``field`` carries the offending key path (``anomaly_threshold_pp`` or
53
+ the unknown-key name) so the JSON 400 response can surface it.
54
+ """
55
+ def __init__(self, message: str, *, field: str | None = None):
56
+ super().__init__(message)
57
+ self.field = field
58
+
59
+
60
+ _CACHE_REPORT_ALLOWED_KEYS = frozenset({"anomaly_threshold_pp"})
61
+
62
+
63
+ def _validate_cache_report_settings(block: dict) -> dict:
64
+ """Validate a ``cache_report`` config block.
65
+
66
+ Pure function. Raises ``_CacheReportConfigError`` on invalid input;
67
+ returns a dict containing ONLY the keys that were present in the
68
+ input (validated). Callers merge the result into the existing
69
+ persisted block instead of replacing it wholesale — this mirrors
70
+ the ``update.check`` partial-PUT pattern at
71
+ ``_handle_post_settings`` (~line 5277) and prevents a combined save
72
+ that omits ``anomaly_threshold_pp`` from clobbering a previously
73
+ persisted user value with the default.
74
+
75
+ v1 only accepts ``anomaly_threshold_pp`` — ``anomaly_window_days``
76
+ stays hardcoded at 14 (spec §6.1; F10 from spec §10 tracks adding
77
+ a configurable baseline window along with the UI-copy work).
78
+ """
79
+ if not isinstance(block, dict):
80
+ raise _CacheReportConfigError(
81
+ "cache_report must be an object", field="cache_report",
82
+ )
83
+ for key in block:
84
+ if key not in _CACHE_REPORT_ALLOWED_KEYS:
85
+ raise _CacheReportConfigError(
86
+ f"unknown key in cache_report block: {key!r}",
87
+ field=key,
88
+ )
89
+ validated: dict = {}
90
+ if "anomaly_threshold_pp" in block:
91
+ threshold = block["anomaly_threshold_pp"]
92
+ # bool is an int subclass — reject it explicitly (mirrors the
93
+ # update.check.ttl_hours precedent).
94
+ if isinstance(threshold, bool) or not isinstance(threshold, int):
95
+ raise _CacheReportConfigError(
96
+ "anomaly_threshold_pp must be an integer",
97
+ field="anomaly_threshold_pp",
98
+ )
99
+ if threshold < 1 or threshold > 100:
100
+ raise _CacheReportConfigError(
101
+ "anomaly_threshold_pp must be in [1, 100]",
102
+ field="anomaly_threshold_pp",
103
+ )
104
+ validated["anomaly_threshold_pp"] = threshold
105
+ return validated
106
+
107
+
108
+ # === Cache-report envelope dataclasses (spec 2026-05-21) =================
109
+ # Snake_case fields are emitted verbatim into the SSE envelope so the React
110
+ # store can read ``state.cache_report.<field>`` without a key-transform pass
111
+ # (the envelope is intentionally snake_case end-to-end; see
112
+ # ``dashboard/web/src/types/envelope.ts:189``). Built by
113
+ # ``build_cache_report_snapshot()`` and shipped on the existing 5-minute
114
+ # sync cadence — no separate ``/api/cache-report`` endpoint.
115
+
116
+ # Hardcoded for v1; F10 tracks lifting via cache_report.anomaly_window_days config.
117
+ CACHE_REPORT_WINDOW_DAYS = 14
118
+ # Two concepts that happen to share a value today: the data window the
119
+ # panel renders vs. the baseline window the anomaly classifier reads
120
+ # back over. Split so F10 can lift the latter without dragging the
121
+ # former along.
122
+ CACHE_REPORT_ANOMALY_WINDOW_DAYS = 14
123
+
124
+ @dataclass(frozen=True)
125
+ class CacheReportDailyRow:
126
+ date: str # YYYY-MM-DD in display tz
127
+ cache_hit_percent: float
128
+ input_tokens: int
129
+ output_tokens: int
130
+ cache_creation_tokens: int
131
+ cache_read_tokens: int
132
+ saved_usd: float
133
+ wasted_usd: float
134
+ net_usd: float
135
+ anomaly_triggered: bool
136
+ anomaly_reasons: tuple[str, ...]
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class CacheReportBreakdownRow:
141
+ """One row of the by-project / by-model breakdown sub-cards."""
142
+ key: str
143
+ cache_hit_percent: float
144
+ net_usd: float
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class CacheReportTodaySpotlight:
149
+ """Today's spotlight card: hit %, baseline-median, Δ vs baseline,
150
+ cumulative net / saved / wasted, anomaly state, and the count of
151
+ baseline daily rows so the React panel can gate the
152
+ "Building baseline · N/5 days" insufficient-baseline state."""
153
+ date: str
154
+ cache_hit_percent: float
155
+ baseline_median_percent: float | None
156
+ delta_pp: float | None
157
+ net_usd: float
158
+ saved_usd: float
159
+ wasted_usd: float
160
+ anomaly_triggered: bool
161
+ anomaly_reasons: tuple[str, ...]
162
+ baseline_daily_row_count: int
163
+
164
+
165
+ def _cache_report_snapshot_to_dict(cr: "CacheReportSnapshot | None") -> "dict | None":
166
+ """Serialize a ``CacheReportSnapshot`` to the SSE envelope dict.
167
+
168
+ Returns ``None`` when the snapshot is ``None`` (first tick before
169
+ sync, or sub-build failure recorded on ``last_sync_error``). Snake-
170
+ case keys throughout — the envelope is intentionally snake_case end
171
+ -to-end per ``envelope.ts:189`` (no ``to_camel`` pass). Tuples are
172
+ flattened to lists for JSON palatability.
173
+ """
174
+ if cr is None:
175
+ return None
176
+ return {
177
+ "window_days": cr.window_days,
178
+ "anomaly_threshold_pp": cr.anomaly_threshold_pp,
179
+ "anomaly_window_days": cr.anomaly_window_days,
180
+ "today": {
181
+ "date": cr.today.date,
182
+ "cache_hit_percent": cr.today.cache_hit_percent,
183
+ "baseline_median_percent": cr.today.baseline_median_percent,
184
+ "delta_pp": cr.today.delta_pp,
185
+ "net_usd": cr.today.net_usd,
186
+ "saved_usd": cr.today.saved_usd,
187
+ "wasted_usd": cr.today.wasted_usd,
188
+ "anomaly_triggered": cr.today.anomaly_triggered,
189
+ "anomaly_reasons": list(cr.today.anomaly_reasons),
190
+ "baseline_daily_row_count": cr.today.baseline_daily_row_count,
191
+ },
192
+ "days": [
193
+ {
194
+ "date": d.date,
195
+ "cache_hit_percent": d.cache_hit_percent,
196
+ "input_tokens": d.input_tokens,
197
+ "output_tokens": d.output_tokens,
198
+ "cache_creation_tokens": d.cache_creation_tokens,
199
+ "cache_read_tokens": d.cache_read_tokens,
200
+ "saved_usd": d.saved_usd,
201
+ "wasted_usd": d.wasted_usd,
202
+ "net_usd": d.net_usd,
203
+ "anomaly_triggered": d.anomaly_triggered,
204
+ "anomaly_reasons": list(d.anomaly_reasons),
205
+ }
206
+ for d in cr.days
207
+ ],
208
+ "by_project": [
209
+ {
210
+ "key": b.key,
211
+ "cache_hit_percent": b.cache_hit_percent,
212
+ "net_usd": b.net_usd,
213
+ }
214
+ for b in cr.by_project
215
+ ],
216
+ "by_model": [
217
+ {
218
+ "key": b.key,
219
+ "cache_hit_percent": b.cache_hit_percent,
220
+ "net_usd": b.net_usd,
221
+ }
222
+ for b in cr.by_model
223
+ ],
224
+ "seven_day_net_usd": cr.seven_day_net_usd,
225
+ "seven_day_anomaly_count": cr.seven_day_anomaly_count,
226
+ "fourteen_day_counterfactual_usd": cr.fourteen_day_counterfactual_usd,
227
+ "fourteen_day_efficiency_ratio": cr.fourteen_day_efficiency_ratio,
228
+ "is_empty": cr.is_empty,
229
+ }
230
+
231
+
232
+ @dataclass(frozen=True)
233
+ class CacheReportSnapshot:
234
+ """The complete cache-report envelope block.
235
+
236
+ ``days`` is newest-first, length ``≤ window_days``. ``by_project`` /
237
+ ``by_model`` are sorted by ``abs(net_usd)`` descending and capped at
238
+ 6 entries (top 5 + ``(other)``). ``window_days`` is hardcoded at 14
239
+ in v1; ``anomaly_threshold_pp`` is read from
240
+ ``config.json:cache_report.anomaly_threshold_pp`` (default 15) via
241
+ the dashboard sync thread.
242
+ """
243
+ window_days: int
244
+ anomaly_threshold_pp: int
245
+ anomaly_window_days: int
246
+ today: CacheReportTodaySpotlight
247
+ days: tuple[CacheReportDailyRow, ...]
248
+ by_project: tuple[CacheReportBreakdownRow, ...]
249
+ by_model: tuple[CacheReportBreakdownRow, ...]
250
+ seven_day_net_usd: float
251
+ seven_day_anomaly_count: int
252
+ fourteen_day_counterfactual_usd: float
253
+ fourteen_day_efficiency_ratio: float
254
+ is_empty: bool
255
+
256
+
257
+ # === Cache-report snapshot builder (spec 2026-05-21 §5.2) ================
258
+ # Adapter from the I/O layer (``get_claude_session_entries`` +
259
+ # ``CLAUDE_MODEL_PRICING`` + ``_calculate_entry_cost``) into the kernel's
260
+ # pure ``_build_cache_report`` orchestrator. By-project + by-model
261
+ # breakdowns dedup through the kernel's ``_aggregate_cache_breakdown``
262
+ # (one path, one ``<synthetic>`` filter rule) so the two axes can't
263
+ # silently disagree on token totals when a session has both real and
264
+ # synthetic entries on the same project.
265
+
266
+ def _cache_report_load_kernel():
267
+ """Lazy-load ``_lib_cache_report`` via the cctally ``_load_sibling``
268
+ bridge so monkeypatch-driven test reloads of cctally see the same
269
+ kernel module instance (matches the late-load pattern used by share /
270
+ doctor helpers in this file)."""
271
+ return sys.modules["cctally"]._load_sibling("_lib_cache_report")
272
+
273
+
274
+ def _cache_report_needed_closed_dates(since, now_utc, bucket_tz):
275
+ """The CLOSED display-tz dates a full-window cache-report build needs (#272 §6).
276
+
277
+ Every display-tz calendar date overlapping ``[since, now_utc]`` EXCEPT the
278
+ current open day (Codex-2: a ``since``-straddling window yields up to
279
+ ``window_days + 1`` display dates, and classification runs over the full
280
+ set BEFORE the ``days`` cap). Returned sorted ascending. The ``have_all``
281
+ gate requires every one of these to be cached before serving the warm
282
+ (today-only-fetch) path. A genuinely activity-free calendar day produces
283
+ no per-day fold row, so the cold/miss populate branch stores an
284
+ ``is_empty`` sentinel (``empty_cached_day``) for every needed closed date
285
+ the fold didn't emit — registering the quiet day as a cache HIT (the same
286
+ computed-empty registration ``projects_env_week_put`` does for an empty
287
+ week). That keeps ``have_all`` reachable, so a weekend/vacation gap day
288
+ does NOT permanently defeat the warm path; the sentinel is skipped in the
289
+ restitch, so the output stays byte-identical to from-scratch.
290
+ """
291
+ today_key = now_utc.astimezone(bucket_tz).strftime("%Y-%m-%d")
292
+ d = since.astimezone(bucket_tz).date()
293
+ end_date = now_utc.astimezone(bucket_tz).date()
294
+ out: list = []
295
+ while d <= end_date:
296
+ key = d.strftime("%Y-%m-%d")
297
+ if key != today_key:
298
+ out.append(key)
299
+ d += dt.timedelta(days=1)
300
+ return out
301
+
302
+
303
+ def _day_start(date_iso, bucket_tz):
304
+ """The aware-UTC instant of ``date_iso``'s local midnight in ``bucket_tz`` (#272 §6).
305
+
306
+ Lower-bounds the warm-path current-day fetch so it returns exactly the
307
+ entries ``_aggregate_cache_by_day`` buckets to ``date_iso`` (which buckets
308
+ via ``entry.timestamp.astimezone(bucket_tz)``). DST-correct: attaching the
309
+ zone to the naive local-midnight then converting to UTC lets ``ZoneInfo``
310
+ pick the right offset for that wall time.
311
+ """
312
+ naive = dt.datetime.strptime(date_iso, "%Y-%m-%d")
313
+ return naive.replace(tzinfo=bucket_tz).astimezone(dt.timezone.utc)
314
+
315
+
316
+ def _cache_report_empty(
317
+ *, today_iso, window_days, anomaly_threshold_pp, anomaly_window_days,
318
+ ):
319
+ """The empty (no in-window entries) ``CacheReportSnapshot`` — factored so the
320
+ warm and cold builder paths share one ``is_empty`` return (#272 §6)."""
321
+ empty_today = CacheReportTodaySpotlight(
322
+ date=today_iso,
323
+ cache_hit_percent=0.0,
324
+ baseline_median_percent=None,
325
+ delta_pp=None,
326
+ net_usd=0.0, saved_usd=0.0, wasted_usd=0.0,
327
+ anomaly_triggered=False,
328
+ anomaly_reasons=(),
329
+ baseline_daily_row_count=0,
330
+ )
331
+ return CacheReportSnapshot(
332
+ window_days=window_days,
333
+ anomaly_threshold_pp=anomaly_threshold_pp,
334
+ anomaly_window_days=anomaly_window_days,
335
+ today=empty_today,
336
+ days=(), by_project=(), by_model=(),
337
+ seven_day_net_usd=0.0,
338
+ seven_day_anomaly_count=0,
339
+ fourteen_day_counterfactual_usd=0.0,
340
+ fourteen_day_efficiency_ratio=0.0,
341
+ is_empty=True,
342
+ )
343
+
344
+
345
+ def build_cache_report_snapshot(
346
+ *,
347
+ now_utc: dt.datetime,
348
+ anomaly_threshold_pp: int,
349
+ anomaly_window_days: int,
350
+ display_tz: "ZoneInfo | None",
351
+ skip_sync: bool = False,
352
+ use_cache_report_cache: bool = False,
353
+ ) -> CacheReportSnapshot:
354
+ """Build the ``cache_report`` envelope field from the session-entry cache.
355
+
356
+ Pulls entries via ``get_claude_session_entries`` (uses the cache when
357
+ warm, falls back to direct-JSONL parse on cache miss / lock
358
+ contention — same chain the CLI uses). Aggregates per closed day via
359
+ ``_lib_cache_report.build_cached_days`` (served from the per-day cache
360
+ when ``use_cache_report_cache`` and reconciled), reconstructs fresh
361
+ rows (``reconstruct_cache_row``), runs the cross-row
362
+ ``classify_and_summarize`` + ``combine_day_project_partials`` restitch,
363
+ and shapes the result into a frozen ``CacheReportSnapshot``.
364
+
365
+ ``window_days`` is hardcoded at 14 in v1 (spec §6.1 hardcodes
366
+ ``anomaly_window_days`` too; ``anomaly_threshold_pp`` is the only
367
+ user-configurable knob). F10 from spec §10 tracks making the window
368
+ configurable, plus the UI-copy work it'd require.
369
+
370
+ **Per-day cache (#272 §6).** With ``use_cache_report_cache=True`` (set by
371
+ the dashboard sync thread AFTER ``reconcile_cache_report_cache`` succeeds),
372
+ the CLOSED days are served from the immutable per-day cache and only the
373
+ current (open) day is fetched + folded fresh — the warm-tick win. The
374
+ reconcile (in ``_tui_build_snapshot``) owns invalidation; this builder only
375
+ reads/populates the cache. With the flag OFF (default / safe fallback), the
376
+ full window is fetched every tick and the output is byte-identical to the
377
+ cached path (same restructured assembly, cache bypassed).
378
+ """
379
+ crk = _cache_report_load_kernel()
380
+ cctally_ns = sys.modules["cctally"]
381
+ _sc = sys.modules["_lib_snapshot_cache"]
382
+
383
+ window_days = CACHE_REPORT_WINDOW_DAYS # v1: hardcoded per spec §6.1.
384
+ since = now_utc - dt.timedelta(days=window_days)
385
+ pricing = cctally_ns.CLAUDE_MODEL_PRICING
386
+
387
+ # Cache mechanics key on the BUCKETING tz (``_resolve_bucket_tz`` — the same
388
+ # tz ``_aggregate_cache_by_day`` buckets by), so ``today_key`` / the closed-
389
+ # day keys line up with ``CacheRow.date``. The spotlight's ``today_iso``
390
+ # keeps its own UTC-fallback derivation unchanged (byte-identity); the two
391
+ # agree whenever ``display_tz`` is set — always, on the dashboard.
392
+ bucket_tz = crk._resolve_bucket_tz(display_tz)
393
+ today_key = now_utc.astimezone(bucket_tz).strftime("%Y-%m-%d")
394
+ today_iso = now_utc.astimezone(
395
+ display_tz if display_tz is not None else dt.timezone.utc
396
+ ).strftime("%Y-%m-%d")
397
+
398
+ def _wrap_day_entries(raw):
399
+ # Day-mode kernel expects entries with a ``usage`` dict (matches
400
+ # ``UsageEntry``); ``get_claude_session_entries`` returns flat
401
+ # ``_JoinedClaudeEntry`` objects. SimpleNamespace keeps the wrapper
402
+ # pure-Python and avoids a new dataclass type just for the bridge.
403
+ from types import SimpleNamespace as _NS
404
+ return [
405
+ _NS(
406
+ timestamp=e.timestamp,
407
+ model=e.model,
408
+ cost_usd=e.cost_usd,
409
+ usage={
410
+ "input_tokens": e.input_tokens,
411
+ "output_tokens": e.output_tokens,
412
+ "cache_creation_input_tokens": e.cache_creation_tokens,
413
+ "cache_read_input_tokens": e.cache_read_tokens,
414
+ },
415
+ )
416
+ for e in raw
417
+ ]
418
+
419
+ # Which CLOSED display-tz dates the window needs (every date overlapping
420
+ # [since, now] except the current open day). If the cache holds them ALL,
421
+ # fetch only the current day (the warm win); else one full-window fetch
422
+ # (re)populates the closed days + recomputes today.
423
+ needed_closed = _cache_report_needed_closed_dates(since, now_utc, bucket_tz)
424
+ have_all = use_cache_report_cache and all(
425
+ _sc.cache_report_day_get(d) is not None for d in needed_closed
426
+ )
427
+
428
+ cached_days: dict = {} # date_key -> CachedCacheReportDay
429
+ if have_all:
430
+ # Steady-state warm: recompute ONLY the open bucket; reuse cached closed.
431
+ # late-binding: tests patch this on the dashboard module object (rebuild_parity :3803,:4031)
432
+ raw_today = list(sys.modules["_cctally_dashboard"].get_claude_session_entries(
433
+ _day_start(today_key, bucket_tz), now_utc,
434
+ project=None, skip_sync=skip_sync,
435
+ ))
436
+ built = crk.build_cached_days(
437
+ _wrap_day_entries(raw_today), raw_today,
438
+ display_tz=display_tz, pricing=pricing,
439
+ cost_calculator=_calculate_entry_cost,
440
+ )
441
+ for d in needed_closed:
442
+ cached_days[d] = _sc.cache_report_day_get(d)
443
+ cached_days.update(built) # the fresh current day (today only)
444
+ else:
445
+ # Cold / partial-miss / post-eviction: pay one full-window fetch, then
446
+ # (re)compute + store every closed day. Runs on the rare stale tick.
447
+ # late-binding: tests patch this on the dashboard module object (rebuild_parity :3803,:4031)
448
+ raw = list(sys.modules["_cctally_dashboard"].get_claude_session_entries(
449
+ since, now_utc, project=None, skip_sync=skip_sync,
450
+ ))
451
+ if not raw:
452
+ return _cache_report_empty(
453
+ today_iso=today_iso, window_days=window_days,
454
+ anomaly_threshold_pp=anomaly_threshold_pp,
455
+ anomaly_window_days=anomaly_window_days,
456
+ )
457
+ built = crk.build_cached_days(
458
+ _wrap_day_entries(raw), raw,
459
+ display_tz=display_tz, pricing=pricing,
460
+ cost_calculator=_calculate_entry_cost,
461
+ )
462
+ cached_days = dict(built)
463
+ if use_cache_report_cache:
464
+ for d, unit in built.items():
465
+ if d != today_key: # never store the OPEN day as a closed unit
466
+ _sc.cache_report_day_store(d, unit)
467
+ # Empty/gap-day sentinel: a genuinely activity-free CLOSED day never
468
+ # appears in ``built`` (``build_cached_days`` only emits days with
469
+ # entries), so register an ``is_empty`` sentinel for every needed
470
+ # closed date the fold produced nothing for. Without this,
471
+ # ``have_all`` would see that quiet day as a perpetual miss and the
472
+ # builder would full-window-refetch on EVERY warm tick — permanently
473
+ # defeating the cache for any user with an off day. Mirrors
474
+ # ``projects_env_week_put`` registering a computed-empty week as a
475
+ # hit. ``needed_closed`` already excludes ``today_key``; the sentinel
476
+ # is skipped in the restitch below, so this is byte-identical.
477
+ for d in needed_closed:
478
+ if d not in built:
479
+ _sc.cache_report_day_store(d, crk.empty_cached_day(d))
480
+ # Window-rolloff eviction (#275): the reconcile's seq-gated pass only
481
+ # evicts CHANGED days (>= the change watermark); days that roll off the
482
+ # trailing edge of the [since, now] window are never pruned there and
483
+ # would accrete one frozen unit/day on a long-uptime dashboard. Drop
484
+ # them here on this rare cold/rollover store tick (the warm path never
485
+ # reaches this branch). ``needed_closed`` is sorted ascending, so its
486
+ # first element is the window's oldest still-needed closed day; every
487
+ # needed day (real or ``is_empty`` sentinel) is >= it and survives.
488
+ if needed_closed:
489
+ _sc.cache_report_day_evict_before(needed_closed[0])
490
+
491
+ if not cached_days:
492
+ return _cache_report_empty(
493
+ today_iso=today_iso, window_days=window_days,
494
+ anomaly_threshold_pp=anomaly_threshold_pp,
495
+ anomaly_window_days=anomaly_window_days,
496
+ )
497
+
498
+ # Reconstruct fresh MUTABLE rows from the frozen cached days (F7 — cached
499
+ # units are never touched), then run the cross-row classify + summarize
500
+ # pass over the FULL assembled set every tick (the cached day aggregates
501
+ # carry no anomaly state).
502
+ rows = [
503
+ crk.reconstruct_cache_row(cached_days[d])
504
+ for d in sorted(cached_days)
505
+ if not cached_days[d].is_empty # sentinel gap days emit no CacheRow
506
+ ]
507
+ result = crk.classify_and_summarize(
508
+ rows,
509
+ now_utc=now_utc,
510
+ window_days=window_days,
511
+ anomaly_threshold_pp=anomaly_threshold_pp,
512
+ anomaly_window_days=anomaly_window_days,
513
+ display_tz=display_tz,
514
+ mode="day",
515
+ )
516
+
517
+ # Pick out today's row (if any) and the baseline-daily-row count for
518
+ # the spotlight. The spotlight median is computed against ALL rows
519
+ # except today (cross-row reference; mirrors what the panel's
520
+ # "Δ vs 14d median" label means). The median itself rides back on
521
+ # ``result.today_baseline_median`` (EFF-3 — kernel computes it once
522
+ # alongside the anomaly classifier so we don't re-walk the same
523
+ # row set here).
524
+ today_row = next((r for r in result.rows if r.date == today_iso), None)
525
+ other_rows = [r for r in result.rows if r.date != today_iso]
526
+ baseline_median = result.today_baseline_median
527
+
528
+ baseline_daily_row_count = len(other_rows)
529
+
530
+ # ``delta_pp`` sign convention (spec §4.2): "signed; negative = today
531
+ # below median" → ``delta = today − baseline``. The empty-day branch
532
+ # uses today_hit_pct = 0.0 so the formula degenerates to
533
+ # ``0.0 − baseline_median``, which IS what users expect (a flat-zero
534
+ # today read against a healthy 60% baseline yields delta=-60pp).
535
+ today_hit_pct = today_row.cache_hit_percent if today_row is not None else 0.0
536
+ delta_pp = (
537
+ None if baseline_median is None
538
+ else today_hit_pct - baseline_median
539
+ )
540
+
541
+ if today_row is None:
542
+ today_spotlight = CacheReportTodaySpotlight(
543
+ date=today_iso,
544
+ cache_hit_percent=0.0,
545
+ baseline_median_percent=baseline_median,
546
+ delta_pp=delta_pp,
547
+ net_usd=0.0, saved_usd=0.0, wasted_usd=0.0,
548
+ anomaly_triggered=False,
549
+ anomaly_reasons=(),
550
+ baseline_daily_row_count=baseline_daily_row_count,
551
+ )
552
+ else:
553
+ today_spotlight = CacheReportTodaySpotlight(
554
+ date=today_iso,
555
+ cache_hit_percent=today_row.cache_hit_percent,
556
+ baseline_median_percent=baseline_median,
557
+ delta_pp=delta_pp,
558
+ net_usd=today_row.net_usd,
559
+ saved_usd=today_row.saved_usd,
560
+ wasted_usd=today_row.wasted_usd,
561
+ anomaly_triggered=today_row.anomaly_triggered,
562
+ anomaly_reasons=tuple(today_row.anomaly_reasons),
563
+ baseline_daily_row_count=baseline_daily_row_count,
564
+ )
565
+
566
+ # Daily rows — newest first, capped at ``window_days``.
567
+ #
568
+ # Slice cap (spec §4.2 — "length up to ``window_days``"): the kernel's
569
+ # ``since = now_utc - timedelta(days=window_days)`` rolling window
570
+ # straddles midnight in any non-UTC ``display_tz`` (and in fact even
571
+ # in UTC, since ``now_utc - 14d`` and ``now_utc`` flank the same
572
+ # wall-clock minute on different calendar dates), so the kernel can
573
+ # emit ``window_days + 1`` distinct calendar-date buckets. Capping
574
+ # here (and not in the kernel) keeps the kernel agnostic of the
575
+ # envelope's hard ceiling while honoring the contract every TS /
576
+ # React consumer relies on (the sparkline ladder is hard-sized to
577
+ # ``window_days`` points). Regression:
578
+ # ``test_build_cache_report_snapshot_days_bounded_by_window``.
579
+ #
580
+ # Synthetic-today insertion: if the trailing window has older activity
581
+ # but no entries for the current display-tz day, the kernel emits a
582
+ # rows[] list whose newest row is yesterday (or older). Both React
583
+ # consumers (``CacheSparkline`` and ``CacheNetBars``) treat the
584
+ # rightmost element of ``days`` as "Today" purely positionally
585
+ # (``ordered.length - 1`` / ``isLast ? 'Today'``), so without an
586
+ # explicit today bucket they would mis-label the older row as Today.
587
+ # Insert a zero-valued CacheReportDailyRow at position 0 (newest)
588
+ # whenever ``today_row is None``. The zero values mirror the
589
+ # ``today_spotlight`` synthesized above (kept in lock-step), and
590
+ # contribute 0 to ``seven_day_*`` / ``fourteen_day_*`` rollups so
591
+ # the rollup math stays untouched.
592
+ raw_days_newest_first = sorted(
593
+ result.rows, key=lambda r: r.date or "", reverse=True,
594
+ )
595
+ days_newest_first: list = []
596
+ if today_row is None:
597
+ # Build a zero-valued synthetic today row mirroring today_spotlight.
598
+ days_newest_first.append(
599
+ CacheReportDailyRow(
600
+ date=today_iso,
601
+ cache_hit_percent=0.0,
602
+ input_tokens=0,
603
+ output_tokens=0,
604
+ cache_creation_tokens=0,
605
+ cache_read_tokens=0,
606
+ saved_usd=0.0,
607
+ wasted_usd=0.0,
608
+ net_usd=0.0,
609
+ anomaly_triggered=False,
610
+ anomaly_reasons=(),
611
+ )
612
+ )
613
+ days_newest_first.extend(
614
+ CacheReportDailyRow(
615
+ date=r.date or "",
616
+ cache_hit_percent=r.cache_hit_percent,
617
+ input_tokens=r.input_tokens,
618
+ output_tokens=r.output_tokens,
619
+ cache_creation_tokens=r.cache_creation_tokens,
620
+ cache_read_tokens=r.cache_read_tokens,
621
+ saved_usd=r.saved_usd,
622
+ wasted_usd=r.wasted_usd,
623
+ net_usd=r.net_usd,
624
+ anomaly_triggered=r.anomaly_triggered,
625
+ anomaly_reasons=tuple(r.anomaly_reasons),
626
+ )
627
+ for r in raw_days_newest_first
628
+ )
629
+ days = tuple(days_newest_first[:window_days])
630
+
631
+ # By-project + by-model breakdowns are window-wide aggregates (not
632
+ # today-only) so the panel can surface the project / model carrying
633
+ # the bulk of net savings across the trailing 14d. by-project walks
634
+ # raw entries (project_path is per-entry, not on the day-model
635
+ # buckets); by-model folds the per-row ``model_breakdowns`` already
636
+ # produced by day-mode, which avoids re-running the tiered-pricing
637
+ # math per entry. Both paths apply the same ``<synthetic>`` filter so
638
+ # the axes can't silently disagree on token totals.
639
+ #
640
+ # Constrain both axes to the SAME calendar dates as ``days``: the
641
+ # kernel's rolling window can emit ``window_days + 1`` distinct
642
+ # display-tz buckets (see the slice-cap comment above), and ``days``
643
+ # drops the oldest. Without the same drop here the by-project /
644
+ # by-model cards would silently include the clipped 15th day and
645
+ # their net totals stop reconciling against the visible table /
646
+ # CacheNetBars in the modal. The filter mirrors the kernel's
647
+ # bucket-key derivation (``entry.timestamp.astimezone(tz)``) so a
648
+ # cache entry and its corresponding day row always agree on which
649
+ # bucket they belong to.
650
+ kept_dates = frozenset(r.date for r in days if r.date)
651
+ rows_in_window = [r for r in result.rows if r.date in kept_dates]
652
+ # by_project via the #272 §4 two-level ``stable_sum`` fold
653
+ # (grouping-invariant). The per-(day, project) partials are carried on each
654
+ # ``CachedCacheReportDay`` (§5) — served from cache for closed days and
655
+ # freshly folded for today — so restitching them here memoizes the fold
656
+ # byte-identically. Their day keys use ``_resolve_bucket_tz(display_tz)``
657
+ # (matching ``CacheRow.date`` / ``kept_dates``), so combining over
658
+ # ``kept_dates`` is exactly the old full-window slice, modulo the one-time
659
+ # ULP fold move. Every ``kept_date`` is in ``cached_days`` (it came from a
660
+ # day row); the ``if d in cached_days`` guard is defensive.
661
+ by_project_rows = crk.combine_day_project_partials(
662
+ {
663
+ d: dict(cached_days[d].project_partials)
664
+ for d in kept_dates
665
+ if d in cached_days and not cached_days[d].is_empty
666
+ }
667
+ )
668
+ by_model_rows = crk._aggregate_cache_breakdown_from_rows(
669
+ rows_in_window,
670
+ skip_synthetic=True,
671
+ )
672
+ by_project = tuple(
673
+ CacheReportBreakdownRow(
674
+ key=r.key, cache_hit_percent=r.cache_hit_percent, net_usd=r.net_usd,
675
+ )
676
+ for r in by_project_rows
677
+ )
678
+ by_model = tuple(
679
+ CacheReportBreakdownRow(
680
+ key=r.key, cache_hit_percent=r.cache_hit_percent, net_usd=r.net_usd,
681
+ )
682
+ for r in by_model_rows
683
+ )
684
+
685
+ # 7-day rollup: today + 6 prior. Walk by string date; ``days_newest_first``
686
+ # is already in the right order.
687
+ seven_day_rows = days[:7]
688
+ seven_day_net_usd = stable_sum(r.net_usd for r in seven_day_rows)
689
+ seven_day_anomaly_count = sum(
690
+ 1 for r in seven_day_rows if r.anomaly_triggered
691
+ )
692
+
693
+ # 14-day counterfactual: sum(saved_usd) across the window.
694
+ fourteen_day_counterfactual_usd = stable_sum(r.saved_usd for r in days)
695
+ fourteen_day_wasted_usd = stable_sum(r.wasted_usd for r in days)
696
+ denom = fourteen_day_counterfactual_usd + abs(fourteen_day_wasted_usd)
697
+ fourteen_day_efficiency_ratio = (
698
+ (fourteen_day_counterfactual_usd / denom) if denom > 1e-9 else 0.0
699
+ )
700
+
701
+ return CacheReportSnapshot(
702
+ window_days=window_days,
703
+ anomaly_threshold_pp=anomaly_threshold_pp,
704
+ anomaly_window_days=anomaly_window_days,
705
+ today=today_spotlight,
706
+ days=days,
707
+ by_project=by_project,
708
+ by_model=by_model,
709
+ seven_day_net_usd=seven_day_net_usd,
710
+ seven_day_anomaly_count=seven_day_anomaly_count,
711
+ fourteen_day_counterfactual_usd=fourteen_day_counterfactual_usd,
712
+ fourteen_day_efficiency_ratio=fourteen_day_efficiency_ratio,
713
+ is_empty=False,
714
+ )