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