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
package/bin/_lib_doctor.py
CHANGED
|
@@ -177,6 +177,23 @@ class DoctorState:
|
|
|
177
177
|
# valid and default to the enabled (opt-out) posture.
|
|
178
178
|
telemetry_enabled: bool = True
|
|
179
179
|
telemetry_reason: str = "enabled"
|
|
180
|
+
# #279 S2 (F5a): rolling ingest parse-health records read from
|
|
181
|
+
# cache_meta keys parse_health_claude / parse_health_codex (JSON
|
|
182
|
+
# dicts; see _cctally_cache._update_parse_health_meta). None = key
|
|
183
|
+
# absent (pre-first-sync) or cache unreadable — check degrades OK.
|
|
184
|
+
parse_health_claude: Optional[dict] = None
|
|
185
|
+
parse_health_codex: Optional[dict] = None
|
|
186
|
+
# #279 S2 (F5b): PRAGMA quick_check(1) results, gathered ONLY under
|
|
187
|
+
# doctor_gather_state(deep=True) (CLI cmd_doctor) — the dashboard
|
|
188
|
+
# rebuild loop calls the gather every rebuild and quick_check on a
|
|
189
|
+
# large cache.db costs seconds. "ok" | first error line |
|
|
190
|
+
# "open failed: ..." | None = not run.
|
|
191
|
+
stats_db_quick_check: Optional[str] = None
|
|
192
|
+
cache_db_quick_check: Optional[str] = None
|
|
193
|
+
# #279 S2 (F5c): non-blocking flock probes on the two sync lock files
|
|
194
|
+
# (name -> True held / False free / None unreadable). Probe never
|
|
195
|
+
# creates files (doctor read-only contract). None = probe errored.
|
|
196
|
+
locks_held: Optional[dict] = None
|
|
180
197
|
|
|
181
198
|
|
|
182
199
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -1207,6 +1224,122 @@ def _check_telemetry(s: DoctorState) -> CheckResult:
|
|
|
1207
1224
|
)
|
|
1208
1225
|
|
|
1209
1226
|
|
|
1227
|
+
_PARSE_HEALTH_RECENCY_DAYS = 7
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def _check_data_parse_health(s: DoctorState) -> CheckResult:
|
|
1231
|
+
details = {"claude": s.parse_health_claude, "codex": s.parse_health_codex}
|
|
1232
|
+
recent, historical_total = [], 0
|
|
1233
|
+
for label, ph in (("claude", s.parse_health_claude),
|
|
1234
|
+
("codex", s.parse_health_codex)):
|
|
1235
|
+
if not isinstance(ph, dict):
|
|
1236
|
+
continue
|
|
1237
|
+
malformed = int(ph.get("lines_malformed", 0) or 0)
|
|
1238
|
+
skipped = int(ph.get("lines_skipped", 0) or 0)
|
|
1239
|
+
historical_total += malformed + skipped
|
|
1240
|
+
last_anomaly = ph.get("last_anomaly_at")
|
|
1241
|
+
if not last_anomaly or (malformed + skipped) == 0:
|
|
1242
|
+
continue
|
|
1243
|
+
try:
|
|
1244
|
+
at = dt.datetime.fromisoformat(
|
|
1245
|
+
str(last_anomaly).replace("Z", "+00:00"))
|
|
1246
|
+
if at.tzinfo is None:
|
|
1247
|
+
at = at.replace(tzinfo=dt.timezone.utc)
|
|
1248
|
+
except ValueError:
|
|
1249
|
+
continue
|
|
1250
|
+
if (s.now_utc - at).total_seconds() <= \
|
|
1251
|
+
_PARSE_HEALTH_RECENCY_DAYS * 86400.0:
|
|
1252
|
+
reasons = ph.get("reasons") \
|
|
1253
|
+
if isinstance(ph.get("reasons"), dict) else {}
|
|
1254
|
+
top = max(reasons.items(), key=lambda kv: kv[1])[0] \
|
|
1255
|
+
if reasons else None
|
|
1256
|
+
recent.append((label, malformed, skipped, top))
|
|
1257
|
+
if recent:
|
|
1258
|
+
parts = []
|
|
1259
|
+
for label, malformed, skipped, top in recent:
|
|
1260
|
+
frag = f"{label}: {malformed} malformed / {skipped} drift-skipped"
|
|
1261
|
+
if top:
|
|
1262
|
+
frag += f" (top reason: {top})"
|
|
1263
|
+
parts.append(frag)
|
|
1264
|
+
return CheckResult(
|
|
1265
|
+
id="data.parse_health", title="Ingest parse health",
|
|
1266
|
+
severity="warn",
|
|
1267
|
+
summary="; ".join(parts)
|
|
1268
|
+
+ f" within {_PARSE_HEALTH_RECENCY_DAYS}d",
|
|
1269
|
+
remediation=(
|
|
1270
|
+
"JSONL lines are failing to parse — a Claude Code / Codex "
|
|
1271
|
+
"update may have changed the session format. Check for a "
|
|
1272
|
+
"cctally update or file an issue; `cctally cache-sync "
|
|
1273
|
+
"--rebuild` re-baselines the counters."),
|
|
1274
|
+
details=details,
|
|
1275
|
+
)
|
|
1276
|
+
if s.parse_health_claude is None and s.parse_health_codex is None:
|
|
1277
|
+
summary = "no parse-health data yet (pre-first-sync)"
|
|
1278
|
+
elif historical_total:
|
|
1279
|
+
summary = (f"no recent anomalies ({historical_total} historical; "
|
|
1280
|
+
f"see details)")
|
|
1281
|
+
else:
|
|
1282
|
+
summary = "no parse anomalies"
|
|
1283
|
+
return CheckResult(
|
|
1284
|
+
id="data.parse_health", title="Ingest parse health",
|
|
1285
|
+
severity="ok", summary=summary, remediation=None, details=details,
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _check_db_integrity(s: DoctorState) -> CheckResult:
|
|
1290
|
+
details = {"stats_quick_check": s.stats_db_quick_check,
|
|
1291
|
+
"cache_quick_check": s.cache_db_quick_check}
|
|
1292
|
+
if s.stats_db_quick_check is not None and s.stats_db_quick_check != "ok":
|
|
1293
|
+
return CheckResult(
|
|
1294
|
+
id="db.integrity", title="Integrity", severity="fail",
|
|
1295
|
+
summary=f"stats.db quick_check: {s.stats_db_quick_check}",
|
|
1296
|
+
remediation=(
|
|
1297
|
+
"stats.db (the non-re-derivable DB) reports corruption. "
|
|
1298
|
+
"Back up the file FIRST, then try "
|
|
1299
|
+
"`sqlite3 <path> \".recover\"` into a fresh file; "
|
|
1300
|
+
"`cctally db recover` does not repair corruption. Please "
|
|
1301
|
+
"file a bug. Do not delete the file."),
|
|
1302
|
+
details=details,
|
|
1303
|
+
)
|
|
1304
|
+
if s.cache_db_quick_check is not None and s.cache_db_quick_check != "ok":
|
|
1305
|
+
return CheckResult(
|
|
1306
|
+
id="db.integrity", title="Integrity", severity="warn",
|
|
1307
|
+
summary=f"cache.db quick_check: {s.cache_db_quick_check}",
|
|
1308
|
+
remediation=("cache.db is re-derivable — run "
|
|
1309
|
+
"`cctally cache-sync --rebuild`."),
|
|
1310
|
+
details=details,
|
|
1311
|
+
)
|
|
1312
|
+
if s.stats_db_quick_check is None and s.cache_db_quick_check is None:
|
|
1313
|
+
return CheckResult(
|
|
1314
|
+
id="db.integrity", title="Integrity", severity="ok",
|
|
1315
|
+
summary="not checked (fast gather — run `cctally doctor`)",
|
|
1316
|
+
remediation=None, details=details,
|
|
1317
|
+
)
|
|
1318
|
+
return CheckResult(
|
|
1319
|
+
id="db.integrity", title="Integrity", severity="ok",
|
|
1320
|
+
summary="quick_check ok", remediation=None, details=details,
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def _check_db_lock_state(s: DoctorState) -> CheckResult:
|
|
1325
|
+
locks = s.locks_held if isinstance(s.locks_held, dict) else {}
|
|
1326
|
+
held = sorted(k for k, v in locks.items() if v is True)
|
|
1327
|
+
details = {"locks": s.locks_held}
|
|
1328
|
+
if held:
|
|
1329
|
+
return CheckResult(
|
|
1330
|
+
id="db.lock_state", title="Locks", severity="ok",
|
|
1331
|
+
summary=(", ".join(held) + " held — an active sync or "
|
|
1332
|
+
"dashboard is running; a hold persisting across "
|
|
1333
|
+
"repeated doctor runs may indicate a wedged process"),
|
|
1334
|
+
remediation=None, details=details,
|
|
1335
|
+
)
|
|
1336
|
+
return CheckResult(
|
|
1337
|
+
id="db.lock_state", title="Locks", severity="ok",
|
|
1338
|
+
summary="free" if locks else "no lock files present",
|
|
1339
|
+
remediation=None, details=details,
|
|
1340
|
+
)
|
|
1341
|
+
|
|
1342
|
+
|
|
1210
1343
|
# Each entry is (category_id, category_title, ((check_id, evaluator_fn_name), ...)).
|
|
1211
1344
|
# The dotted check_id is the stable JSON-contract ID (spec §5.2) AND the
|
|
1212
1345
|
# fingerprint identity-slice key (spec §5.5). When an evaluator raises,
|
|
@@ -1232,14 +1365,17 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
1232
1365
|
("db", "Database", (
|
|
1233
1366
|
("db.stats.file", "_check_db_stats_file"),
|
|
1234
1367
|
("db.cache.file", "_check_db_cache_file"),
|
|
1368
|
+
("db.integrity", "_check_db_integrity"),
|
|
1235
1369
|
("db.version_ahead", "_check_db_version_ahead"),
|
|
1236
1370
|
("db.migrations.applied", "_check_db_migrations_applied"),
|
|
1237
1371
|
("db.migrations.pending", "_check_db_migrations_pending"),
|
|
1372
|
+
("db.lock_state", "_check_db_lock_state"),
|
|
1238
1373
|
)),
|
|
1239
1374
|
("data", "Data", (
|
|
1240
1375
|
("data.latest_snapshot_age", "_check_data_latest_snapshot_age"),
|
|
1241
1376
|
("data.cache_sync_state", "_check_data_cache_sync_state"),
|
|
1242
1377
|
("data.codex_cache", "_check_data_codex_cache"),
|
|
1378
|
+
("data.parse_health", "_check_data_parse_health"),
|
|
1243
1379
|
("data.forked_buckets", "_check_data_forked_buckets"),
|
|
1244
1380
|
("data.post_credit_milestones", "_check_data_post_credit_milestones"),
|
|
1245
1381
|
("data.conversation_sessions_rollup",
|
|
@@ -1320,7 +1456,20 @@ def run_checks(state: DoctorState) -> DoctorReport:
|
|
|
1320
1456
|
|
|
1321
1457
|
|
|
1322
1458
|
def _iso_z(d: dt.datetime) -> str:
|
|
1323
|
-
"""Render a UTC datetime as ISO 8601 with trailing 'Z' (share-v2 convention).
|
|
1459
|
+
"""Render a UTC datetime as ISO 8601 with trailing 'Z' (share-v2 convention).
|
|
1460
|
+
|
|
1461
|
+
DELIBERATELY DIVERGES from the canonical ``_lib_json_envelope._iso_z``
|
|
1462
|
+
(#279 S6 W4) on two axes and therefore KEEPS this local name (gate F11):
|
|
1463
|
+
(1) a NAIVE datetime is treated as UTC via ``replace(tzinfo=utc)`` — the
|
|
1464
|
+
canonical's ``astimezone(utc)`` would reinterpret it as host-local; and
|
|
1465
|
+
(2) microseconds are preserved via ``isoformat()`` — the canonical floors
|
|
1466
|
+
to whole seconds via ``%S``. Renaming this to the canonical would silently
|
|
1467
|
+
degrade the doctor block for the cross-module consumers that resolve it by
|
|
1468
|
+
name through the sibling loader (``_cctally_tui`` and
|
|
1469
|
+
``_cctally_dashboard_envelope``'s doctor-envelope builders, whose
|
|
1470
|
+
try/except swallows a rename). Divergence pinned by
|
|
1471
|
+
``tests/test_lib_doctor.py::test_doctor_iso_z_naive_means_utc_and_keeps_microseconds``.
|
|
1472
|
+
"""
|
|
1324
1473
|
if d.tzinfo is None:
|
|
1325
1474
|
d = d.replace(tzinfo=dt.timezone.utc)
|
|
1326
1475
|
s = d.astimezone(dt.timezone.utc).isoformat()
|
package/bin/_lib_five_hour.py
CHANGED
|
@@ -23,11 +23,45 @@ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
25
|
import datetime as dt
|
|
26
|
+
import math
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
_FIVE_HOUR_JITTER_FLOOR_SECONDS = 600 # 10 minutes; tolerance band for resets_at jitter
|
|
29
30
|
|
|
30
31
|
|
|
32
|
+
def five_hour_milestone_range(pct: float, max_existing: "int | None") -> range:
|
|
33
|
+
"""Which integer 5h-% thresholds ``maybe_update_five_hour_block`` should
|
|
34
|
+
attempt to record for ``pct``, given the ACTIVE segment's highest already-
|
|
35
|
+
recorded threshold ``max_existing`` (#279 S4 F4).
|
|
36
|
+
|
|
37
|
+
Mirrors the milestone-detection loop's fencing exactly (glue call site:
|
|
38
|
+
``maybe_update_five_hour_block``'s 5h-% milestone loop):
|
|
39
|
+
|
|
40
|
+
- ``current_floor = math.floor(pct + 1e-9)`` — the 1e-9 snap flushes the
|
|
41
|
+
``0.50 * 100 == 49.99…`` ULP so the N threshold is not missed.
|
|
42
|
+
- ``current_floor < 1`` → empty range (the ``if current_floor >= 1``
|
|
43
|
+
glue guard).
|
|
44
|
+
- ``start_threshold = current_floor`` when ``max_existing is None``
|
|
45
|
+
(first observation records ONLY the current floor — no synthetic
|
|
46
|
+
1..floor-1 backfill), else ``int(max_existing) + 1``.
|
|
47
|
+
- result = ``range(start_threshold, current_floor + 1)`` — empty when
|
|
48
|
+
``start_threshold > current_floor`` (already at/above the floor),
|
|
49
|
+
which the glue reads as ``if milestone_range:``.
|
|
50
|
+
|
|
51
|
+
Pure: the MAX(percent_threshold) probe that yields ``max_existing``, the
|
|
52
|
+
marginal-cost lookup, the per-threshold INSERT, and the alert plumbing all
|
|
53
|
+
stay in glue. (The weekly ``maybe_record_milestone`` loop shares this
|
|
54
|
+
fencing formula but keeps its own range in glue — its crossing decision
|
|
55
|
+
early-returns BEFORE the cost sync when covered, a structure this
|
|
56
|
+
range-only kernel does not encapsulate; spec §6 defers it.)
|
|
57
|
+
"""
|
|
58
|
+
current_floor = math.floor(pct + 1e-9)
|
|
59
|
+
if current_floor < 1:
|
|
60
|
+
return range(0)
|
|
61
|
+
start_threshold = current_floor if max_existing is None else int(max_existing) + 1
|
|
62
|
+
return range(start_threshold, current_floor + 1)
|
|
63
|
+
|
|
64
|
+
|
|
31
65
|
def _floor_to_ten_minutes(d: dt.datetime) -> dt.datetime:
|
|
32
66
|
"""Floor a datetime to the previous 10-minute boundary.
|
|
33
67
|
|
|
@@ -47,6 +81,33 @@ def _floor_to_ten_minutes(d: dt.datetime) -> dt.datetime:
|
|
|
47
81
|
)
|
|
48
82
|
|
|
49
83
|
|
|
84
|
+
def _round_to_ten_minutes(d: dt.datetime) -> dt.datetime:
|
|
85
|
+
"""Round a datetime to the NEAREST 10-minute boundary (half up).
|
|
86
|
+
|
|
87
|
+
The *display*-side companion to ``_floor_to_ten_minutes``. Anthropic
|
|
88
|
+
``rate_limits.5h.resets_at`` (and the derived ``block_start_at``,
|
|
89
|
+
``block_start = reset - 5h``) carries sub-10-minute capture jitter, so
|
|
90
|
+
a reset that truly lands on ``:40`` can be recorded as ``:39``.
|
|
91
|
+
Flooring that for display shows ``:30`` — off by a full bucket — so
|
|
92
|
+
every user-facing 5h-block clock time rounds to the nearest boundary
|
|
93
|
+
instead.
|
|
94
|
+
|
|
95
|
+
Rounds the ABSOLUTE instant (epoch, tz-independent) rather than the
|
|
96
|
+
local minute, so it is correct regardless of the display zone's offset
|
|
97
|
+
(the reset is a fixed UTC instant). Returns a UTC-aware datetime;
|
|
98
|
+
callers hand it to ``format_display_dt`` for zone conversion.
|
|
99
|
+
|
|
100
|
+
NEVER use this for keys / partitioning / lookups — those require the
|
|
101
|
+
exact stored timestamp (issue #76). This is a render-only normalizer.
|
|
102
|
+
"""
|
|
103
|
+
if d.tzinfo is None:
|
|
104
|
+
d = d.replace(tzinfo=dt.timezone.utc)
|
|
105
|
+
bucket = _FIVE_HOUR_JITTER_FLOOR_SECONDS # 600s = 10 min
|
|
106
|
+
# floor(x + 0.5) is predictable half-UP (Python's round() is banker's).
|
|
107
|
+
rounded = math.floor(d.timestamp() / bucket + 0.5) * bucket
|
|
108
|
+
return dt.datetime.fromtimestamp(rounded, dt.timezone.utc)
|
|
109
|
+
|
|
110
|
+
|
|
50
111
|
def _canonical_5h_window_key(
|
|
51
112
|
resets_at_epoch: int,
|
|
52
113
|
prior_epoch: int | None = None,
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Forecast decision kernel for cctally.
|
|
2
|
+
|
|
3
|
+
Pure-fn layer (no I/O at import time): the forecast inputs/output/budget
|
|
4
|
+
dataclasses plus `_compute_forecast` — the projection + budget-headroom
|
|
5
|
+
math. Values in (`ForecastInputs`), a `ForecastOutput` out. No DB reads,
|
|
6
|
+
no config loads, no `_cctally()` accessor: the projection routes through
|
|
7
|
+
`project_linear` (the shared, pure primitive whose real home is
|
|
8
|
+
`_lib_budget`).
|
|
9
|
+
|
|
10
|
+
Imported by `_cctally_forecast.py` (and `_cctally_tui.py`) and re-exported
|
|
11
|
+
on the `cctally` namespace via `bin/cctally`, so the existing
|
|
12
|
+
`cctally.ForecastInputs` / `mod.ForecastOutput` / `inspect.getsource(
|
|
13
|
+
cctally._compute_forecast)` read paths resolve unchanged (re-export
|
|
14
|
+
continuity, spec §2). Single definition of each dataclass lives here;
|
|
15
|
+
everything else imports it, so class identity stays unique.
|
|
16
|
+
|
|
17
|
+
Spec: docs/superpowers/specs/2026-07-09-279-s4-record-kernelization-design.md
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import datetime as dt
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from _lib_budget import project_linear
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ForecastInputs:
|
|
29
|
+
now_utc: dt.datetime
|
|
30
|
+
week_start_at: dt.datetime
|
|
31
|
+
week_end_at: dt.datetime
|
|
32
|
+
elapsed_hours: float
|
|
33
|
+
elapsed_fraction: float
|
|
34
|
+
remaining_hours: float
|
|
35
|
+
remaining_days: float
|
|
36
|
+
# Current state
|
|
37
|
+
p_now: float
|
|
38
|
+
five_hour_percent: float | None
|
|
39
|
+
spent_usd: float
|
|
40
|
+
snapshot_count: int
|
|
41
|
+
latest_snapshot_at: dt.datetime
|
|
42
|
+
# Rate inputs
|
|
43
|
+
p_24h_ago: float | None
|
|
44
|
+
t_24h_actual_hours: float | None
|
|
45
|
+
# $/1% selection
|
|
46
|
+
dollars_per_percent: float
|
|
47
|
+
dollars_per_percent_source: str # "this_week" | "trailing_4wk_median" | "this_week_sparse"
|
|
48
|
+
# Confidence
|
|
49
|
+
confidence: str # "high" | "low"
|
|
50
|
+
low_confidence_reasons: list[str]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class BudgetRow:
|
|
55
|
+
target_percent: int
|
|
56
|
+
pct_headroom: float | None # None when already past target
|
|
57
|
+
dollars_per_day: float | None
|
|
58
|
+
percent_per_day: float | None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class ForecastOutput:
|
|
63
|
+
inputs: ForecastInputs
|
|
64
|
+
r_avg: float # pct per hour, week-avg
|
|
65
|
+
r_recent: float | None # pct per hour, 24h recent; None if no prior sample
|
|
66
|
+
final_percent_low: float
|
|
67
|
+
final_percent_high: float
|
|
68
|
+
week_avg_projection_pct: float # p_now + r_avg*remaining (smooth estimator)
|
|
69
|
+
projected_cap: bool
|
|
70
|
+
already_capped: bool
|
|
71
|
+
cap_at: dt.datetime | None
|
|
72
|
+
budgets: list[BudgetRow]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _compute_forecast(inputs: ForecastInputs, targets: list[int]) -> ForecastOutput:
|
|
76
|
+
"""Implements spec §2. targets are sorted desc for stable output (100, 90, …)."""
|
|
77
|
+
# Rate methods
|
|
78
|
+
r_avg = inputs.p_now / inputs.elapsed_hours if inputs.elapsed_hours > 0 else 0.0
|
|
79
|
+
if inputs.p_24h_ago is not None and inputs.t_24h_actual_hours:
|
|
80
|
+
r_recent: float | None = max(
|
|
81
|
+
0.0, (inputs.p_now - inputs.p_24h_ago) / inputs.t_24h_actual_hours
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
r_recent = None
|
|
85
|
+
|
|
86
|
+
# Projected final % — routed through the shared project_linear primitive
|
|
87
|
+
# (spec F1). r_recent is None ⇒ collapse to the average projection.
|
|
88
|
+
if r_recent is None:
|
|
89
|
+
final_low, final_high = project_linear(
|
|
90
|
+
inputs.p_now, inputs.remaining_hours, r_avg, r_avg
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
a, b = project_linear(
|
|
94
|
+
inputs.p_now, inputs.remaining_hours, r_avg, r_recent
|
|
95
|
+
)
|
|
96
|
+
final_low, final_high = min(a, b), max(a, b)
|
|
97
|
+
|
|
98
|
+
# Smooth week-average projection (additive surface field). Distinct from
|
|
99
|
+
# the displayed band (which keys off final_high): this is the conservative
|
|
100
|
+
# week-average value the projected-pace alert axis fires on.
|
|
101
|
+
# p_now + r_avg*remaining (== project_linear collapsed to the single rate).
|
|
102
|
+
week_avg_projection_pct = inputs.p_now + r_avg * inputs.remaining_hours
|
|
103
|
+
|
|
104
|
+
already_capped = inputs.p_now >= 100.0
|
|
105
|
+
projected_cap = already_capped or final_high >= 100.0
|
|
106
|
+
|
|
107
|
+
cap_at: dt.datetime | None = None
|
|
108
|
+
if not already_capped and projected_cap:
|
|
109
|
+
r_pessimistic = max(r_avg, r_recent or 0.0)
|
|
110
|
+
if r_pessimistic > 0:
|
|
111
|
+
hours_to_cap = (100.0 - inputs.p_now) / r_pessimistic
|
|
112
|
+
if hours_to_cap < inputs.remaining_hours:
|
|
113
|
+
cap_at = inputs.now_utc + dt.timedelta(hours=hours_to_cap)
|
|
114
|
+
|
|
115
|
+
# Budgets
|
|
116
|
+
budgets: list[BudgetRow] = []
|
|
117
|
+
if not already_capped:
|
|
118
|
+
for t in sorted(targets, reverse=True):
|
|
119
|
+
headroom = t - inputs.p_now
|
|
120
|
+
if headroom <= 0 or inputs.remaining_days <= 0:
|
|
121
|
+
budgets.append(BudgetRow(target_percent=t, pct_headroom=None,
|
|
122
|
+
dollars_per_day=None, percent_per_day=None))
|
|
123
|
+
continue
|
|
124
|
+
dollars_day = (headroom * inputs.dollars_per_percent) / inputs.remaining_days
|
|
125
|
+
pct_day = headroom / inputs.remaining_days
|
|
126
|
+
budgets.append(BudgetRow(
|
|
127
|
+
target_percent=t,
|
|
128
|
+
pct_headroom=headroom,
|
|
129
|
+
dollars_per_day=dollars_day,
|
|
130
|
+
percent_per_day=pct_day,
|
|
131
|
+
))
|
|
132
|
+
|
|
133
|
+
return ForecastOutput(
|
|
134
|
+
inputs=inputs,
|
|
135
|
+
r_avg=r_avg,
|
|
136
|
+
r_recent=r_recent,
|
|
137
|
+
final_percent_low=final_low,
|
|
138
|
+
final_percent_high=final_high,
|
|
139
|
+
week_avg_projection_pct=week_avg_projection_pct,
|
|
140
|
+
projected_cap=projected_cap,
|
|
141
|
+
already_capped=already_capped,
|
|
142
|
+
cap_at=cap_at,
|
|
143
|
+
budgets=budgets,
|
|
144
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Pure JSON wire-format helpers: schemaVersion envelope + canonical UTC-Z serializer.
|
|
2
|
+
|
|
3
|
+
stamp_schema_version() is the single chokepoint for the additive camelCase
|
|
4
|
+
``schemaVersion: 1`` envelope adopted across reporting ``--json`` surfaces
|
|
5
|
+
(#279 S6 W1; convention: docs/cli-contract.md). _iso_z() is the canonical
|
|
6
|
+
None-safe seconds-precision UTC-Z serializer (the forecast/dashboard-envelope
|
|
7
|
+
behavior; _lib_doctor._iso_z deliberately diverges — see its docstring).
|
|
8
|
+
|
|
9
|
+
Pure kernel: stdlib-only, no cctally back-import (split design §5.3).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as dt
|
|
14
|
+
|
|
15
|
+
SCHEMA_VERSION_KEY = "schemaVersion"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def stamp_schema_version(payload: dict, *, version: int = 1,
|
|
19
|
+
key: str = SCHEMA_VERSION_KEY) -> dict:
|
|
20
|
+
"""Return a new dict with ``key: version`` first.
|
|
21
|
+
|
|
22
|
+
Always returns a shallow copy; never mutates ``payload``. If ``key`` is
|
|
23
|
+
already present the copy preserves the existing value AND key order
|
|
24
|
+
(value- and order-idempotent no-op). Formatting is the caller's concern:
|
|
25
|
+
this returns a dict, each emitter keeps its own json.dumps arguments.
|
|
26
|
+
"""
|
|
27
|
+
if key in payload:
|
|
28
|
+
return dict(payload)
|
|
29
|
+
out = {key: version}
|
|
30
|
+
out.update(payload)
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _iso_z(d: "dt.datetime | None") -> "str | None":
|
|
35
|
+
"""None-safe UTC ISO-8601 with Z suffix, seconds precision."""
|
|
36
|
+
if d is None:
|
|
37
|
+
return None
|
|
38
|
+
return d.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|