cctally 1.10.3 → 1.11.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.
@@ -256,6 +256,7 @@ def _cctally():
256
256
  # import from _cctally_core; already-decentralized buckets (X = _lib_*,
257
257
  # Y = _cctally_*) import from their natural home. These bypass the
258
258
  # legacy shim pattern entirely.
259
+ import _cctally_core
259
260
  from _cctally_core import (
260
261
  eprint,
261
262
  now_utc_iso,
@@ -380,6 +381,81 @@ def _validate_update_check_ttl_hours_value(*args, **kwargs):
380
381
  return sys.modules["cctally"]._validate_update_check_ttl_hours_value(*args, **kwargs)
381
382
 
382
383
 
384
+ # === Cache-report settings validator (spec 2026-05-21 §6) ================
385
+ # Validates the optional ``config.json:cache_report`` block. Strict in
386
+ # v1: only ``anomaly_threshold_pp`` is settable, must be a plain int in
387
+ # ``[1, 100]`` (bool / float / string rejected — bool because it's an
388
+ # int subclass in Python and quietly accepting ``true`` for a numeric
389
+ # field is exactly the trip-up ``_validate_update_check_ttl_hours_value``
390
+ # protects against). HTTP write path raises ``_CacheReportConfigError``
391
+ # → ``_handle_post_settings`` maps to HTTP 400 + ``{error, field}``
392
+ # (matches the existing handler convention at lines 4587-4602; spec
393
+ # explicitly says 400, NOT 422).
394
+
395
+ @dataclass(frozen=True)
396
+ class _CacheReportSettings:
397
+ anomaly_threshold_pp: int
398
+
399
+
400
+ class _CacheReportConfigError(Exception):
401
+ """Validation error for the ``cache_report`` config block.
402
+
403
+ ``field`` carries the offending key path (``anomaly_threshold_pp`` or
404
+ the unknown-key name) so the JSON 400 response can surface it.
405
+ """
406
+ def __init__(self, message: str, *, field: str | None = None):
407
+ super().__init__(message)
408
+ self.field = field
409
+
410
+
411
+ _CACHE_REPORT_ALLOWED_KEYS = frozenset({"anomaly_threshold_pp"})
412
+
413
+
414
+ def _validate_cache_report_settings(block: dict) -> dict:
415
+ """Validate a ``cache_report`` config block.
416
+
417
+ Pure function. Raises ``_CacheReportConfigError`` on invalid input;
418
+ returns a dict containing ONLY the keys that were present in the
419
+ input (validated). Callers merge the result into the existing
420
+ persisted block instead of replacing it wholesale — this mirrors
421
+ the ``update.check`` partial-PUT pattern at
422
+ ``_handle_post_settings`` (~line 5277) and prevents a combined save
423
+ that omits ``anomaly_threshold_pp`` from clobbering a previously
424
+ persisted user value with the default.
425
+
426
+ v1 only accepts ``anomaly_threshold_pp`` — ``anomaly_window_days``
427
+ stays hardcoded at 14 (spec §6.1; F10 from spec §10 tracks adding
428
+ a configurable baseline window along with the UI-copy work).
429
+ """
430
+ if not isinstance(block, dict):
431
+ raise _CacheReportConfigError(
432
+ "cache_report must be an object", field="cache_report",
433
+ )
434
+ for key in block:
435
+ if key not in _CACHE_REPORT_ALLOWED_KEYS:
436
+ raise _CacheReportConfigError(
437
+ f"unknown key in cache_report block: {key!r}",
438
+ field=key,
439
+ )
440
+ validated: dict = {}
441
+ if "anomaly_threshold_pp" in block:
442
+ threshold = block["anomaly_threshold_pp"]
443
+ # bool is an int subclass — reject it explicitly (mirrors the
444
+ # update.check.ttl_hours precedent).
445
+ if isinstance(threshold, bool) or not isinstance(threshold, int):
446
+ raise _CacheReportConfigError(
447
+ "anomaly_threshold_pp must be an integer",
448
+ field="anomaly_threshold_pp",
449
+ )
450
+ if threshold < 1 or threshold > 100:
451
+ raise _CacheReportConfigError(
452
+ "anomaly_threshold_pp must be in [1, 100]",
453
+ field="anomaly_threshold_pp",
454
+ )
455
+ validated["anomaly_threshold_pp"] = threshold
456
+ return validated
457
+
458
+
383
459
  def _config_known_value(*args, **kwargs):
384
460
  return sys.modules["cctally"]._config_known_value(*args, **kwargs)
385
461
 
@@ -1674,6 +1750,461 @@ def _build_projects_share_panel_data(options: dict,
1674
1750
 
1675
1751
 
1676
1752
 
1753
+ # === Cache-report envelope dataclasses (spec 2026-05-21) =================
1754
+ # Snake_case fields are emitted verbatim into the SSE envelope so the React
1755
+ # store can read ``state.cache_report.<field>`` without a key-transform pass
1756
+ # (the envelope is intentionally snake_case end-to-end; see
1757
+ # ``dashboard/web/src/types/envelope.ts:189``). Built by
1758
+ # ``build_cache_report_snapshot()`` and shipped on the existing 5-minute
1759
+ # sync cadence — no separate ``/api/cache-report`` endpoint.
1760
+
1761
+ # Hardcoded for v1; F10 tracks lifting via cache_report.anomaly_window_days config.
1762
+ CACHE_REPORT_WINDOW_DAYS = 14
1763
+ # Two concepts that happen to share a value today: the data window the
1764
+ # panel renders vs. the baseline window the anomaly classifier reads
1765
+ # back over. Split so F10 can lift the latter without dragging the
1766
+ # former along.
1767
+ CACHE_REPORT_ANOMALY_WINDOW_DAYS = 14
1768
+
1769
+ @dataclass(frozen=True)
1770
+ class CacheReportDailyRow:
1771
+ date: str # YYYY-MM-DD in display tz
1772
+ cache_hit_percent: float
1773
+ input_tokens: int
1774
+ output_tokens: int
1775
+ cache_creation_tokens: int
1776
+ cache_read_tokens: int
1777
+ saved_usd: float
1778
+ wasted_usd: float
1779
+ net_usd: float
1780
+ anomaly_triggered: bool
1781
+ anomaly_reasons: tuple[str, ...]
1782
+
1783
+
1784
+ @dataclass(frozen=True)
1785
+ class CacheReportBreakdownRow:
1786
+ """One row of the by-project / by-model breakdown sub-cards."""
1787
+ key: str
1788
+ cache_hit_percent: float
1789
+ net_usd: float
1790
+
1791
+
1792
+ @dataclass(frozen=True)
1793
+ class CacheReportTodaySpotlight:
1794
+ """Today's spotlight card: hit %, baseline-median, Δ vs baseline,
1795
+ cumulative net / saved / wasted, anomaly state, and the count of
1796
+ baseline daily rows so the React panel can gate the
1797
+ "Building baseline · N/5 days" insufficient-baseline state."""
1798
+ date: str
1799
+ cache_hit_percent: float
1800
+ baseline_median_percent: float | None
1801
+ delta_pp: float | None
1802
+ net_usd: float
1803
+ saved_usd: float
1804
+ wasted_usd: float
1805
+ anomaly_triggered: bool
1806
+ anomaly_reasons: tuple[str, ...]
1807
+ baseline_daily_row_count: int
1808
+
1809
+
1810
+ def _cache_report_snapshot_to_dict(cr: "CacheReportSnapshot | None") -> "dict | None":
1811
+ """Serialize a ``CacheReportSnapshot`` to the SSE envelope dict.
1812
+
1813
+ Returns ``None`` when the snapshot is ``None`` (first tick before
1814
+ sync, or sub-build failure recorded on ``last_sync_error``). Snake-
1815
+ case keys throughout — the envelope is intentionally snake_case end
1816
+ -to-end per ``envelope.ts:189`` (no ``to_camel`` pass). Tuples are
1817
+ flattened to lists for JSON palatability.
1818
+ """
1819
+ if cr is None:
1820
+ return None
1821
+ return {
1822
+ "window_days": cr.window_days,
1823
+ "anomaly_threshold_pp": cr.anomaly_threshold_pp,
1824
+ "anomaly_window_days": cr.anomaly_window_days,
1825
+ "today": {
1826
+ "date": cr.today.date,
1827
+ "cache_hit_percent": cr.today.cache_hit_percent,
1828
+ "baseline_median_percent": cr.today.baseline_median_percent,
1829
+ "delta_pp": cr.today.delta_pp,
1830
+ "net_usd": cr.today.net_usd,
1831
+ "saved_usd": cr.today.saved_usd,
1832
+ "wasted_usd": cr.today.wasted_usd,
1833
+ "anomaly_triggered": cr.today.anomaly_triggered,
1834
+ "anomaly_reasons": list(cr.today.anomaly_reasons),
1835
+ "baseline_daily_row_count": cr.today.baseline_daily_row_count,
1836
+ },
1837
+ "days": [
1838
+ {
1839
+ "date": d.date,
1840
+ "cache_hit_percent": d.cache_hit_percent,
1841
+ "input_tokens": d.input_tokens,
1842
+ "output_tokens": d.output_tokens,
1843
+ "cache_creation_tokens": d.cache_creation_tokens,
1844
+ "cache_read_tokens": d.cache_read_tokens,
1845
+ "saved_usd": d.saved_usd,
1846
+ "wasted_usd": d.wasted_usd,
1847
+ "net_usd": d.net_usd,
1848
+ "anomaly_triggered": d.anomaly_triggered,
1849
+ "anomaly_reasons": list(d.anomaly_reasons),
1850
+ }
1851
+ for d in cr.days
1852
+ ],
1853
+ "by_project": [
1854
+ {
1855
+ "key": b.key,
1856
+ "cache_hit_percent": b.cache_hit_percent,
1857
+ "net_usd": b.net_usd,
1858
+ }
1859
+ for b in cr.by_project
1860
+ ],
1861
+ "by_model": [
1862
+ {
1863
+ "key": b.key,
1864
+ "cache_hit_percent": b.cache_hit_percent,
1865
+ "net_usd": b.net_usd,
1866
+ }
1867
+ for b in cr.by_model
1868
+ ],
1869
+ "seven_day_net_usd": cr.seven_day_net_usd,
1870
+ "seven_day_anomaly_count": cr.seven_day_anomaly_count,
1871
+ "fourteen_day_counterfactual_usd": cr.fourteen_day_counterfactual_usd,
1872
+ "fourteen_day_efficiency_ratio": cr.fourteen_day_efficiency_ratio,
1873
+ "is_empty": cr.is_empty,
1874
+ }
1875
+
1876
+
1877
+ @dataclass(frozen=True)
1878
+ class CacheReportSnapshot:
1879
+ """The complete cache-report envelope block.
1880
+
1881
+ ``days`` is newest-first, length ``≤ window_days``. ``by_project`` /
1882
+ ``by_model`` are sorted by ``abs(net_usd)`` descending and capped at
1883
+ 6 entries (top 5 + ``(other)``). ``window_days`` is hardcoded at 14
1884
+ in v1; ``anomaly_threshold_pp`` is read from
1885
+ ``config.json:cache_report.anomaly_threshold_pp`` (default 15) via
1886
+ the dashboard sync thread.
1887
+ """
1888
+ window_days: int
1889
+ anomaly_threshold_pp: int
1890
+ anomaly_window_days: int
1891
+ today: CacheReportTodaySpotlight
1892
+ days: tuple[CacheReportDailyRow, ...]
1893
+ by_project: tuple[CacheReportBreakdownRow, ...]
1894
+ by_model: tuple[CacheReportBreakdownRow, ...]
1895
+ seven_day_net_usd: float
1896
+ seven_day_anomaly_count: int
1897
+ fourteen_day_counterfactual_usd: float
1898
+ fourteen_day_efficiency_ratio: float
1899
+ is_empty: bool
1900
+
1901
+
1902
+ # === Cache-report snapshot builder (spec 2026-05-21 §5.2) ================
1903
+ # Adapter from the I/O layer (``get_claude_session_entries`` +
1904
+ # ``CLAUDE_MODEL_PRICING`` + ``_calculate_entry_cost``) into the kernel's
1905
+ # pure ``_build_cache_report`` orchestrator. By-project + by-model
1906
+ # breakdowns dedup through the kernel's ``_aggregate_cache_breakdown``
1907
+ # (one path, one ``<synthetic>`` filter rule) so the two axes can't
1908
+ # silently disagree on token totals when a session has both real and
1909
+ # synthetic entries on the same project.
1910
+
1911
+ def _cache_report_load_kernel():
1912
+ """Lazy-load ``_cctally_cache_report`` via the cctally ``_load_sibling``
1913
+ bridge so monkeypatch-driven test reloads of cctally see the same
1914
+ kernel module instance (matches the late-load pattern used by share /
1915
+ doctor helpers in this file)."""
1916
+ return sys.modules["cctally"]._load_sibling("_cctally_cache_report")
1917
+
1918
+
1919
+ def build_cache_report_snapshot(
1920
+ *,
1921
+ now_utc: dt.datetime,
1922
+ anomaly_threshold_pp: int,
1923
+ anomaly_window_days: int,
1924
+ display_tz: "ZoneInfo | None",
1925
+ skip_sync: bool = False,
1926
+ ) -> CacheReportSnapshot:
1927
+ """Build the ``cache_report`` envelope field from the session-entry cache.
1928
+
1929
+ Pulls entries via ``get_claude_session_entries`` (uses the cache when
1930
+ warm, falls back to direct-JSONL parse on cache miss / lock
1931
+ contention — same chain the CLI uses). Delegates aggregation +
1932
+ anomaly classification to ``_cctally_cache_report._build_cache_report``;
1933
+ shapes the result into a frozen ``CacheReportSnapshot``.
1934
+
1935
+ ``window_days`` is hardcoded at 14 in v1 (spec §6.1 hardcodes
1936
+ ``anomaly_window_days`` too; ``anomaly_threshold_pp`` is the only
1937
+ user-configurable knob). F10 from spec §10 tracks making the window
1938
+ configurable, plus the UI-copy work it'd require.
1939
+ """
1940
+ crk = _cache_report_load_kernel()
1941
+ cctally_ns = sys.modules["cctally"]
1942
+
1943
+ window_days = CACHE_REPORT_WINDOW_DAYS # v1: hardcoded per spec §6.1.
1944
+ since = now_utc - dt.timedelta(days=window_days)
1945
+
1946
+ entries = list(
1947
+ get_claude_session_entries(since, now_utc, project=None, skip_sync=skip_sync)
1948
+ )
1949
+
1950
+ today_iso = now_utc.astimezone(
1951
+ display_tz if display_tz is not None else dt.timezone.utc
1952
+ ).strftime("%Y-%m-%d")
1953
+
1954
+ if not entries:
1955
+ empty_today = CacheReportTodaySpotlight(
1956
+ date=today_iso,
1957
+ cache_hit_percent=0.0,
1958
+ baseline_median_percent=None,
1959
+ delta_pp=None,
1960
+ net_usd=0.0, saved_usd=0.0, wasted_usd=0.0,
1961
+ anomaly_triggered=False,
1962
+ anomaly_reasons=(),
1963
+ baseline_daily_row_count=0,
1964
+ )
1965
+ return CacheReportSnapshot(
1966
+ window_days=window_days,
1967
+ anomaly_threshold_pp=anomaly_threshold_pp,
1968
+ anomaly_window_days=anomaly_window_days,
1969
+ today=empty_today,
1970
+ days=(), by_project=(), by_model=(),
1971
+ seven_day_net_usd=0.0,
1972
+ seven_day_anomaly_count=0,
1973
+ fourteen_day_counterfactual_usd=0.0,
1974
+ fourteen_day_efficiency_ratio=0.0,
1975
+ is_empty=True,
1976
+ )
1977
+
1978
+ pricing = cctally_ns.CLAUDE_MODEL_PRICING
1979
+
1980
+ # Day-mode kernel expects entries with a ``usage`` dict (matches
1981
+ # ``UsageEntry``). ``get_claude_session_entries`` returns flat
1982
+ # ``_JoinedClaudeEntry`` objects, so wrap each into the right shape
1983
+ # before passing to the kernel. SimpleNamespace keeps the wrapper
1984
+ # pure-Python and avoids a new dataclass type just for the bridge.
1985
+ from types import SimpleNamespace as _NS
1986
+ day_entries = [
1987
+ _NS(
1988
+ timestamp=e.timestamp,
1989
+ model=e.model,
1990
+ cost_usd=e.cost_usd,
1991
+ usage={
1992
+ "input_tokens": e.input_tokens,
1993
+ "output_tokens": e.output_tokens,
1994
+ "cache_creation_input_tokens": e.cache_creation_tokens,
1995
+ "cache_read_input_tokens": e.cache_read_tokens,
1996
+ },
1997
+ )
1998
+ for e in entries
1999
+ ]
2000
+
2001
+ result = crk._build_cache_report(
2002
+ day_entries,
2003
+ now_utc=now_utc,
2004
+ window_days=window_days,
2005
+ anomaly_threshold_pp=anomaly_threshold_pp,
2006
+ anomaly_window_days=anomaly_window_days,
2007
+ display_tz=display_tz,
2008
+ pricing=pricing,
2009
+ mode="day",
2010
+ cost_calculator=_calculate_entry_cost,
2011
+ )
2012
+
2013
+ # Pick out today's row (if any) and the baseline-daily-row count for
2014
+ # the spotlight. The spotlight median is computed against ALL rows
2015
+ # except today (cross-row reference; mirrors what the panel's
2016
+ # "Δ vs 14d median" label means). The median itself rides back on
2017
+ # ``result.today_baseline_median`` (EFF-3 — kernel computes it once
2018
+ # alongside the anomaly classifier so we don't re-walk the same
2019
+ # row set here).
2020
+ today_row = next((r for r in result.rows if r.date == today_iso), None)
2021
+ other_rows = [r for r in result.rows if r.date != today_iso]
2022
+ baseline_median = result.today_baseline_median
2023
+
2024
+ baseline_daily_row_count = len(other_rows)
2025
+
2026
+ # ``delta_pp`` sign convention (spec §4.2): "signed; negative = today
2027
+ # below median" → ``delta = today − baseline``. The empty-day branch
2028
+ # uses today_hit_pct = 0.0 so the formula degenerates to
2029
+ # ``0.0 − baseline_median``, which IS what users expect (a flat-zero
2030
+ # today read against a healthy 60% baseline yields delta=-60pp).
2031
+ today_hit_pct = today_row.cache_hit_percent if today_row is not None else 0.0
2032
+ delta_pp = (
2033
+ None if baseline_median is None
2034
+ else today_hit_pct - baseline_median
2035
+ )
2036
+
2037
+ if today_row is None:
2038
+ today_spotlight = CacheReportTodaySpotlight(
2039
+ date=today_iso,
2040
+ cache_hit_percent=0.0,
2041
+ baseline_median_percent=baseline_median,
2042
+ delta_pp=delta_pp,
2043
+ net_usd=0.0, saved_usd=0.0, wasted_usd=0.0,
2044
+ anomaly_triggered=False,
2045
+ anomaly_reasons=(),
2046
+ baseline_daily_row_count=baseline_daily_row_count,
2047
+ )
2048
+ else:
2049
+ today_spotlight = CacheReportTodaySpotlight(
2050
+ date=today_iso,
2051
+ cache_hit_percent=today_row.cache_hit_percent,
2052
+ baseline_median_percent=baseline_median,
2053
+ delta_pp=delta_pp,
2054
+ net_usd=today_row.net_usd,
2055
+ saved_usd=today_row.saved_usd,
2056
+ wasted_usd=today_row.wasted_usd,
2057
+ anomaly_triggered=today_row.anomaly_triggered,
2058
+ anomaly_reasons=tuple(today_row.anomaly_reasons),
2059
+ baseline_daily_row_count=baseline_daily_row_count,
2060
+ )
2061
+
2062
+ # Daily rows — newest first, capped at ``window_days``.
2063
+ #
2064
+ # Slice cap (spec §4.2 — "length up to ``window_days``"): the kernel's
2065
+ # ``since = now_utc - timedelta(days=window_days)`` rolling window
2066
+ # straddles midnight in any non-UTC ``display_tz`` (and in fact even
2067
+ # in UTC, since ``now_utc - 14d`` and ``now_utc`` flank the same
2068
+ # wall-clock minute on different calendar dates), so the kernel can
2069
+ # emit ``window_days + 1`` distinct calendar-date buckets. Capping
2070
+ # here (and not in the kernel) keeps the kernel agnostic of the
2071
+ # envelope's hard ceiling while honoring the contract every TS /
2072
+ # React consumer relies on (the sparkline ladder is hard-sized to
2073
+ # ``window_days`` points). Regression:
2074
+ # ``test_build_cache_report_snapshot_days_bounded_by_window``.
2075
+ #
2076
+ # Synthetic-today insertion: if the trailing window has older activity
2077
+ # but no entries for the current display-tz day, the kernel emits a
2078
+ # rows[] list whose newest row is yesterday (or older). Both React
2079
+ # consumers (``CacheSparkline`` and ``CacheNetBars``) treat the
2080
+ # rightmost element of ``days`` as "Today" purely positionally
2081
+ # (``ordered.length - 1`` / ``isLast ? 'Today'``), so without an
2082
+ # explicit today bucket they would mis-label the older row as Today.
2083
+ # Insert a zero-valued CacheReportDailyRow at position 0 (newest)
2084
+ # whenever ``today_row is None``. The zero values mirror the
2085
+ # ``today_spotlight`` synthesized above (kept in lock-step), and
2086
+ # contribute 0 to ``seven_day_*`` / ``fourteen_day_*`` rollups so
2087
+ # the rollup math stays untouched.
2088
+ raw_days_newest_first = sorted(
2089
+ result.rows, key=lambda r: r.date or "", reverse=True,
2090
+ )
2091
+ days_newest_first: list = []
2092
+ if today_row is None:
2093
+ # Build a zero-valued synthetic today row mirroring today_spotlight.
2094
+ days_newest_first.append(
2095
+ CacheReportDailyRow(
2096
+ date=today_iso,
2097
+ cache_hit_percent=0.0,
2098
+ input_tokens=0,
2099
+ output_tokens=0,
2100
+ cache_creation_tokens=0,
2101
+ cache_read_tokens=0,
2102
+ saved_usd=0.0,
2103
+ wasted_usd=0.0,
2104
+ net_usd=0.0,
2105
+ anomaly_triggered=False,
2106
+ anomaly_reasons=(),
2107
+ )
2108
+ )
2109
+ days_newest_first.extend(
2110
+ CacheReportDailyRow(
2111
+ date=r.date or "",
2112
+ cache_hit_percent=r.cache_hit_percent,
2113
+ input_tokens=r.input_tokens,
2114
+ output_tokens=r.output_tokens,
2115
+ cache_creation_tokens=r.cache_creation_tokens,
2116
+ cache_read_tokens=r.cache_read_tokens,
2117
+ saved_usd=r.saved_usd,
2118
+ wasted_usd=r.wasted_usd,
2119
+ net_usd=r.net_usd,
2120
+ anomaly_triggered=r.anomaly_triggered,
2121
+ anomaly_reasons=tuple(r.anomaly_reasons),
2122
+ )
2123
+ for r in raw_days_newest_first
2124
+ )
2125
+ days = tuple(days_newest_first[:window_days])
2126
+
2127
+ # By-project + by-model breakdowns are window-wide aggregates (not
2128
+ # today-only) so the panel can surface the project / model carrying
2129
+ # the bulk of net savings across the trailing 14d. by-project walks
2130
+ # raw entries (project_path is per-entry, not on the day-model
2131
+ # buckets); by-model folds the per-row ``model_breakdowns`` already
2132
+ # produced by day-mode, which avoids re-running the tiered-pricing
2133
+ # math per entry. Both paths apply the same ``<synthetic>`` filter so
2134
+ # the axes can't silently disagree on token totals.
2135
+ #
2136
+ # Constrain both axes to the SAME calendar dates as ``days``: the
2137
+ # kernel's rolling window can emit ``window_days + 1`` distinct
2138
+ # display-tz buckets (see the slice-cap comment above), and ``days``
2139
+ # drops the oldest. Without the same drop here the by-project /
2140
+ # by-model cards would silently include the clipped 15th day and
2141
+ # their net totals stop reconciling against the visible table /
2142
+ # CacheNetBars in the modal. The filter mirrors the kernel's
2143
+ # bucket-key derivation (``entry.timestamp.astimezone(tz)``) so a
2144
+ # cache entry and its corresponding day row always agree on which
2145
+ # bucket they belong to.
2146
+ kept_dates = frozenset(r.date for r in days if r.date)
2147
+ bucket_tz = display_tz if display_tz is not None else dt.timezone.utc
2148
+ entries_in_window = [
2149
+ e for e in entries
2150
+ if e.timestamp.astimezone(bucket_tz).strftime("%Y-%m-%d") in kept_dates
2151
+ ]
2152
+ rows_in_window = [r for r in result.rows if r.date in kept_dates]
2153
+ by_project_rows = crk._aggregate_cache_breakdown(
2154
+ entries_in_window,
2155
+ key_fn=lambda e: (getattr(e, "project_path", None) or "(unknown)"),
2156
+ pricing=pricing,
2157
+ skip_synthetic=True,
2158
+ )
2159
+ by_model_rows = crk._aggregate_cache_breakdown_from_rows(
2160
+ rows_in_window,
2161
+ skip_synthetic=True,
2162
+ )
2163
+ by_project = tuple(
2164
+ CacheReportBreakdownRow(
2165
+ key=r.key, cache_hit_percent=r.cache_hit_percent, net_usd=r.net_usd,
2166
+ )
2167
+ for r in by_project_rows
2168
+ )
2169
+ by_model = tuple(
2170
+ CacheReportBreakdownRow(
2171
+ key=r.key, cache_hit_percent=r.cache_hit_percent, net_usd=r.net_usd,
2172
+ )
2173
+ for r in by_model_rows
2174
+ )
2175
+
2176
+ # 7-day rollup: today + 6 prior. Walk by string date; ``days_newest_first``
2177
+ # is already in the right order.
2178
+ seven_day_rows = days[:7]
2179
+ seven_day_net_usd = sum(r.net_usd for r in seven_day_rows)
2180
+ seven_day_anomaly_count = sum(
2181
+ 1 for r in seven_day_rows if r.anomaly_triggered
2182
+ )
2183
+
2184
+ # 14-day counterfactual: sum(saved_usd) across the window.
2185
+ fourteen_day_counterfactual_usd = sum(r.saved_usd for r in days)
2186
+ fourteen_day_wasted_usd = sum(r.wasted_usd for r in days)
2187
+ denom = fourteen_day_counterfactual_usd + abs(fourteen_day_wasted_usd)
2188
+ fourteen_day_efficiency_ratio = (
2189
+ (fourteen_day_counterfactual_usd / denom) if denom > 1e-9 else 0.0
2190
+ )
2191
+
2192
+ return CacheReportSnapshot(
2193
+ window_days=window_days,
2194
+ anomaly_threshold_pp=anomaly_threshold_pp,
2195
+ anomaly_window_days=anomaly_window_days,
2196
+ today=today_spotlight,
2197
+ days=days,
2198
+ by_project=by_project,
2199
+ by_model=by_model,
2200
+ seven_day_net_usd=seven_day_net_usd,
2201
+ seven_day_anomaly_count=seven_day_anomaly_count,
2202
+ fourteen_day_counterfactual_usd=fourteen_day_counterfactual_usd,
2203
+ fourteen_day_efficiency_ratio=fourteen_day_efficiency_ratio,
2204
+ is_empty=False,
2205
+ )
2206
+
2207
+
1677
2208
  # === Dashboard server core: _SnapshotRef + SSEHub + envelope builders =====
1678
2209
  # Pre-extract location: bin/cctally L16265.
1679
2210
 
@@ -4237,6 +4768,17 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
4237
4768
  # panel-empty state in that case.
4238
4769
  "projects": getattr(snap, "projects_envelope", None),
4239
4770
 
4771
+ # Cache-report panel + modal envelope block (spec
4772
+ # 2026-05-21-cache-report-panel-design.md §4.2). Snake_case
4773
+ # keys throughout — the envelope is intentionally snake_case
4774
+ # end-to-end (envelope.ts:189). ``None`` on first tick before
4775
+ # sync completes; the client renders the panel-empty state in
4776
+ # that case. envelope_version stays at 2 (additive optional
4777
+ # field, matches the update? / doctor? precedent).
4778
+ "cache_report": _cache_report_snapshot_to_dict(
4779
+ getattr(snap, "cache_report", None)
4780
+ ),
4781
+
4240
4782
  # threshold-actions T5: see prelude above for rationale.
4241
4783
  "alerts": alerts_array,
4242
4784
  "alerts_settings": alerts_settings,
@@ -4583,10 +5125,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4583
5125
  """Persist a settings update and trigger an immediate SSE broadcast.
4584
5126
 
4585
5127
  Body shape: ``{"display"?: {"tz": "..."}, "alerts"?: {...},
4586
- "update"?: {"check"?: {"enabled"?: bool, "ttl_hours"?: int}}}``
4587
- every top-level key is optional; any subset may be sent
4588
- together (combined save). Unknown top-level keys are rejected
4589
- with 400.
5128
+ "update"?: {"check"?: {"enabled"?: bool, "ttl_hours"?: int}},
5129
+ "cache_report"?: {"anomaly_threshold_pp"?: int}}`` every
5130
+ top-level key is optional; any subset may be sent together
5131
+ (combined save). Unknown top-level keys are rejected with 400.
4590
5132
 
4591
5133
  Per-block validation:
4592
5134
  * ``display.tz`` — "local", "utc", or a valid IANA zone (via
@@ -4600,6 +5142,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4600
5142
  ``[1, 720]``; 400 on out-of-range or non-int. Bool is rejected
4601
5143
  (Python ``True`` is an int subclass, so a permissive check
4602
5144
  would silently accept ``true`` for a numeric field).
5145
+ * ``cache_report.anomaly_threshold_pp`` — JSON int (NOT bool /
5146
+ float / string), in ``[1, 100]``; 400 with
5147
+ ``{error, field: "anomaly_threshold_pp"}`` on out-of-range
5148
+ or non-int. Spec §6.1 hardcodes ``anomaly_window_days``;
5149
+ F10 tracks lifting that.
4603
5150
 
4604
5151
  Atomic merged write: if all touched blocks validate, the merged
4605
5152
  config is persisted in a single ``save_config`` call inside the
@@ -4651,7 +5198,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4651
5198
  return
4652
5199
 
4653
5200
  # Reject unknown top-level keys (forward-compat hygiene).
4654
- allowed_top_keys = {"display", "alerts", "update"}
5201
+ allowed_top_keys = {"display", "alerts", "update", "cache_report"}
4655
5202
  for k in payload.keys():
4656
5203
  if k not in allowed_top_keys:
4657
5204
  self._respond_json(
@@ -4664,16 +5211,49 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4664
5211
  "display" not in payload
4665
5212
  and "alerts" not in payload
4666
5213
  and "update" not in payload
5214
+ and "cache_report" not in payload
4667
5215
  ):
4668
5216
  self._respond_json(
4669
5217
  400,
4670
5218
  {"error": (
4671
5219
  "body must contain at least one of: "
4672
- "display, alerts, update"
5220
+ "display, alerts, update, cache_report"
4673
5221
  )},
4674
5222
  )
4675
5223
  return
4676
5224
 
5225
+ # Pre-validate cache_report block (spec 2026-05-21 §6.2). Outside
5226
+ # the config_writer_lock so a 400 short-circuit doesn't take the
5227
+ # lock unnecessarily. Returns HTTP 400 (NOT 422) on validation
5228
+ # error — matches the convention every other block here uses.
5229
+ #
5230
+ # Validator returns a dict of ONLY the keys present in the input
5231
+ # (partial-PUT semantics, mirroring the ``update.check`` block
5232
+ # at ~line 5277). The handler below merges this into the
5233
+ # existing persisted ``cache_report`` block so a combined save
5234
+ # that omits ``anomaly_threshold_pp`` does not clobber the
5235
+ # user's persisted threshold with the default.
5236
+ cache_report_validated: "dict | None" = None
5237
+ if "cache_report" in payload:
5238
+ cache_report_block = payload["cache_report"]
5239
+ if not isinstance(cache_report_block, dict):
5240
+ self._respond_json(
5241
+ 400,
5242
+ {"error": "cache_report must be an object",
5243
+ "field": "cache_report"},
5244
+ )
5245
+ return
5246
+ try:
5247
+ cache_report_validated = _validate_cache_report_settings(
5248
+ cache_report_block
5249
+ )
5250
+ except _CacheReportConfigError as exc:
5251
+ self._respond_json(
5252
+ 400,
5253
+ {"error": str(exc), "field": exc.field or "cache_report"},
5254
+ )
5255
+ return
5256
+
4677
5257
  # Pre-validate display block (outside the config_writer_lock so a
4678
5258
  # 400 short-circuit doesn't take the lock unnecessarily).
4679
5259
  display_canonical: "str | None" = None
@@ -4861,6 +5441,28 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4861
5441
  merged_update["check"] = merged_check
4862
5442
  merged["update"] = merged_update
4863
5443
 
5444
+ if cache_report_validated is not None:
5445
+ # Same hand-edited-junk guard as alerts / update: a non
5446
+ # -dict ``cache_report`` block in config.json should
5447
+ # surface as a recoverable 400, not a 500.
5448
+ existing_cr = merged.get("cache_report")
5449
+ if existing_cr is not None and not isinstance(existing_cr, dict):
5450
+ self._respond_json(
5451
+ 400, {"error": "cache_report must be an object",
5452
+ "field": "cache_report"}
5453
+ )
5454
+ return
5455
+ # Partial-PUT merge: preserve keys the request didn't
5456
+ # touch (mirrors the update.check block at ~line 5371).
5457
+ # Becomes load-bearing once F10 lifts
5458
+ # ``anomaly_window_days`` to config — until then it
5459
+ # still defends a combined save (e.g. display + empty
5460
+ # cache_report) from clobbering ``anomaly_threshold_pp``
5461
+ # with the default.
5462
+ merged_cr = dict(existing_cr or {})
5463
+ merged_cr.update(cache_report_validated)
5464
+ merged["cache_report"] = merged_cr
5465
+
4864
5466
  save_config(merged)
4865
5467
 
4866
5468
  # Build the response: subset of touched blocks.
@@ -4884,6 +5486,18 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4884
5486
  ),
4885
5487
  }
4886
5488
  }
5489
+ if cache_report_validated is not None:
5490
+ # Echo the full cooked block (resolved defaults included) so
5491
+ # the dashboard composer can repaint without a follow-up GET
5492
+ # — mirrors the update.check echo at ~line 5402. Read from
5493
+ # the merged cache_report we just wrote; fall back to the
5494
+ # documented default when neither the request nor the
5495
+ # persisted config carries an explicit value.
5496
+ persisted_cr = merged.get("cache_report") or {}
5497
+ stored_threshold = persisted_cr.get("anomaly_threshold_pp", 15)
5498
+ out["cache_report"] = {
5499
+ "anomaly_threshold_pp": stored_threshold,
5500
+ }
4887
5501
  out["saved_at"] = (
4888
5502
  dt.datetime.now(dt.timezone.utc)
4889
5503
  .strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -5935,7 +6549,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
5935
6549
  c = _cctally()
5936
6550
  conn.execute(
5937
6551
  "ATTACH DATABASE ? AS cache_db",
5938
- (str(c.CACHE_DB_PATH),),
6552
+ (str(_cctally_core.CACHE_DB_PATH),),
5939
6553
  )
5940
6554
  conn.execute(
5941
6555
  "CREATE TEMP VIEW IF NOT EXISTS session_entries AS "