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.
- package/CHANGELOG.md +63 -0
- package/README.md +6 -4
- package/bin/_cctally_alerts.py +1 -1
- package/bin/_cctally_cache.py +203 -22
- package/bin/_cctally_cache_report.py +7 -5
- package/bin/_cctally_core.py +125 -14
- package/bin/_cctally_dashboard.py +495 -4893
- package/bin/_cctally_dashboard_cache_report.py +714 -0
- package/bin/_cctally_dashboard_conversation.py +830 -0
- package/bin/_cctally_dashboard_envelope.py +1520 -0
- package/bin/_cctally_dashboard_share.py +2012 -0
- package/bin/_cctally_db.py +158 -5
- package/bin/_cctally_doctor.py +104 -3
- package/bin/_cctally_five_hour.py +19 -3
- package/bin/_cctally_forecast.py +118 -161
- package/bin/_cctally_parser.py +1003 -777
- package/bin/_cctally_percent_breakdown.py +1 -1
- package/bin/_cctally_pricing_check.py +39 -0
- package/bin/_cctally_project.py +18 -42
- package/bin/_cctally_record.py +279 -274
- package/bin/_cctally_reporting.py +1 -1
- package/bin/_cctally_setup.py +150 -43
- package/bin/_cctally_sync_week.py +1 -1
- package/bin/_cctally_telemetry.py +3 -5
- package/bin/_cctally_transcript.py +191 -0
- package/bin/_cctally_tui.py +42 -18
- package/bin/_lib_alert_dispatch.py +1 -1
- package/bin/_lib_blocks.py +6 -1
- package/bin/_lib_conversation_anon.py +254 -0
- package/bin/_lib_conversation_query.py +66 -0
- package/bin/_lib_credit.py +133 -0
- package/bin/_lib_diff_kernel.py +19 -7
- package/bin/_lib_doctor.py +150 -1
- package/bin/_lib_five_hour.py +61 -0
- package/bin/_lib_forecast.py +144 -0
- package/bin/_lib_json_envelope.py +38 -0
- package/bin/_lib_jsonl.py +115 -73
- package/bin/_lib_log.py +96 -0
- package/bin/_lib_pricing.py +47 -1
- package/bin/_lib_pricing_check.py +25 -3
- package/bin/_lib_record.py +179 -0
- package/bin/_lib_render.py +26 -11
- package/bin/_lib_snapshot_cache.py +61 -0
- package/bin/_lib_view_models.py +7 -1
- package/bin/cctally +59 -7
- package/bin/cctally-dashboard +2 -2
- package/bin/cctally-statusline +1 -0
- package/bin/cctally-transcript +5 -0
- package/bin/cctally-tui +2 -2
- package/dashboard/static/assets/index-BybNp_Di.js +80 -0
- package/dashboard/static/assets/{index-DQWNrIqu.css → index-DgAmLK65.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +13 -1
- 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
|
+
)
|