cctally 1.10.3 → 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.
@@ -1071,6 +1071,16 @@ class DataSnapshot:
1071
1071
  # declares ``ProjectsEnvelope | null`` and the client renders the
1072
1072
  # panel-empty state until the next tick replaces it.
1073
1073
  projects_envelope: dict | None = None
1074
+ # Cache-report panel + modal envelope block (spec
1075
+ # 2026-05-21-cache-report-panel-design.md §4.2). Populated on the
1076
+ # sync thread by ``build_cache_report_snapshot`` alongside the
1077
+ # existing projects build. The dashboard's
1078
+ # ``snapshot_to_envelope`` reads this back unchanged and assigns it
1079
+ # to ``envelope["cache_report"]``. ``None`` on first tick before
1080
+ # sync completes — the TS envelope mirror declares
1081
+ # ``CacheReportEnvelope | null`` and the client renders the
1082
+ # panel-empty state until the next tick replaces it.
1083
+ cache_report: Any | None = None
1074
1084
 
1075
1085
  @classmethod
1076
1086
  def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
@@ -2113,6 +2123,40 @@ def _tui_build_snapshot(
2113
2123
  sessions = annotated
2114
2124
  except Exception as exc:
2115
2125
  errors.append(f"projects-cross-nav-bind: {exc}")
2126
+
2127
+ # Cache-report panel + modal envelope block (spec
2128
+ # 2026-05-21-cache-report-panel-design.md §5.2). Per-tick build
2129
+ # alongside the projects envelope. Threshold is read from
2130
+ # ``config.json:cache_report.anomaly_threshold_pp`` (default
2131
+ # 15); ``anomaly_window_days`` is hardcoded at 14 in v1.
2132
+ # display_tz inherits the same resolved zone as every other
2133
+ # panel so today-bucketing matches the envelope's ``display``
2134
+ # block. Errors record on ``last_sync_error``; ``None`` lands
2135
+ # on the DataSnapshot field and the client renders the empty
2136
+ # state.
2137
+ cache_report_block = None
2138
+ try:
2139
+ cfg_cr = load_config().get("cache_report") or {}
2140
+ threshold_raw = cfg_cr.get("anomaly_threshold_pp", 15)
2141
+ try:
2142
+ threshold_pp = int(threshold_raw)
2143
+ except (TypeError, ValueError):
2144
+ threshold_pp = 15
2145
+ if threshold_pp < 1 or threshold_pp > 100:
2146
+ threshold_pp = 15
2147
+ _dash_mod = sys.modules["_cctally_dashboard"]
2148
+ _bcr = _dash_mod.build_cache_report_snapshot
2149
+ cache_report_block = _bcr(
2150
+ now_utc=now_utc,
2151
+ anomaly_threshold_pp=threshold_pp,
2152
+ # Hardcoded for v1; F10 tracks lifting via cache_report.anomaly_window_days config.
2153
+ anomaly_window_days=_dash_mod.CACHE_REPORT_ANOMALY_WINDOW_DAYS,
2154
+ display_tz=_build_display_tz,
2155
+ skip_sync=skip_sync,
2156
+ )
2157
+ except Exception as exc:
2158
+ errors.append(f"cache-report: {exc}")
2159
+
2116
2160
  return DataSnapshot(
2117
2161
  current_week=cw,
2118
2162
  forecast=fc,
@@ -2141,6 +2185,7 @@ def _tui_build_snapshot(
2141
2185
  trend_history_median_dpp=history_median_dpp,
2142
2186
  forecast_view=fc_view,
2143
2187
  projects_envelope=projects_envelope_block,
2188
+ cache_report=cache_report_block,
2144
2189
  )
2145
2190
  finally:
2146
2191
  conn.close()
package/bin/cctally CHANGED
@@ -2179,71 +2179,24 @@ def _trend_row_recency_seconds(row: dict[str, Any]) -> float:
2179
2179
  return 0.0
2180
2180
 
2181
2181
 
2182
- @dataclass
2183
- class CacheModelBreakdown:
2184
- model_name: str
2185
- input_tokens: int
2186
- output_tokens: int
2187
- cache_creation_tokens: int
2188
- cache_read_tokens: int
2189
- cache_hit_percent: float
2190
- cost: float
2191
- saved_usd: float = 0.0
2192
- wasted_usd: float = 0.0
2193
- net_usd: float = 0.0
2194
-
2195
-
2196
- @dataclass
2197
- class CacheRow:
2198
- # Identity (exactly one group populated)
2199
- date: str | None = None
2200
- session_id: str | None = None
2201
- project_path: str | None = None
2202
- last_activity: dt.datetime | None = None
2203
- source_paths: list[str] = field(default_factory=list)
2204
-
2205
- # Token counters
2206
- input_tokens: int = 0
2207
- output_tokens: int = 0
2208
- cache_creation_tokens: int = 0
2209
- cache_read_tokens: int = 0
2210
-
2211
- # Financials (populated by Task 2; zero here)
2212
- cost: float = 0.0
2213
- saved_usd: float = 0.0
2214
- wasted_usd: float = 0.0
2215
- net_usd: float = 0.0
2216
-
2217
- # Per-model breakdown children
2218
- model_breakdowns: list[CacheModelBreakdown] = field(default_factory=list)
2219
-
2220
- # Anomaly (populated by Task 5; defaults here)
2221
- anomaly_triggered: bool = False
2222
- anomaly_reasons: list[str] = field(default_factory=list)
2223
-
2224
- @property
2225
- def total_tokens(self) -> int:
2226
- return (
2227
- self.input_tokens + self.output_tokens
2228
- + self.cache_creation_tokens + self.cache_read_tokens
2229
- )
2230
-
2231
- @property
2232
- def cache_hit_percent(self) -> float:
2233
- return _compute_cache_hit_percent(
2234
- self.input_tokens, self.cache_creation_tokens, self.cache_read_tokens
2235
- )
2236
-
2237
-
2238
- def _compute_cache_hit_percent(
2239
- input_tokens: int,
2240
- cache_creation_tokens: int,
2241
- cache_read_tokens: int,
2242
- ) -> float:
2243
- total_input = input_tokens + cache_creation_tokens + cache_read_tokens
2244
- if total_input == 0:
2245
- return 0.0
2246
- return (cache_read_tokens / total_input) * 100
2182
+ # === Cache-report kernel re-exports (Task A2 onward) =========================
2183
+ # The dataclasses + pure helpers below previously lived inline in bin/cctally;
2184
+ # the cache-report panel/modal effort moved them to bin/_cctally_cache_report
2185
+ # so the dashboard sync builder can reuse the same pure aggregation as the
2186
+ # CLI. cctally-side callers continue to reach for ``CacheRow`` /
2187
+ # ``CacheModelBreakdown`` / ``_compute_cache_hit_percent`` /
2188
+ # ``_compute_entry_cache_dollars`` by bare name (extensive — every cache-report
2189
+ # renderer + JSON emitter); per-symbol re-export here preserves the call sites
2190
+ # unchanged. ``_compute_entry_cache_dollars`` keeps its pre-extraction
2191
+ # signature on this side by wrapping the kernel version with the embedded
2192
+ # ``CLAUDE_MODEL_PRICING`` injected as the ``pricing`` kwarg.
2193
+ #
2194
+ # Spec: docs/superpowers/specs/2026-05-21-cache-report-panel-design.md §5.2
2195
+ _cctally_cache_report = _load_sibling("_cctally_cache_report")
2196
+ CacheModelBreakdown = _cctally_cache_report.CacheModelBreakdown
2197
+ CacheRow = _cctally_cache_report.CacheRow
2198
+ _compute_cache_hit_percent = _cctally_cache_report._compute_cache_hit_percent
2199
+ _compute_entry_cache_dollars_kernel = _cctally_cache_report._compute_entry_cache_dollars
2247
2200
 
2248
2201
 
2249
2202
  def _compute_entry_cache_dollars(
@@ -2251,142 +2204,57 @@ def _compute_entry_cache_dollars(
2251
2204
  cache_creation_tokens: int,
2252
2205
  cache_read_tokens: int,
2253
2206
  ) -> tuple[float, float, float]:
2254
- """Return (saved_usd, wasted_usd, net_usd) for a single API-call entry.
2255
-
2256
- saved_usd = cache_read_tokens x (base_rate - read_rate)
2257
- "what you'd have paid without caching"
2258
- wasted_usd = cache_creation_tokens x (create_rate - base_rate)
2259
- "the premium paid to write cache"
2260
- net_usd = saved_usd - wasted_usd
2261
- positive = caching helped; negative = caching hurt
2262
-
2263
- Applies Anthropic's per-call >200K-tokens tier (mirrors the
2264
- `_tiered` helper in `_calculate_entry_cost`). Aggregating tokens
2265
- across multiple calls and then pricing would under-count savings on
2266
- any single call that crossed the tier. Resolves `anthropic/` and
2267
- `anthropic.` aliases via `_resolve_model_pricing` so cache-dollar
2268
- numbers stay aligned with cost numbers.
2207
+ """Compatibility wrapper pre-extraction signature.
2208
+
2209
+ The kernel function takes ``pricing`` explicitly so it stays pure;
2210
+ bin/cctally callers inject the embedded ``CLAUDE_MODEL_PRICING``.
2211
+ ``_lookup_pricing`` inside the kernel handles the ``anthropic/`` /
2212
+ ``anthropic.`` alias-stripping that the legacy ``_resolve_model_pricing``
2213
+ did, but without the stderr warning (the warning is the CLI's concern
2214
+ and already fires elsewhere via ``_calculate_entry_cost``).
2269
2215
  """
2270
- pricing = _resolve_model_pricing(model) or {}
2271
-
2272
- def _tiered_rate(tokens: int, base_key: str, tiered_key: str) -> float:
2273
- """Blended $/token rate for a single-call token count under tiered pricing."""
2274
- base_rate = pricing.get(base_key, 0.0)
2275
- tiered_rate = pricing.get(tiered_key)
2276
- if tokens <= 0:
2277
- return 0.0
2278
- if tokens > TIERED_THRESHOLD and tiered_rate is not None:
2279
- below = TIERED_THRESHOLD
2280
- above = tokens - TIERED_THRESHOLD
2281
- return (below * base_rate + above * tiered_rate) / tokens
2282
- return base_rate
2283
-
2284
- base_for_read = _tiered_rate(
2285
- cache_read_tokens,
2286
- "input_cost_per_token",
2287
- "input_cost_per_token_above_200k_tokens",
2288
- )
2289
- read_rate = _tiered_rate(
2290
- cache_read_tokens,
2291
- "cache_read_input_token_cost",
2292
- "cache_read_input_token_cost_above_200k_tokens",
2293
- )
2294
- base_for_create = _tiered_rate(
2295
- cache_creation_tokens,
2296
- "input_cost_per_token",
2297
- "input_cost_per_token_above_200k_tokens",
2298
- )
2299
- create_rate = _tiered_rate(
2300
- cache_creation_tokens,
2301
- "cache_creation_input_token_cost",
2302
- "cache_creation_input_token_cost_above_200k_tokens",
2303
- )
2304
-
2305
- saved = cache_read_tokens * max(0.0, base_for_read - read_rate)
2306
- wasted = cache_creation_tokens * max(0.0, create_rate - base_for_create)
2307
- net = saved - wasted
2308
- return (saved, wasted, net)
2216
+ return _compute_entry_cache_dollars_kernel(
2217
+ model, cache_creation_tokens, cache_read_tokens,
2218
+ pricing=CLAUDE_MODEL_PRICING,
2219
+ tiered_threshold=TIERED_THRESHOLD,
2220
+ )
2309
2221
 
2310
2222
 
2311
2223
  def _aggregate_cache_by_day(
2312
2224
  since: dt.datetime,
2313
2225
  until: dt.datetime,
2314
2226
  project: str | None = None,
2227
+ *,
2228
+ display_tz: "ZoneInfo | None" = None,
2315
2229
  ) -> list[CacheRow]:
2316
- """Group Claude Code entries by local date within [since, until]."""
2317
- # internal fallback: host-local intentional
2318
- local_tz = dt.datetime.now().astimezone().tzinfo
2319
-
2320
- day_model_buckets: dict[str, dict[str, dict[str, Any]]] = {}
2321
- for entry in get_entries(since, until, project=project):
2322
- day_key = entry.timestamp.astimezone(local_tz).strftime("%Y-%m-%d")
2323
- cost = _calculate_entry_cost(
2324
- entry.model, entry.usage, mode="auto", cost_usd=entry.cost_usd
2325
- )
2326
- create_tok = entry.usage.get("cache_creation_input_tokens", 0)
2327
- read_tok = entry.usage.get("cache_read_input_tokens", 0)
2328
- saved, wasted, net = _compute_entry_cache_dollars(
2329
- entry.model, create_tok, read_tok
2330
- )
2331
- models = day_model_buckets.setdefault(day_key, {})
2332
- b = models.setdefault(entry.model, {
2333
- "inputTokens": 0, "outputTokens": 0,
2334
- "cacheCreationTokens": 0, "cacheReadTokens": 0, "cost": 0.0,
2335
- "savedUsd": 0.0, "wastedUsd": 0.0, "netUsd": 0.0,
2336
- })
2337
- b["inputTokens"] += entry.usage.get("input_tokens", 0)
2338
- b["outputTokens"] += entry.usage.get("output_tokens", 0)
2339
- b["cacheCreationTokens"] += create_tok
2340
- b["cacheReadTokens"] += read_tok
2341
- b["cost"] += cost
2342
- b["savedUsd"] += saved
2343
- b["wastedUsd"] += wasted
2344
- b["netUsd"] += net
2345
-
2346
- result: list[CacheRow] = []
2347
- for day_key in sorted(day_model_buckets.keys()):
2348
- models = day_model_buckets[day_key]
2349
- row = CacheRow(date=day_key)
2350
- for model_name in sorted(models.keys()):
2351
- b = models[model_name]
2352
- mb = CacheModelBreakdown(
2353
- model_name=model_name,
2354
- input_tokens=b["inputTokens"],
2355
- output_tokens=b["outputTokens"],
2356
- cache_creation_tokens=b["cacheCreationTokens"],
2357
- cache_read_tokens=b["cacheReadTokens"],
2358
- cache_hit_percent=_compute_cache_hit_percent(
2359
- b["inputTokens"], b["cacheCreationTokens"], b["cacheReadTokens"]
2360
- ),
2361
- cost=b["cost"],
2362
- saved_usd=b["savedUsd"],
2363
- wasted_usd=b["wastedUsd"],
2364
- net_usd=b["netUsd"],
2365
- )
2366
- row.model_breakdowns.append(mb)
2367
- row.input_tokens += mb.input_tokens
2368
- row.output_tokens += mb.output_tokens
2369
- row.cache_creation_tokens += mb.cache_creation_tokens
2370
- row.cache_read_tokens += mb.cache_read_tokens
2371
- row.cost += mb.cost
2372
- row.saved_usd += mb.saved_usd
2373
- row.wasted_usd += mb.wasted_usd
2374
- row.net_usd += mb.net_usd
2375
- result.append(row)
2376
- return result
2377
-
2230
+ """CLI adapter: pulls entries from ``get_entries`` and delegates to the
2231
+ pure-fn kernel ``_cctally_cache_report._aggregate_cache_by_day``.
2232
+
2233
+ Adds an explicit ``display_tz`` kwarg (closes the pre-existing minor bug
2234
+ where ``--tz`` shifted the window edges but not the day-bucketing —
2235
+ spec §1.6, plan A3). Passes the embedded ``CLAUDE_MODEL_PRICING`` +
2236
+ ``_calculate_entry_cost`` into the kernel so the kernel itself stays
2237
+ free of pricing globals / cost-dispatch I/O.
2238
+
2239
+ Direct callers that don't pass ``display_tz`` (legacy contract) fall
2240
+ back to host-local via the kernel's ``None``-tz handling, matching
2241
+ pre-extraction behavior byte-for-byte. ``since`` / ``until`` bound
2242
+ the I/O query here; the kernel itself trusts the caller's pre-filter.
2243
+ """
2244
+ entries = list(get_entries(since, until, project=project))
2245
+ return _cctally_cache_report._aggregate_cache_by_day(
2246
+ entries,
2247
+ display_tz=display_tz,
2248
+ pricing=CLAUDE_MODEL_PRICING,
2249
+ cost_calculator=_calculate_entry_cost,
2250
+ )
2378
2251
 
2379
- def _filename_uuid_stem(path: str) -> str:
2380
- """Extract the UUID stem from a JSONL filename.
2381
2252
 
2382
- Claude JSONL files are named `<uuid>.jsonl`; fall back to the full
2383
- filename (without extension) if the stem isn't a valid UUID shape.
2384
- Matches the `session` subcommand's convention for unresolved session
2385
- IDs.
2386
- """
2387
- base = os.path.basename(path)
2388
- stem, _, _ = base.partition(".")
2389
- return stem
2253
+ # Re-export the kernel's filename stem helper so any bare-name callers
2254
+ # inside bin/cctally (and tests poking via ``ns["_filename_uuid_stem"]``)
2255
+ # resolve unchanged. Kernel is pure-string; ``os.path.basename``
2256
+ # equivalence is asserted by ``test_aggregate_by_session_falls_back_*``.
2257
+ _filename_uuid_stem = _cctally_cache_report._filename_uuid_stem
2390
2258
 
2391
2259
 
2392
2260
  def _aggregate_cache_by_session(
@@ -2394,135 +2262,40 @@ def _aggregate_cache_by_session(
2394
2262
  until: dt.datetime,
2395
2263
  project: str | None = None,
2396
2264
  ) -> list[CacheRow]:
2397
- """Group Claude entries by sessionId (resumed-merged) within [since, until].
2398
-
2399
- Uses get_claude_session_entries for the existing session_entries x
2400
- session_files LEFT JOIN. Entries with NULL session_id fall back to
2401
- the filename UUID stem of source_path (matches the `session`
2402
- subcommand's convention). A one-shot stderr warning fires when any
2403
- entry used the fallback. `project`, when set, filters by the same
2404
- slug semantics as `get_entries(project=...)`.
2265
+ """CLI adapter: pulls Claude session entries from
2266
+ ``get_claude_session_entries`` and delegates to the pure-fn kernel
2267
+ ``_cctally_cache_report._aggregate_cache_by_session``.
2268
+
2269
+ Preserves the legacy one-shot ``Warning: N entries lacked
2270
+ session_files rows (cache may be catching up).`` stderr line by
2271
+ consuming the kernel's ``fallback_count`` and calling ``eprint``
2272
+ here (kept on the I/O side; kernel stays pure). Injects
2273
+ ``CLAUDE_MODEL_PRICING`` + ``_calculate_entry_cost`` +
2274
+ ``_decode_escaped_cwd`` so the kernel doesn't reach for cctally
2275
+ globals. ``since`` / ``until`` bound the I/O query; the kernel
2276
+ itself trusts the caller's pre-filter.
2405
2277
  """
2406
2278
  entries = get_claude_session_entries(since, until, project=project)
2407
2279
  if not entries:
2408
2280
  return []
2409
2281
 
2410
- # buckets[sid] = {"entries": [...], "project_path": str|None,
2411
- # "last_activity": dt|None, "source_paths": set[str]}
2412
- buckets: dict[str, dict[str, Any]] = {}
2413
- fallback_count = 0
2414
- for entry in entries:
2415
- # Skip synthetic entries (Claude Code internal markers, not real
2416
- # model calls). Mirrors `_aggregate_claude_sessions` (line ~2992).
2417
- # Must occur before the session_id fallback so synthetic entries
2418
- # don't inflate fallback_count either.
2419
- if entry.model == "<synthetic>":
2420
- continue
2421
- sid = entry.session_id
2422
- if sid is None:
2423
- sid = _filename_uuid_stem(entry.source_path)
2424
- fallback_count += 1
2425
- b = buckets.setdefault(sid, {
2426
- "entries": [],
2427
- # Seed with decoded-cwd fallback so rows still resolve a
2428
- # Project cell while session_files backfill is incomplete.
2429
- # Real project_path from session_files (if present on any
2430
- # joined row) overrides below.
2431
- "project_path": _decode_escaped_cwd(
2432
- os.path.basename(os.path.dirname(entry.source_path))
2433
- ),
2434
- "last_activity": None,
2435
- "source_paths": set(),
2436
- })
2437
- b["entries"].append(entry)
2438
- b["source_paths"].add(entry.source_path)
2439
- if b["last_activity"] is None or entry.timestamp > b["last_activity"]:
2440
- b["last_activity"] = entry.timestamp
2441
- # Project path from most-recent in-window entry that has it.
2442
- if entry.project_path:
2443
- b["project_path"] = entry.project_path
2444
-
2445
- if fallback_count:
2446
- eprint(
2447
- f"Warning: {fallback_count} entries lacked session_files rows "
2448
- "(cache may be catching up)."
2282
+ def _project_decoder(source_path: str) -> str:
2283
+ return _decode_escaped_cwd(
2284
+ os.path.basename(os.path.dirname(source_path))
2449
2285
  )
2450
2286
 
2451
- result: list[CacheRow] = []
2452
- for sid, b in buckets.items():
2453
- # Per-model sub-buckets scoped to this session's entries.
2454
- model_buckets: dict[str, dict[str, Any]] = {}
2455
- for entry in b["entries"]:
2456
- mb_raw = model_buckets.setdefault(entry.model, {
2457
- "inputTokens": 0, "outputTokens": 0,
2458
- "cacheCreationTokens": 0, "cacheReadTokens": 0, "cost": 0.0,
2459
- "savedUsd": 0.0, "wastedUsd": 0.0, "netUsd": 0.0,
2460
- })
2461
- mb_raw["inputTokens"] += entry.input_tokens
2462
- mb_raw["outputTokens"] += entry.output_tokens
2463
- mb_raw["cacheCreationTokens"] += entry.cache_creation_tokens
2464
- mb_raw["cacheReadTokens"] += entry.cache_read_tokens
2465
- mb_raw["cost"] += _calculate_entry_cost(
2466
- entry.model,
2467
- {
2468
- "input_tokens": entry.input_tokens,
2469
- "output_tokens": entry.output_tokens,
2470
- "cache_creation_input_tokens": entry.cache_creation_tokens,
2471
- "cache_read_input_tokens": entry.cache_read_tokens,
2472
- },
2473
- mode="auto",
2474
- cost_usd=entry.cost_usd,
2475
- )
2476
- saved, wasted, net = _compute_entry_cache_dollars(
2477
- entry.model,
2478
- entry.cache_creation_tokens,
2479
- entry.cache_read_tokens,
2480
- )
2481
- mb_raw["savedUsd"] += saved
2482
- mb_raw["wastedUsd"] += wasted
2483
- mb_raw["netUsd"] += net
2484
-
2485
- row = CacheRow(
2486
- session_id=sid,
2487
- project_path=b["project_path"],
2488
- last_activity=b["last_activity"],
2489
- source_paths=sorted(b["source_paths"]),
2287
+ agg = _cctally_cache_report._aggregate_cache_by_session(
2288
+ entries,
2289
+ pricing=CLAUDE_MODEL_PRICING,
2290
+ cost_calculator=_calculate_entry_cost,
2291
+ project_decoder=_project_decoder,
2292
+ )
2293
+ if agg.fallback_count:
2294
+ eprint(
2295
+ f"Warning: {agg.fallback_count} entries lacked session_files rows "
2296
+ "(cache may be catching up)."
2490
2297
  )
2491
- for model_name in sorted(model_buckets.keys()):
2492
- mb_raw = model_buckets[model_name]
2493
- mb = CacheModelBreakdown(
2494
- model_name=model_name,
2495
- input_tokens=mb_raw["inputTokens"],
2496
- output_tokens=mb_raw["outputTokens"],
2497
- cache_creation_tokens=mb_raw["cacheCreationTokens"],
2498
- cache_read_tokens=mb_raw["cacheReadTokens"],
2499
- cache_hit_percent=_compute_cache_hit_percent(
2500
- mb_raw["inputTokens"],
2501
- mb_raw["cacheCreationTokens"],
2502
- mb_raw["cacheReadTokens"],
2503
- ),
2504
- cost=mb_raw["cost"],
2505
- saved_usd=mb_raw["savedUsd"],
2506
- wasted_usd=mb_raw["wastedUsd"],
2507
- net_usd=mb_raw["netUsd"],
2508
- )
2509
- row.model_breakdowns.append(mb)
2510
- row.input_tokens += mb.input_tokens
2511
- row.output_tokens += mb.output_tokens
2512
- row.cache_creation_tokens += mb.cache_creation_tokens
2513
- row.cache_read_tokens += mb.cache_read_tokens
2514
- row.cost += mb.cost
2515
- row.saved_usd += mb.saved_usd
2516
- row.wasted_usd += mb.wasted_usd
2517
- row.net_usd += mb.net_usd
2518
- result.append(row)
2519
-
2520
- # Initial ordering descending by last_activity; Task 6 adds --sort and
2521
- # will change the session-mode default. Use tz-aware sentinel to avoid
2522
- # naive-vs-aware comparison errors on rows missing last_activity.
2523
- _min_dt = dt.datetime.min.replace(tzinfo=dt.timezone.utc)
2524
- result.sort(key=lambda r: r.last_activity or _min_dt, reverse=True)
2525
- return result
2298
+ return agg.rows
2526
2299
 
2527
2300
 
2528
2301
  def _annotate_anomalies(
@@ -2532,83 +2305,20 @@ def _annotate_anomalies(
2532
2305
  *,
2533
2306
  enabled: bool = True,
2534
2307
  ) -> None:
2535
- """Mutate each row's anomaly_triggered / anomaly_reasons in place.
2308
+ """CLI adapter: thin shim around the kernel's ``_classify_anomalies``.
2536
2309
 
2537
- Trigger 1 (net_negative): net_usd < 0 (strict). Skipped when the row has
2538
- zero cache activity (no-op session, not a bug).
2539
- Trigger 2 (cache_drop): cache_hit_percent is >= threshold_pp below the
2540
- trailing window_days median of OTHER rows. Requires minimum 5 (daily)
2541
- or 10 (session) baseline samples; silently skipped otherwise.
2542
-
2543
- Mode is inferred from the first row: if it has a session_id, session
2544
- mode (window_days back to <= last_activity - 1s); else daily mode
2545
- (window_days back to <= date - 1 day).
2310
+ Kept under the original name so the existing call site in
2311
+ ``cmd_cache_report`` resolves unchanged. The kernel mutates each row
2312
+ in place (same contract as the pre-extraction implementation
2313
+ ``anomaly_triggered`` / ``anomaly_reasons`` set on each ``CacheRow``).
2546
2314
  """
2547
- import statistics
2548
-
2549
- if not enabled:
2550
- for row in rows:
2551
- row.anomaly_triggered = False
2552
- row.anomaly_reasons = []
2553
- return
2554
- if not rows:
2555
- return
2556
-
2557
- # Determine mode + baseline minimum from the first row's identity.
2558
- is_session_mode = rows[0].session_id is not None
2559
- min_baseline = 10 if is_session_mode else 5
2560
-
2561
- def _row_anchor(r: CacheRow) -> dt.datetime | None:
2562
- """Return the row's position in time for baseline-window comparison."""
2563
- if r.last_activity is not None:
2564
- return r.last_activity
2565
- if r.date:
2566
- # Use .astimezone() (not .replace(tzinfo=...)) so the OS tzdb
2567
- # gives the correct offset for the given date — avoids DST drift
2568
- # on dates that straddle a DST boundary. Mirrors the idiom in
2569
- # _parse_cli_date_range.
2570
- # internal fallback: host-local intentional
2571
- return dt.datetime.strptime(r.date, "%Y-%m-%d").astimezone()
2572
- return None
2573
-
2574
- window = dt.timedelta(days=window_days)
2575
- upper_offset = (
2576
- dt.timedelta(seconds=1) if is_session_mode else dt.timedelta(days=1)
2315
+ _cctally_cache_report._classify_anomalies(
2316
+ rows,
2317
+ threshold_pp=threshold_pp,
2318
+ window_days=window_days,
2319
+ enabled=enabled,
2577
2320
  )
2578
2321
 
2579
- # Pre-compute anchors once to avoid O(n^2 * datetime-parse) overhead.
2580
- anchors: list[dt.datetime | None] = [_row_anchor(r) for r in rows]
2581
-
2582
- for i, row in enumerate(rows):
2583
- reasons: list[str] = []
2584
-
2585
- # Trigger 1: net_negative (no baseline needed; cache-activity guard).
2586
- if row.cache_creation_tokens + row.cache_read_tokens > 0:
2587
- if row.net_usd < 0:
2588
- reasons.append("net_negative")
2589
-
2590
- # Trigger 2: cache_drop (requires baseline).
2591
- anchor = anchors[i]
2592
- if anchor is not None:
2593
- lower_bound = anchor - window
2594
- upper_bound = anchor - upper_offset
2595
- baseline_values: list[float] = []
2596
- for j, other in enumerate(rows):
2597
- if j == i:
2598
- continue
2599
- other_anchor = anchors[j]
2600
- if other_anchor is None:
2601
- continue
2602
- if lower_bound <= other_anchor <= upper_bound:
2603
- baseline_values.append(other.cache_hit_percent)
2604
- if len(baseline_values) >= min_baseline:
2605
- median = statistics.median(baseline_values)
2606
- if (median - row.cache_hit_percent) >= threshold_pp:
2607
- reasons.append("cache_drop")
2608
-
2609
- row.anomaly_reasons = reasons
2610
- row.anomaly_triggered = bool(reasons)
2611
-
2612
2322
 
2613
2323
  @dataclass
2614
2324
  class WeekCostResult:
@@ -8444,7 +8154,13 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
8444
8154
  if mode == "session":
8445
8155
  rows = _aggregate_cache_by_session(since, until, project=args.project)
8446
8156
  else:
8447
- rows = _aggregate_cache_by_day(since, until, project=args.project)
8157
+ # Task A3: pass the resolved display_tz so day buckets match the
8158
+ # ``--tz`` flag (closes the pre-existing minor bug where the
8159
+ # window edges shifted but day buckets stayed on host-local —
8160
+ # spec §1.6 / plan A3).
8161
+ rows = _aggregate_cache_by_day(
8162
+ since, until, project=args.project, display_tz=tz,
8163
+ )
8448
8164
 
8449
8165
  if not rows:
8450
8166
  if args.json: