cctally 1.103.0 → 1.105.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +6 -6
  3. package/bin/_cctally_alerts.py +104 -10
  4. package/bin/_cctally_cache.py +193 -71
  5. package/bin/_cctally_config.py +62 -2
  6. package/bin/_cctally_core.py +62 -1
  7. package/bin/_cctally_dashboard.py +694 -70
  8. package/bin/_cctally_dashboard_conversation.py +400 -6
  9. package/bin/_cctally_dashboard_envelope.py +336 -14
  10. package/bin/_cctally_dashboard_perf.py +93 -0
  11. package/bin/_cctally_dashboard_share.py +56 -25
  12. package/bin/_cctally_dashboard_sources.py +988 -94
  13. package/bin/_cctally_db.py +30 -3
  14. package/bin/_cctally_diagnosis_sources.py +699 -186
  15. package/bin/_cctally_doctor.py +77 -0
  16. package/bin/_cctally_forecast.py +932 -51
  17. package/bin/_cctally_journal.py +430 -24
  18. package/bin/_cctally_parser.py +66 -0
  19. package/bin/_cctally_project.py +535 -10
  20. package/bin/_cctally_quota.py +28 -0
  21. package/bin/_cctally_quota_calibration.py +146 -0
  22. package/bin/_cctally_quota_model.py +2142 -0
  23. package/bin/_cctally_record.py +204 -30
  24. package/bin/_cctally_share.py +16 -8
  25. package/bin/_cctally_statusline.py +34 -0
  26. package/bin/_cctally_tui.py +531 -115
  27. package/bin/_lib_codex_conversation_query.py +258 -12
  28. package/bin/_lib_codex_hooks.py +26 -0
  29. package/bin/_lib_conversation_query.py +145 -15
  30. package/bin/_lib_dashboard_json.py +105 -0
  31. package/bin/_lib_dashboard_settings_contract.py +2 -0
  32. package/bin/_lib_diagnosis.py +21 -2
  33. package/bin/_lib_doctor.py +215 -1
  34. package/bin/_lib_forecast.py +337 -43
  35. package/bin/_lib_ingest_frontier.py +889 -0
  36. package/bin/_lib_meter_rate_change.py +360 -0
  37. package/bin/_lib_perf.py +22 -0
  38. package/bin/_lib_pricing.py +30 -3
  39. package/bin/_lib_quota_calibration.py +311 -0
  40. package/bin/_lib_quota_copy.py +157 -0
  41. package/bin/_lib_quota_model.py +2520 -0
  42. package/bin/_lib_rate_change_delivery.py +119 -0
  43. package/bin/_lib_record.py +50 -0
  44. package/bin/_lib_rederive.py +10 -0
  45. package/bin/_lib_render.py +6 -0
  46. package/bin/_lib_retained_size.py +187 -0
  47. package/bin/_lib_share_templates.py +37 -5
  48. package/bin/_lib_snapshot_cache.py +504 -17
  49. package/bin/_lib_statusline.py +200 -2
  50. package/bin/_lib_tick_stats.py +28 -4
  51. package/bin/_lib_view_models.py +30 -12
  52. package/bin/cctally +32 -0
  53. package/dashboard/static/assets/ConversationsView-DA4j7Yso.js +72 -0
  54. package/dashboard/static/assets/DoctorModal-D4PbVnbE.js +1 -0
  55. package/dashboard/static/assets/ModalRoot-D4oSPxPp.js +1 -0
  56. package/dashboard/static/assets/ProjectsDrillPanel-DkPlB9JH.js +1 -0
  57. package/dashboard/static/assets/SourceDetailModal-BE7FONsS.js +1 -0
  58. package/dashboard/static/assets/UpdateModal-BJuI8glf.js +7 -0
  59. package/dashboard/static/assets/index-klO46NcU.css +1 -0
  60. package/dashboard/static/assets/index-w_bINkJ8.js +13 -0
  61. package/dashboard/static/assets/outlineNavigation-zDVm6Hdd.js +9 -0
  62. package/dashboard/static/assets/useKeymap-CJ-Pi17D.js +1 -0
  63. package/dashboard/static/dashboard.html +3 -2
  64. package/package.json +10 -1
  65. package/dashboard/static/assets/index-Di2hljvB.css +0 -1
  66. package/dashboard/static/assets/index-XYCIWjVG.js +0 -97
@@ -824,6 +824,68 @@ def _source_bundle_to_envelope(bundle: object | None) -> dict:
824
824
  }
825
825
 
826
826
 
827
+ #: How many rate-change events the envelope publishes. A metering-rate
828
+ #: transition is rare — the maintainer's store holds one — so this is a
829
+ #: bound rather than a page size, and a store holding more than a handful
830
+ #: describes a detector that is misfiring rather than a busy account.
831
+ METER_RATE_CHANGE_LIMIT = 20
832
+
833
+
834
+ def _build_meter_rate_change_array(
835
+ conn: sqlite3.Connection, limit: int = METER_RATE_CHANGE_LIMIT,
836
+ ) -> list:
837
+ """The #661 S2 §6.1 non-threshold event family's wire rows.
838
+
839
+ A SEPARATE array from `alerts`, and deliberately so. Every member of
840
+ `AXIS_REGISTRY` is a numeric threshold axis: `AlertEntry` requires a
841
+ numeric `threshold`, its `id` is threshold-shaped, and `alert_row_owner`
842
+ raises on a seventh axis so that adding one without deciding its
843
+ ownership fails a test rather than shipping an invisible row. A rate
844
+ transition has neither a threshold nor a threshold-derived severity, so
845
+ it gets its own discriminated variant instead of being forced into that
846
+ union.
847
+
848
+ Each row carries the explicit severity, the old and new rates, the
849
+ effective instant, its OWNERSHIP (which provider tab renders it) and its
850
+ SCOPE (the account, decorated under R8 exactly as the alert rows are).
851
+ """
852
+ account_fields = _alert_account_resolver(conn)
853
+ try:
854
+ rows = conn.execute(
855
+ "SELECT provider, account_key, effective_from,"
856
+ " previous_units_per_point, new_units_per_point, severity,"
857
+ " detected_at_utc, created_at_utc"
858
+ " FROM meter_rate_change_events"
859
+ " ORDER BY unixepoch(effective_from) DESC, id DESC"
860
+ " LIMIT ?", (int(limit),)).fetchall()
861
+ except sqlite3.Error:
862
+ # A store predating epoch 1011 has no such table, and the rebuild
863
+ # that creates it is deferred to a background worker. An empty array
864
+ # renders as "no change recorded", which is the truth on that store.
865
+ return []
866
+ out: list[dict] = []
867
+ for (provider, account_key, effective_from, previous, new, severity,
868
+ detected_at, created_at) in rows:
869
+ entry = {
870
+ # Opaque React key. It is never parsed — the same contract the
871
+ # alert `id` carries.
872
+ "id": f"meter_rate_change:{provider}:{account_key}:{effective_from}",
873
+ "family": "meter_rate_change",
874
+ "provider": str(provider),
875
+ "owner": str(provider),
876
+ "severity": str(severity or "info"),
877
+ "effective_from": str(effective_from),
878
+ "detected_at": str(detected_at or created_at or ""),
879
+ "recorded_at": str(created_at or ""),
880
+ "previous_units_per_point": (
881
+ None if previous is None else float(previous)),
882
+ "new_units_per_point": None if new is None else float(new),
883
+ }
884
+ entry.update(account_fields(str(provider), account_key))
885
+ out.append(entry)
886
+ return out
887
+
888
+
827
889
  def _build_alerts_envelope_array(
828
890
  conn: sqlite3.Connection,
829
891
  limit: int = 100,
@@ -1103,6 +1165,239 @@ def _sync_activity_envelope(activity: "dict | None") -> dict:
1103
1165
  }
1104
1166
 
1105
1167
 
1168
+ #: How far two subscription-week anchors may drift and still count as the
1169
+ #: same reset domain. The week anchor is normalized to the hour to absorb
1170
+ #: Anthropic's reset jitter, so two hours is comfortably wider than the
1171
+ #: jitter and far narrower than the shortest interval a synthesized
1172
+ #: mid-week row could produce.
1173
+ _RESET_DOMAIN_TOLERANCE_SECONDS = 2 * 3600
1174
+ _SUBSCRIPTION_WEEK_SECONDS = 7 * 24 * 3600
1175
+
1176
+
1177
+ def _same_reset_domain(current_start, prior_start) -> bool:
1178
+ """Whether two week anchors belong to the same reset domain.
1179
+
1180
+ Spec §10.1 requires the prior operand to share the current row's reset
1181
+ domain. The rows carry no domain field, so it is derived from the one
1182
+ thing they always carry: two anchors are in the same domain when they
1183
+ are a whole number of subscription weeks apart. A row synthesized by
1184
+ reset or credit handling sits at a fractional offset and is skipped
1185
+ rather than silently used, which is exactly what that clause is for.
1186
+ """
1187
+ if current_start is None or prior_start is None:
1188
+ return False
1189
+ delta = (current_start - prior_start).total_seconds()
1190
+ if delta <= 0:
1191
+ return False
1192
+ offset = delta % _SUBSCRIPTION_WEEK_SECONDS
1193
+ return min(offset, _SUBSCRIPTION_WEEK_SECONDS - offset) <= \
1194
+ _RESET_DOMAIN_TOLERANCE_SECONDS
1195
+
1196
+
1197
+ def _cause_presentation(code):
1198
+ """The §8 presentation fields for one cause, or None for no cause.
1199
+
1200
+ ADDITIVE beside the machine `code`, which is unchanged and stays the
1201
+ stable value a client keys on. A client that receives no presentation
1202
+ fields — a new tab against an older server, which `execvp` makes
1203
+ ordinary — derives the short token from the code itself through the same
1204
+ named function this uses.
1205
+ """
1206
+ if not code:
1207
+ return None
1208
+ try:
1209
+ copy = sys.modules["cctally"]._load_sibling("_lib_quota_copy")
1210
+ except Exception: # noqa: BLE001
1211
+ return None
1212
+ out = copy.presentation(code)
1213
+ return out or None
1214
+
1215
+
1216
+ def _basis_presentation(basis):
1217
+ """The shared two-register projection-basis copy, or None.
1218
+
1219
+ Separate from `_cause_presentation`: a basis describes a present figure's
1220
+ provenance and never joins the withholding-cause vocabulary.
1221
+ """
1222
+ if not basis:
1223
+ return None
1224
+ try:
1225
+ copy = sys.modules["cctally"]._load_sibling("_lib_quota_copy")
1226
+ except Exception: # noqa: BLE001
1227
+ return None
1228
+ out = copy.basis_presentation(basis)
1229
+ return out or None
1230
+
1231
+
1232
+ def _forecast_quota_envelope(fc, cw, rate_change):
1233
+ """Spec §10's optional typed `forecast.quota` object.
1234
+
1235
+ Additive, and deliberately does NOT bump `source_schema_version`: that
1236
+ field describes the provider-source bundles, not this.
1237
+
1238
+ Every quantity here was already computed. The basis, the codes, the
1239
+ corrected interval and the calibrated triple come off the forecast the
1240
+ envelope is already serializing, and `rate_change` is the caller's cheap
1241
+ calibration-file read. Nothing here opens a store.
1242
+
1243
+ NO PRESENTATION LATCH: this is rebuilt from the current forecast on
1244
+ every refresh, so the surface switches with the shared selector rather
1245
+ than presenting a model whose evidence has since been withheld.
1246
+ """
1247
+ if fc is None:
1248
+ return None
1249
+ inputs = getattr(fc, "inputs", None)
1250
+ interval = getattr(inputs, "p_now_interval", None)
1251
+ code = getattr(fc, "projection_code", None)
1252
+ calibration_code = getattr(inputs, "calibrated_withheld_code", None)
1253
+ consumption = getattr(inputs, "calibrated_consumption_pct", None)
1254
+ consumption_interval = getattr(
1255
+ inputs, "calibrated_consumption_interval", None)
1256
+ observed = getattr(cw, "used_pct", None) if cw is not None else None
1257
+ basis = getattr(fc, "projection_basis", None)
1258
+ projection = getattr(fc, "week_avg_projection_pct", None)
1259
+ headroom = getattr(inputs, "calibrated_headroom_pct", None)
1260
+ # The calibrated view is published as a UNIT (#661 S2 review, D2).
1261
+ #
1262
+ # `select_projection_basis` reads ONLY `calibrated_projection_pct`, so a
1263
+ # non-null projection beside a null modelled consumption would publish
1264
+ # `basis: "calibrated"` over a number the model does not state — a modal
1265
+ # reading "basis: model" above "modelled consumption: —". The invariant
1266
+ # held until now only because `_calibrated_week_detail` sets the whole
1267
+ # triple or none of it, which is the producer's discipline rather than a
1268
+ # property of this serializer.
1269
+ #
1270
+ # WITHHELD rather than demoted. Demoting to `corrected-meter` would
1271
+ # relabel a number the calibrated model produced, and the meter's own
1272
+ # projection is not on this forecast to substitute, so there is nothing
1273
+ # honest to publish. `withheld` is an already-rendered basis on the panel
1274
+ # and in the modal, and it carries a cause.
1275
+ # `projection is None` is included for completeness rather than because
1276
+ # it is reachable: `select_projection_basis` reads `calibrated_projection_pct`,
1277
+ # so a null projection does not yield this basis in the first place, and
1278
+ # `bin/_lib_forecast.py` nulls the projection only on its WITHHELD branch.
1279
+ # The argument that motivates the other two operands -- that the whole
1280
+ # triple is the producer's discipline and not a property of this
1281
+ # serializer -- applies to the projection identically, so leaving it out
1282
+ # would make the guard state a narrower invariant than the one it means.
1283
+ if basis == "calibrated" and (projection is None
1284
+ or consumption is None
1285
+ or headroom is None):
1286
+ basis = "withheld"
1287
+ projection = None
1288
+ consumption = None
1289
+ consumption_interval = None
1290
+ headroom = None
1291
+ code = code or "unavailable"
1292
+ calibration_code = calibration_code or "unavailable"
1293
+ return {
1294
+ "basis": basis,
1295
+ "basis_presentation": _basis_presentation(basis),
1296
+ "projection_pct": projection,
1297
+ "right_censored": bool(getattr(fc, "right_censored", False)),
1298
+ # The cause of a WITHHELD projection, and separately why the
1299
+ # CALIBRATED basis was not reached. They are different questions and
1300
+ # a surface that collapses them says nothing when it falls back.
1301
+ "code": code,
1302
+ "code_presentation": _cause_presentation(code),
1303
+ "calibration_code": calibration_code,
1304
+ "calibration_code_presentation":
1305
+ _cause_presentation(calibration_code),
1306
+ # The corrected-meter interval a displayed reading denotes. `hi` is
1307
+ # null at a right-censored 100, which is unbounded above.
1308
+ "corrected_interval":
1309
+ None if interval is None
1310
+ else {"lo": interval[0], "hi": interval[1]},
1311
+ "calibrated_consumption_pct": consumption,
1312
+ "calibrated_consumption_interval":
1313
+ None if not consumption_interval
1314
+ else {"lo": consumption_interval[0],
1315
+ "hi": consumption_interval[1]},
1316
+ "calibrated_headroom_pct": headroom,
1317
+ "rate_change": rate_change,
1318
+ # Spec §10: the observed meter minus the modelled local quota,
1319
+ # labelled as exactly that. It is a DIFFERENCE between two
1320
+ # quantities and does not identify or estimate usage from another
1321
+ # machine; the modal states that sentence, and it is null unless
1322
+ # both sides exist.
1323
+ "observed_minus_modelled_pct":
1324
+ None if (observed is None or consumption is None)
1325
+ else observed - consumption,
1326
+ }
1327
+
1328
+
1329
+ def _header_rate_and_delta(current_rate, current_week_start, trend):
1330
+ """`(dollar_per_pct, vs_last_week_delta)` from ONE current operand.
1331
+
1332
+ Spec §10.1, and the defect it closes: `header.dollar_per_pct` came from
1333
+ `_tui_build_current_week` while `header.vs_last_week_delta` came from the
1334
+ operand `build_trend_view` returned. Two computations, no guard, so the
1335
+ hero could publish a week-over-week comparison on a screen stating no
1336
+ `$ / 1%` at all.
1337
+
1338
+ Both values are computed here, from the header's own current operand,
1339
+ before serialization — so the client never reconciles two differently
1340
+ sourced "current" values. When the current rate is absent BOTH are null,
1341
+ because a delta measured from an absent operand is not a delta.
1342
+
1343
+ The prior operand is the NEAREST COMPARABLE completed row, and
1344
+ comparable is the exact list §10.1 gives. Provider and account scope are
1345
+ properties of the whole snapshot, so every row in `trend` shares them by
1346
+ construction; what has to be tested per row is the remaining three:
1347
+
1348
+ * a completed rather than current interval (`is_current` is False);
1349
+ * a non-null rate on that row; and
1350
+ * the same reset domain as the current week.
1351
+
1352
+ A row failing any of them is SKIPPED and the walk continues to the next
1353
+ older row, rather than the delta being abandoned or the row being used.
1354
+
1355
+ NEAREST IS BY ANCHOR, not by list position. The walk used to be
1356
+ `reversed(list(trend))`, which makes "nearest" mean "last in the list" and
1357
+ rests the whole clause on `snap.trend` arriving ascending. That holds for
1358
+ `build_trend_view` and for every committed fixture and was stated nowhere,
1359
+ and `_same_reset_domain` accepts any positive whole-week multiple, so a
1360
+ row twenty weeks back was as comparable as one week back. The candidates
1361
+ are therefore ordered here, newest anchor first. A row carrying no anchor
1362
+ sorts last and is skipped by the reset-domain test anyway.
1363
+ """
1364
+ if current_rate is None:
1365
+ return None, None
1366
+ # Only rows whose anchor is a `datetime` are sorted; anything else keeps
1367
+ # list order after them. `sorted` compares the keys, so one naive anchor
1368
+ # beside an aware one -- or a `str` anchor, which the sibling row type at
1369
+ # `bin/_lib_view_models.py:174` carries -- would raise `TypeError` inside
1370
+ # the dashboard's serialization path and surface as a 500 rather than a
1371
+ # withheld figure. `build_trend_view` emits aware datetimes today, so the
1372
+ # partition is a guard against a future producer, not a live defect.
1373
+ # Ties keep list order, because `sorted` is stable: two rows sharing an
1374
+ # anchor resolve to the earlier one in `trend`.
1375
+ rows = list(trend or ())
1376
+ anchored = [r for r in rows
1377
+ if isinstance(getattr(r, "week_start_at", None), dt.datetime)]
1378
+ unanchored = [r for r in rows
1379
+ if not isinstance(getattr(r, "week_start_at", None),
1380
+ dt.datetime)]
1381
+ aware = [r for r in anchored if r.week_start_at.tzinfo is not None]
1382
+ naive = [r for r in anchored if r.week_start_at.tzinfo is None]
1383
+ candidates = (
1384
+ sorted(aware, key=lambda row: row.week_start_at, reverse=True)
1385
+ + sorted(naive, key=lambda row: row.week_start_at, reverse=True)
1386
+ + unanchored
1387
+ )
1388
+ for row in candidates:
1389
+ if getattr(row, "is_current", False):
1390
+ continue
1391
+ prior_rate = getattr(row, "dollars_per_percent", None)
1392
+ if prior_rate is None:
1393
+ continue
1394
+ if not _same_reset_domain(current_week_start,
1395
+ getattr(row, "week_start_at", None)):
1396
+ continue
1397
+ return current_rate, current_rate - prior_rate
1398
+ return current_rate, None
1399
+
1400
+
1106
1401
  def snapshot_to_envelope(snap: "DataSnapshot", *,
1107
1402
  now_utc: "dt.datetime",
1108
1403
  monotonic_now: "float | None" = None,
@@ -1256,17 +1551,13 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1256
1551
  inputs = getattr(fc, "inputs", None)
1257
1552
  if inputs is not None:
1258
1553
  confidence = getattr(inputs, "confidence", None)
1259
- r_avg = getattr(fc, "r_avg", None)
1260
- r_recent = getattr(fc, "r_recent", None)
1261
- p_now = getattr(inputs, "p_now", None) if inputs is not None else None
1262
- rem_hrs = getattr(inputs, "remaining_hours", None) if inputs is not None else None
1263
- if p_now is not None and rem_hrs is not None and r_avg is not None:
1264
- fcast_pct = p_now + r_avg * rem_hrs
1265
- if (p_now is not None and rem_hrs is not None
1266
- and r_recent is not None):
1267
- p_final_recent = p_now + r_recent * rem_hrs
1268
- if fcast_pct is None or p_final_recent != fcast_pct:
1269
- recent_24h_pct = p_final_recent
1554
+ # The ONE decomposition (#661 S2 spec section 3.3), so this legacy
1555
+ # branch cannot answer the projection question differently from the
1556
+ # View path above it — which is exactly what the `over` golden
1557
+ # recorded before this call replaced the inline arithmetic.
1558
+ from _lib_view_models import _forecast_projection_pcts
1559
+
1560
+ fcast_pct, recent_24h_pct = _forecast_projection_pcts(fc)
1270
1561
  if getattr(fc, "already_capped", False):
1271
1562
  verdict = "capped"
1272
1563
  elif getattr(fc, "projected_cap", False):
@@ -1474,6 +1765,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1474
1765
  # the entire snapshot — fall back to safe defaults and rely on
1475
1766
  # `_warn_alerts_bad_config_once` for the user-visible signal.
1476
1767
  alerts_array = list(getattr(snap, "alerts", []) or [])
1768
+ # #661 S2 §6.1. Precomputed at sync time beside `alerts`, so
1769
+ # `snapshot_to_envelope` stays a pure renderer with no DB I/O on the
1770
+ # dashboard hot path.
1771
+ meter_rate_changes_array = list(
1772
+ getattr(snap, "meter_rate_changes", []) or [])
1477
1773
  # #268 M4: reuse the precompute's config (or the fallback load_config()
1478
1774
  # resolved above) — the envelope used to call load_config() a SECOND time
1479
1775
  # here. Within one tick both reads returned the same file, so this is
@@ -1551,6 +1847,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1551
1847
  # template itself).
1552
1848
  "notifier": _alerts_cfg.get("notifier", "auto"),
1553
1849
  "command_configured": _alerts_cfg.get("command_template") is not None,
1850
+ # #661 S2 §6.2: the metering-rate-change family's PUSH toggle. The
1851
+ # events themselves are recorded whatever this says, so the client
1852
+ # renders the history with no configuration; this mirror only tells
1853
+ # Settings whether the OS popup is armed.
1854
+ "rate_change_enabled": bool(_alerts_cfg.get("rate_change_enabled")),
1554
1855
  }
1555
1856
  # Dashboard render-prefs mirror (cache-failure-markers opt-out, spec §5).
1556
1857
  # Reuses the single `_cfg_for_alerts = load_config()` read above (no extra
@@ -1666,6 +1967,15 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1666
1967
  (r for r in reversed(snap.trend) if r.is_current), None
1667
1968
  ) if snap.trend else None
1668
1969
 
1970
+ # #661 S2 §10.1. BOTH header operands are decided here, together, from
1971
+ # the header's own current rate. `_current_trend` is retained above
1972
+ # because other blocks read it, but its `delta_dpp` is no longer what
1973
+ # the header publishes: that field is computed against whichever row
1974
+ # happened to precede the current one in the trend series, which is a
1975
+ # different question from "the nearest comparable prior operand".
1976
+ dollar_pp, header_delta = _header_rate_and_delta(
1977
+ dollar_pp, week_start_at_utc, snap.trend)
1978
+
1669
1979
  envelope = {
1670
1980
  "envelope_version": 2,
1671
1981
  # #278 Theme A: single additive first-paint hydration latch. True only
@@ -1718,9 +2028,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1718
2028
  "dollar_per_pct": dollar_pp,
1719
2029
  "forecast_pct": header_fcast_pct,
1720
2030
  "forecast_verdict": verdict,
1721
- "vs_last_week_delta": (
1722
- _current_trend.delta_dpp if _current_trend is not None else None
1723
- ),
2031
+ "vs_last_week_delta": header_delta,
1724
2032
  },
1725
2033
 
1726
2034
  "current_week":
@@ -1804,6 +2112,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1804
2112
  else 2 if confidence == "low"
1805
2113
  else 0),
1806
2114
  "explain": sys.modules["cctally"]._build_forecast_json_payload(fc),
2115
+ # #661 S2 §10. Optional, typed, and ADDITIVE: it sits under
2116
+ # `forecast` because `ForecastEnvelope` is its semantic
2117
+ # boundary, and it does not bump `source_schema_version`,
2118
+ # which describes the provider-source bundles.
2119
+ "quota": _forecast_quota_envelope(
2120
+ fc, cw, getattr(snap, "quota_rate_change", None)),
1807
2121
  },
1808
2122
 
1809
2123
  "trend":
@@ -1921,6 +2235,14 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1921
2235
  "alerts": alerts_array,
1922
2236
  "alerts_settings": alerts_settings,
1923
2237
 
2238
+ # #661 S2 §6.1: the non-threshold event family, in its OWN array
2239
+ # rather than inside `alerts`. `AlertEntry` requires a numeric
2240
+ # threshold and a rate transition has none, so mixing the two would
2241
+ # either weaken that contract or ship a row every consumer skips.
2242
+ # Additive optional — `envelope_version` stays at 2, and an older
2243
+ # client simply does not read it.
2244
+ "meter_rate_changes": meter_rate_changes_array,
2245
+
1924
2246
  # Dashboard render-prefs mirror (cache-failure-markers opt-out, spec
1925
2247
  # §5). Additive optional, like alerts_settings — envelope_version
1926
2248
  # stays at 2. The client derives `markersEnabled` from this, defaulting
@@ -47,6 +47,7 @@ _TRACE_PATH = "/api/debug/backend/trace"
47
47
  _REPORTED_REGIMES = ("active", "idle")
48
48
 
49
49
  _REGIME_LABELS = {"active": "Codex-active", "idle": "Codex-idle"}
50
+ COMBINED_BACKGROUND_CPU_CEILING = 0.75
50
51
 
51
52
 
52
53
  def _cctally():
@@ -202,6 +203,36 @@ def _mean_or_none(values):
202
203
  return int(sum(values) / len(values)) if values else None
203
204
 
204
205
 
206
+ def _cpu_duty(rows):
207
+ paired = [
208
+ (int(row.get("cpu_ns") or 0), int(row["period_ns"]))
209
+ for row in rows or ()
210
+ if row.get("cpu_ns") is not None
211
+ and row.get("period_ns") is not None
212
+ and int(row["period_ns"]) > 0
213
+ ]
214
+ if not paired:
215
+ return None
216
+ return sum(cpu for cpu, _period in paired) / sum(
217
+ period for _cpu, period in paired)
218
+
219
+
220
+ def summarise_combined_background_duty(tick: dict) -> dict:
221
+ """Conservative sum of each loop's same-span one-core duty."""
222
+ main = _cpu_duty(tick.get("records") or ())
223
+ conversations = _cpu_duty(tick.get("conversation_sync") or ())
224
+ combined = None if main is None or conversations is None else (
225
+ main + conversations)
226
+ return {
227
+ "main": main,
228
+ "conversation": conversations,
229
+ "combined": combined,
230
+ "ceiling": COMBINED_BACKGROUND_CPU_CEILING,
231
+ "withinCeiling": None if combined is None else (
232
+ combined <= COMBINED_BACKGROUND_CPU_CEILING),
233
+ }
234
+
235
+
205
236
  def _median(values):
206
237
  import statistics
207
238
 
@@ -265,6 +296,17 @@ def _render_conversation_sync(tick: dict) -> list:
265
296
  statuses[key] = statuses.get(key, 0) + 1
266
297
  detail = " · ".join(f"{k} {v}" for k, v in sorted(statuses.items()))
267
298
  lines.append(f" {'status':<14} {detail}")
299
+ for provider in ("claude", "codex"):
300
+ modes: dict = {}
301
+ files = 0
302
+ for record in rows:
303
+ mode = record.get(f"{provider}_mode") or "not_observed"
304
+ modes[mode] = modes.get(mode, 0) + 1
305
+ files += max(0, int(record.get(f"{provider}_files") or 0))
306
+ mode_detail = " · ".join(
307
+ f"{mode} {count}" for mode, count in sorted(modes.items()))
308
+ lines.append(
309
+ f" {provider:<14} {mode_detail} · files {files}")
268
310
  return lines
269
311
 
270
312
 
@@ -316,6 +358,23 @@ def render_dashboard_perf(payload: dict) -> str:
316
358
  f"over {len(builder)} tick(s)")
317
359
  lines.append(
318
360
  f" whole tick mean {_millis(_mean_or_none(duration))}")
361
+ cpu = [int(r.get("cpu_ns") or 0) for r in records]
362
+ lines.append(
363
+ f" thread cpu mean {_millis(_mean_or_none(cpu))}")
364
+ paired_cpu = [
365
+ (int(r.get("cpu_ns") or 0), int(r["period_ns"]))
366
+ for r in records
367
+ if r.get("cpu_ns") is not None
368
+ and r.get("period_ns") is not None
369
+ and int(r["period_ns"]) > 0
370
+ ]
371
+ if paired_cpu:
372
+ duty = sum(value for value, _period in paired_cpu) / sum(
373
+ period for _value, period in paired_cpu
374
+ )
375
+ lines.append(f" cpu duty {duty * 100:.1f}% of one core")
376
+ else:
377
+ lines.append(" cpu duty no samples yet")
319
378
  # #583 S5 §2.4: the cache.db read pin, measured at its own BEGIN and
320
379
  # ROLLBACK boundaries. Reported separately from `builder` because it
321
380
  # is a SUBSET of builder time rather than a third exclusive half, and
@@ -331,6 +390,7 @@ def render_dashboard_perf(payload: dict) -> str:
331
390
  f"{'/cold' if newest.get('cold') else '/warm'} "
332
391
  f"ingest {_millis(newest.get('ingest_ns'))} "
333
392
  f"builder {_millis(newest.get('builder_ns'))} "
393
+ f"cpu {_millis(newest.get('cpu_ns'))} "
334
394
  f"pin {_millis(newest.get('cache_pin_ns', 0) or 0)} "
335
395
  f"total {_millis(newest.get('duration_ns'))}")
336
396
  else:
@@ -342,6 +402,15 @@ def render_dashboard_perf(payload: dict) -> str:
342
402
  f"total {_millis(standalone.get('duration_ns'))} "
343
403
  f"(the last build made outside a refresh tick)")
344
404
  lines.extend(_render_conversation_sync(tick))
405
+ combined = summarise_combined_background_duty(tick)
406
+ lines.extend(["", "Combined background work"])
407
+ if combined["combined"] is None:
408
+ lines.append(" duty not jointly sampled")
409
+ else:
410
+ verdict = "within" if combined["withinCeiling"] else "over"
411
+ lines.append(
412
+ f" duty {combined['combined'] * 100:.1f}% of one core · "
413
+ f"ceiling {combined['ceiling'] * 100:.1f}% · {verdict}")
345
414
  lines.append("")
346
415
 
347
416
  counts = tick.get("dispatch_counts") or {}
@@ -359,6 +428,24 @@ def render_dashboard_perf(payload: dict) -> str:
359
428
  else:
360
429
  lines.append("Group A cache-open failures none")
361
430
 
431
+ memory = payload.get("memory") or {}
432
+ if memory:
433
+ lines.append("")
434
+ lines.append("Retained process memory")
435
+ lines.append(
436
+ " owned "
437
+ f"{int(memory.get('ownerEstimatedBytes', 0)) / 1048576:.1f} MiB "
438
+ f"of {int(memory.get('ownerCeilingBytes', 0)) / 1048576:.1f} MiB")
439
+ lines.append(
440
+ " process cap "
441
+ f"{int(memory.get('processCeilingBytes', 0)) / 1048576:.0f} MiB")
442
+ for name, row in sorted((memory.get("owners") or {}).items()):
443
+ lines.append(
444
+ f" {name:<22} "
445
+ f"{int(row.get('estimatedBytes', 0)) / 1048576:.1f}/"
446
+ f"{int(row.get('maxBytes', 0)) / 1048576:.1f} MiB · "
447
+ f"entries {int(row.get('entryCount', 0))}")
448
+
362
449
  applied = tracing.get("applied")
363
450
  requested = tracing.get("requested")
364
451
  applies_at = tracing.get("applies_at", "none")
@@ -374,6 +461,12 @@ def render_dashboard_perf(payload: dict) -> str:
374
461
  else:
375
462
  lines.append(" no phase tree stored — arm one with "
376
463
  "`cctally dashboard-perf --trace on`")
464
+ if payload.get("ingest_phases") is not None:
465
+ ingest = payload["ingest_phases"]
466
+ lines.append(
467
+ f" ingest tree {ingest.get('elapsed_ms', 0):.1f}ms, generated at "
468
+ f"{payload.get('ingest_generated_at')}"
469
+ )
377
470
  return "\n".join(lines) + "\n"
378
471
 
379
472