cctally 1.103.0 → 1.104.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 (38) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/bin/_cctally_alerts.py +65 -4
  3. package/bin/_cctally_config.py +62 -2
  4. package/bin/_cctally_core.py +62 -1
  5. package/bin/_cctally_dashboard.py +11 -0
  6. package/bin/_cctally_dashboard_envelope.py +319 -14
  7. package/bin/_cctally_dashboard_share.py +56 -25
  8. package/bin/_cctally_doctor.py +63 -0
  9. package/bin/_cctally_forecast.py +917 -44
  10. package/bin/_cctally_journal.py +153 -5
  11. package/bin/_cctally_parser.py +66 -0
  12. package/bin/_cctally_project.py +535 -10
  13. package/bin/_cctally_quota.py +13 -0
  14. package/bin/_cctally_quota_calibration.py +146 -0
  15. package/bin/_cctally_quota_model.py +1616 -0
  16. package/bin/_cctally_record.py +114 -6
  17. package/bin/_cctally_share.py +16 -8
  18. package/bin/_cctally_statusline.py +34 -0
  19. package/bin/_cctally_tui.py +214 -45
  20. package/bin/_lib_dashboard_settings_contract.py +2 -0
  21. package/bin/_lib_doctor.py +159 -1
  22. package/bin/_lib_forecast.py +337 -43
  23. package/bin/_lib_meter_rate_change.py +294 -0
  24. package/bin/_lib_quota_calibration.py +311 -0
  25. package/bin/_lib_quota_copy.py +131 -0
  26. package/bin/_lib_quota_model.py +2333 -0
  27. package/bin/_lib_rederive.py +10 -0
  28. package/bin/_lib_render.py +6 -0
  29. package/bin/_lib_share_templates.py +37 -5
  30. package/bin/_lib_statusline.py +226 -2
  31. package/bin/_lib_view_models.py +30 -12
  32. package/bin/cctally +32 -0
  33. package/dashboard/static/assets/index-D19TO7Mg.js +97 -0
  34. package/dashboard/static/assets/index-klO46NcU.css +1 -0
  35. package/dashboard/static/dashboard.html +2 -2
  36. package/package.json +7 -1
  37. package/dashboard/static/assets/index-Di2hljvB.css +0 -1
  38. 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,222 @@ 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 _forecast_quota_envelope(fc, cw, rate_change):
1217
+ """Spec §10's optional typed `forecast.quota` object.
1218
+
1219
+ Additive, and deliberately does NOT bump `source_schema_version`: that
1220
+ field describes the provider-source bundles, not this.
1221
+
1222
+ Every quantity here was already computed. The basis, the codes, the
1223
+ corrected interval and the calibrated triple come off the forecast the
1224
+ envelope is already serializing, and `rate_change` is the caller's cheap
1225
+ calibration-file read. Nothing here opens a store.
1226
+
1227
+ NO PRESENTATION LATCH: this is rebuilt from the current forecast on
1228
+ every refresh, so the surface switches with the shared selector rather
1229
+ than presenting a model whose evidence has since been withheld.
1230
+ """
1231
+ if fc is None:
1232
+ return None
1233
+ inputs = getattr(fc, "inputs", None)
1234
+ interval = getattr(inputs, "p_now_interval", None)
1235
+ code = getattr(fc, "projection_code", None)
1236
+ calibration_code = getattr(inputs, "calibrated_withheld_code", None)
1237
+ consumption = getattr(inputs, "calibrated_consumption_pct", None)
1238
+ consumption_interval = getattr(
1239
+ inputs, "calibrated_consumption_interval", None)
1240
+ observed = getattr(cw, "used_pct", None) if cw is not None else None
1241
+ basis = getattr(fc, "projection_basis", None)
1242
+ projection = getattr(fc, "week_avg_projection_pct", None)
1243
+ headroom = getattr(inputs, "calibrated_headroom_pct", None)
1244
+ # The calibrated view is published as a UNIT (#661 S2 review, D2).
1245
+ #
1246
+ # `select_projection_basis` reads ONLY `calibrated_projection_pct`, so a
1247
+ # non-null projection beside a null modelled consumption would publish
1248
+ # `basis: "calibrated"` over a number the model does not state — a modal
1249
+ # reading "basis: model" above "modelled consumption: —". The invariant
1250
+ # held until now only because `_calibrated_week_detail` sets the whole
1251
+ # triple or none of it, which is the producer's discipline rather than a
1252
+ # property of this serializer.
1253
+ #
1254
+ # WITHHELD rather than demoted. Demoting to `corrected-meter` would
1255
+ # relabel a number the calibrated model produced, and the meter's own
1256
+ # projection is not on this forecast to substitute, so there is nothing
1257
+ # honest to publish. `withheld` is an already-rendered basis on the panel
1258
+ # and in the modal, and it carries a cause.
1259
+ # `projection is None` is included for completeness rather than because
1260
+ # it is reachable: `select_projection_basis` reads `calibrated_projection_pct`,
1261
+ # so a null projection does not yield this basis in the first place, and
1262
+ # `bin/_lib_forecast.py` nulls the projection only on its WITHHELD branch.
1263
+ # The argument that motivates the other two operands -- that the whole
1264
+ # triple is the producer's discipline and not a property of this
1265
+ # serializer -- applies to the projection identically, so leaving it out
1266
+ # would make the guard state a narrower invariant than the one it means.
1267
+ if basis == "calibrated" and (projection is None
1268
+ or consumption is None
1269
+ or headroom is None):
1270
+ basis = "withheld"
1271
+ projection = None
1272
+ consumption = None
1273
+ consumption_interval = None
1274
+ headroom = None
1275
+ code = code or "unavailable"
1276
+ calibration_code = calibration_code or "unavailable"
1277
+ return {
1278
+ "basis": basis,
1279
+ "projection_pct": projection,
1280
+ "right_censored": bool(getattr(fc, "right_censored", False)),
1281
+ # The cause of a WITHHELD projection, and separately why the
1282
+ # CALIBRATED basis was not reached. They are different questions and
1283
+ # a surface that collapses them says nothing when it falls back.
1284
+ "code": code,
1285
+ "code_presentation": _cause_presentation(code),
1286
+ "calibration_code": calibration_code,
1287
+ "calibration_code_presentation":
1288
+ _cause_presentation(calibration_code),
1289
+ # The corrected-meter interval a displayed reading denotes. `hi` is
1290
+ # null at a right-censored 100, which is unbounded above.
1291
+ "corrected_interval":
1292
+ None if interval is None
1293
+ else {"lo": interval[0], "hi": interval[1]},
1294
+ "calibrated_consumption_pct": consumption,
1295
+ "calibrated_consumption_interval":
1296
+ None if not consumption_interval
1297
+ else {"lo": consumption_interval[0],
1298
+ "hi": consumption_interval[1]},
1299
+ "calibrated_headroom_pct": headroom,
1300
+ "rate_change": rate_change,
1301
+ # Spec §10: the observed meter minus the modelled local quota,
1302
+ # labelled as exactly that. It is a DIFFERENCE between two
1303
+ # quantities and does not identify or estimate usage from another
1304
+ # machine; the modal states that sentence, and it is null unless
1305
+ # both sides exist.
1306
+ "observed_minus_modelled_pct":
1307
+ None if (observed is None or consumption is None)
1308
+ else observed - consumption,
1309
+ }
1310
+
1311
+
1312
+ def _header_rate_and_delta(current_rate, current_week_start, trend):
1313
+ """`(dollar_per_pct, vs_last_week_delta)` from ONE current operand.
1314
+
1315
+ Spec §10.1, and the defect it closes: `header.dollar_per_pct` came from
1316
+ `_tui_build_current_week` while `header.vs_last_week_delta` came from the
1317
+ operand `build_trend_view` returned. Two computations, no guard, so the
1318
+ hero could publish a week-over-week comparison on a screen stating no
1319
+ `$ / 1%` at all.
1320
+
1321
+ Both values are computed here, from the header's own current operand,
1322
+ before serialization — so the client never reconciles two differently
1323
+ sourced "current" values. When the current rate is absent BOTH are null,
1324
+ because a delta measured from an absent operand is not a delta.
1325
+
1326
+ The prior operand is the NEAREST COMPARABLE completed row, and
1327
+ comparable is the exact list §10.1 gives. Provider and account scope are
1328
+ properties of the whole snapshot, so every row in `trend` shares them by
1329
+ construction; what has to be tested per row is the remaining three:
1330
+
1331
+ * a completed rather than current interval (`is_current` is False);
1332
+ * a non-null rate on that row; and
1333
+ * the same reset domain as the current week.
1334
+
1335
+ A row failing any of them is SKIPPED and the walk continues to the next
1336
+ older row, rather than the delta being abandoned or the row being used.
1337
+
1338
+ NEAREST IS BY ANCHOR, not by list position. The walk used to be
1339
+ `reversed(list(trend))`, which makes "nearest" mean "last in the list" and
1340
+ rests the whole clause on `snap.trend` arriving ascending. That holds for
1341
+ `build_trend_view` and for every committed fixture and was stated nowhere,
1342
+ and `_same_reset_domain` accepts any positive whole-week multiple, so a
1343
+ row twenty weeks back was as comparable as one week back. The candidates
1344
+ are therefore ordered here, newest anchor first. A row carrying no anchor
1345
+ sorts last and is skipped by the reset-domain test anyway.
1346
+ """
1347
+ if current_rate is None:
1348
+ return None, None
1349
+ # Only rows whose anchor is a `datetime` are sorted; anything else keeps
1350
+ # list order after them. `sorted` compares the keys, so one naive anchor
1351
+ # beside an aware one -- or a `str` anchor, which the sibling row type at
1352
+ # `bin/_lib_view_models.py:174` carries -- would raise `TypeError` inside
1353
+ # the dashboard's serialization path and surface as a 500 rather than a
1354
+ # withheld figure. `build_trend_view` emits aware datetimes today, so the
1355
+ # partition is a guard against a future producer, not a live defect.
1356
+ # Ties keep list order, because `sorted` is stable: two rows sharing an
1357
+ # anchor resolve to the earlier one in `trend`.
1358
+ rows = list(trend or ())
1359
+ anchored = [r for r in rows
1360
+ if isinstance(getattr(r, "week_start_at", None), dt.datetime)]
1361
+ unanchored = [r for r in rows
1362
+ if not isinstance(getattr(r, "week_start_at", None),
1363
+ dt.datetime)]
1364
+ aware = [r for r in anchored if r.week_start_at.tzinfo is not None]
1365
+ naive = [r for r in anchored if r.week_start_at.tzinfo is None]
1366
+ candidates = (
1367
+ sorted(aware, key=lambda row: row.week_start_at, reverse=True)
1368
+ + sorted(naive, key=lambda row: row.week_start_at, reverse=True)
1369
+ + unanchored
1370
+ )
1371
+ for row in candidates:
1372
+ if getattr(row, "is_current", False):
1373
+ continue
1374
+ prior_rate = getattr(row, "dollars_per_percent", None)
1375
+ if prior_rate is None:
1376
+ continue
1377
+ if not _same_reset_domain(current_week_start,
1378
+ getattr(row, "week_start_at", None)):
1379
+ continue
1380
+ return current_rate, current_rate - prior_rate
1381
+ return current_rate, None
1382
+
1383
+
1106
1384
  def snapshot_to_envelope(snap: "DataSnapshot", *,
1107
1385
  now_utc: "dt.datetime",
1108
1386
  monotonic_now: "float | None" = None,
@@ -1256,17 +1534,13 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1256
1534
  inputs = getattr(fc, "inputs", None)
1257
1535
  if inputs is not None:
1258
1536
  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
1537
+ # The ONE decomposition (#661 S2 spec section 3.3), so this legacy
1538
+ # branch cannot answer the projection question differently from the
1539
+ # View path above it — which is exactly what the `over` golden
1540
+ # recorded before this call replaced the inline arithmetic.
1541
+ from _lib_view_models import _forecast_projection_pcts
1542
+
1543
+ fcast_pct, recent_24h_pct = _forecast_projection_pcts(fc)
1270
1544
  if getattr(fc, "already_capped", False):
1271
1545
  verdict = "capped"
1272
1546
  elif getattr(fc, "projected_cap", False):
@@ -1474,6 +1748,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1474
1748
  # the entire snapshot — fall back to safe defaults and rely on
1475
1749
  # `_warn_alerts_bad_config_once` for the user-visible signal.
1476
1750
  alerts_array = list(getattr(snap, "alerts", []) or [])
1751
+ # #661 S2 §6.1. Precomputed at sync time beside `alerts`, so
1752
+ # `snapshot_to_envelope` stays a pure renderer with no DB I/O on the
1753
+ # dashboard hot path.
1754
+ meter_rate_changes_array = list(
1755
+ getattr(snap, "meter_rate_changes", []) or [])
1477
1756
  # #268 M4: reuse the precompute's config (or the fallback load_config()
1478
1757
  # resolved above) — the envelope used to call load_config() a SECOND time
1479
1758
  # here. Within one tick both reads returned the same file, so this is
@@ -1551,6 +1830,11 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1551
1830
  # template itself).
1552
1831
  "notifier": _alerts_cfg.get("notifier", "auto"),
1553
1832
  "command_configured": _alerts_cfg.get("command_template") is not None,
1833
+ # #661 S2 §6.2: the metering-rate-change family's PUSH toggle. The
1834
+ # events themselves are recorded whatever this says, so the client
1835
+ # renders the history with no configuration; this mirror only tells
1836
+ # Settings whether the OS popup is armed.
1837
+ "rate_change_enabled": bool(_alerts_cfg.get("rate_change_enabled")),
1554
1838
  }
1555
1839
  # Dashboard render-prefs mirror (cache-failure-markers opt-out, spec §5).
1556
1840
  # Reuses the single `_cfg_for_alerts = load_config()` read above (no extra
@@ -1666,6 +1950,15 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1666
1950
  (r for r in reversed(snap.trend) if r.is_current), None
1667
1951
  ) if snap.trend else None
1668
1952
 
1953
+ # #661 S2 §10.1. BOTH header operands are decided here, together, from
1954
+ # the header's own current rate. `_current_trend` is retained above
1955
+ # because other blocks read it, but its `delta_dpp` is no longer what
1956
+ # the header publishes: that field is computed against whichever row
1957
+ # happened to precede the current one in the trend series, which is a
1958
+ # different question from "the nearest comparable prior operand".
1959
+ dollar_pp, header_delta = _header_rate_and_delta(
1960
+ dollar_pp, week_start_at_utc, snap.trend)
1961
+
1669
1962
  envelope = {
1670
1963
  "envelope_version": 2,
1671
1964
  # #278 Theme A: single additive first-paint hydration latch. True only
@@ -1718,9 +2011,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1718
2011
  "dollar_per_pct": dollar_pp,
1719
2012
  "forecast_pct": header_fcast_pct,
1720
2013
  "forecast_verdict": verdict,
1721
- "vs_last_week_delta": (
1722
- _current_trend.delta_dpp if _current_trend is not None else None
1723
- ),
2014
+ "vs_last_week_delta": header_delta,
1724
2015
  },
1725
2016
 
1726
2017
  "current_week":
@@ -1804,6 +2095,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1804
2095
  else 2 if confidence == "low"
1805
2096
  else 0),
1806
2097
  "explain": sys.modules["cctally"]._build_forecast_json_payload(fc),
2098
+ # #661 S2 §10. Optional, typed, and ADDITIVE: it sits under
2099
+ # `forecast` because `ForecastEnvelope` is its semantic
2100
+ # boundary, and it does not bump `source_schema_version`,
2101
+ # which describes the provider-source bundles.
2102
+ "quota": _forecast_quota_envelope(
2103
+ fc, cw, getattr(snap, "quota_rate_change", None)),
1807
2104
  },
1808
2105
 
1809
2106
  "trend":
@@ -1921,6 +2218,14 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
1921
2218
  "alerts": alerts_array,
1922
2219
  "alerts_settings": alerts_settings,
1923
2220
 
2221
+ # #661 S2 §6.1: the non-threshold event family, in its OWN array
2222
+ # rather than inside `alerts`. `AlertEntry` requires a numeric
2223
+ # threshold and a rate transition has none, so mixing the two would
2224
+ # either weaken that contract or ship a row every consumer skips.
2225
+ # Additive optional — `envelope_version` stays at 2, and an older
2226
+ # client simply does not read it.
2227
+ "meter_rate_changes": meter_rate_changes_array,
2228
+
1924
2229
  # Dashboard render-prefs mirror (cache-failure-markers opt-out, spec
1925
2230
  # §5). Additive optional, like alerts_settings — envelope_version
1926
2231
  # stays at 2. The client derives `markersEnabled` from this, defaulting
@@ -966,16 +966,24 @@ def _build_forecast_share_panel_data(options: dict,
966
966
  Reuses ``DataSnapshot.forecast`` (ForecastOutput) and, when populated
967
967
  by the sync thread, ``DataSnapshot.forecast_view`` (the kernel
968
968
  wrapper from issue #57) for the (100, 90) budget pair.
969
- ``projection_curve`` is synthesized from ``r_avg`` / ``r_recent`` /
970
- ``inputs.p_now`` — the same arithmetic ``snapshot_to_envelope`` does
971
- for ``week_avg_projection_pct`` / ``recent_24h_projection_pct``,
972
- extended across the next 7 days.
969
+
970
+ ``projected_end_pct`` is the kernel's SELECTED projection (#661 S2 spec
971
+ section 3.3) rather than a sixth re-derivation of it, and the curve and
972
+ the ceiling distances are measured from the same ceiling-corrected base
973
+ the kernel used. This function previously paired a RAW ``inputs.p_now``
974
+ with the corrected ``r_avg`` the kernel publishes and described that as
975
+ "the same arithmetic ``snapshot_to_envelope`` does", which stopped being
976
+ true the moment the correction landed.
977
+
978
+ Every one of those quantities is withheld at a right-censored reading and
979
+ on a week with no observed usage, because each is derived from a meter
980
+ that supplies no point estimate.
973
981
  """
974
982
  fc = getattr(snap, "forecast", None) if snap else None
975
983
  fc_view = getattr(snap, "forecast_view", None) if snap else None
976
984
  if fc is None:
977
985
  return {
978
- "projected_end_pct": 0.0,
986
+ "projected_end_pct": None,
979
987
  # With no forecast at all there is no rate, so neither ceiling has
980
988
  # a distance — the same withholding the populated path performs.
981
989
  "days_to_100pct": None,
@@ -987,25 +995,43 @@ def _build_forecast_share_panel_data(options: dict,
987
995
  "projection_curve": [],
988
996
  "confidence": "LOW CONF",
989
997
  }
998
+ from _lib_forecast import projection_base
999
+
990
1000
  inputs = getattr(fc, "inputs", None)
991
- p_now = float(getattr(inputs, "p_now", 0.0) or 0.0) if inputs else 0.0
1001
+ # The CEILING-CORRECTED base, through the one named resolver. `None` at a
1002
+ # right-censored reading, where there is no point estimate at all.
1003
+ base = projection_base(inputs) if inputs is not None else None
1004
+ p_now = None if base is None else float(base)
992
1005
  remaining_hours = float(
993
1006
  getattr(inputs, "remaining_hours", 0.0) or 0.0
994
1007
  ) if inputs else 0.0
995
1008
  confidence = getattr(inputs, "confidence", "ok") if inputs else "ok"
996
- r_avg = float(getattr(fc, "r_avg", 0.0) or 0.0)
1009
+ r_avg_raw = getattr(fc, "r_avg", None)
1010
+ r_avg = None if r_avg_raw is None else float(r_avg_raw)
997
1011
  r_recent_raw = getattr(fc, "r_recent", None)
998
1012
  r_recent = float(r_recent_raw) if r_recent_raw is not None else r_avg
999
- # End-of-week projected %
1000
- projected_end_pct = (p_now + r_avg * remaining_hours) / 100.0
1013
+ # End-of-week projected % — the kernel's selected value, expressed as the
1014
+ # fraction this panel's contract carries. `None` when the kernel withheld
1015
+ # it, which the templates render as `n/a` rather than as `0.0%`.
1016
+ selected = getattr(fc, "week_avg_projection_pct", None)
1017
+ projected_end_pct = None if selected is None else float(selected) / 100.0
1001
1018
  # Days to ceilings (simple inverse: hours-to-target / 24).
1002
- # The two exit conditions are opposite facts and must not share a value.
1003
- # `p_now >= target_pct` means the target is already reached, and zero days
1004
- # to it is true. `r_avg <= 0` means no rate was observed, so the target is
1005
- # not reachable on any timeline this data describes — and in the no-usage
1006
- # state BOTH hold (`p_now == 0`, `r_avg == 0`), so the shared `0.0`
1007
- # rendered `Days->90% 0.0`, stating the opposite of the truth.
1019
+ # The three exit conditions are different facts and must not share a
1020
+ # value. `p_now >= target_pct` means the target is already reached, and
1021
+ # zero days to it is true. `r_avg <= 0` means no rate was observed, so the
1022
+ # target is not reachable on any timeline this data describes. A withheld
1023
+ # base or rate means the meter supplies no distance at all.
1024
+ #
1025
+ # #661 S2 §3.1 changed which of these fires in the no-usage state. Before
1026
+ # the ceiling correction both `p_now == 0` and `r_avg == 0` held there,
1027
+ # and the shared `0.0` rendered `Days->90% 0.0`, stating the opposite of
1028
+ # the truth. The corrected point of a displayed 0 is 0.25, so NEITHER
1029
+ # holds now: the distance is a real, finite, very large number. Bounding
1030
+ # it is a PRESENTATION decision and is made in `_optional_days`, so this
1031
+ # function keeps publishing the exact figure.
1008
1032
  def _days_to_ceiling(target_pct: float) -> "float | None":
1033
+ if p_now is None or r_avg is None:
1034
+ return None
1009
1035
  if p_now >= target_pct:
1010
1036
  return 0.0
1011
1037
  if r_avg <= 0:
@@ -1045,18 +1071,23 @@ def _build_forecast_share_panel_data(options: dict,
1045
1071
  # defect one layer below the fix.
1046
1072
  _raw_dpp = getattr(inputs, "dollars_per_percent", None) if inputs else None
1047
1073
  dpp = None if _raw_dpp is None else float(_raw_dpp)
1048
- budgets["avg"] = None if dpp is None else dpp * r_avg * 24.0
1049
- budgets["recent_24h"] = None if dpp is None else dpp * r_recent * 24.0
1050
- # Projection curve — 7-day forward, using r_avg
1074
+ budgets["avg"] = (None if dpp is None or r_avg is None
1075
+ else dpp * r_avg * 24.0)
1076
+ budgets["recent_24h"] = (None if dpp is None or r_recent is None
1077
+ else dpp * r_recent * 24.0)
1078
+ # Projection curve — 7-day forward, using r_avg from the corrected base.
1079
+ # Empty when either is withheld: a curve drawn from a censored reading is
1080
+ # the same fabrication the scalar above refuses.
1051
1081
  today = _share_now_utc().date()
1052
1082
  projection_curve: list[dict] = []
1053
- for i in range(7):
1054
- d = today + dt.timedelta(days=i)
1055
- pct = (p_now + r_avg * (i * 24.0)) / 100.0
1056
- projection_curve.append({
1057
- "date": d.isoformat(),
1058
- "projected_pct_used": pct,
1059
- })
1083
+ if p_now is not None and r_avg is not None:
1084
+ for i in range(7):
1085
+ d = today + dt.timedelta(days=i)
1086
+ pct = (p_now + r_avg * (i * 24.0)) / 100.0
1087
+ projection_curve.append({
1088
+ "date": d.isoformat(),
1089
+ "projected_pct_used": pct,
1090
+ })
1060
1091
  return {
1061
1092
  "projected_end_pct": projected_end_pct,
1062
1093
  "days_to_100pct": days_to_100,
@@ -1021,6 +1021,41 @@ def _load_codex_quota_observations_for_doctor(*, force_cold: bool = False):
1021
1021
  return loaded
1022
1022
 
1023
1023
 
1024
+ _QUOTA_NO_CHANGE: dict = {
1025
+ "active": False, "effective_from": None,
1026
+ "previous_units_per_point": None, "new_units_per_point": None,
1027
+ }
1028
+
1029
+
1030
+ def _gather_quota_rate_change(c, rejection=None) -> "dict | None":
1031
+ """§6.6's derived marker state, read WITHOUT the prediction gate.
1032
+
1033
+ The rate-change surfaces read the regime pair deliberately: S1 marks a
1034
+ successor `detection-only` while its fit stays below the PREDICTION gate,
1035
+ and detection is exactly what a detection-grade fit is for. So this
1036
+ gather goes to the stored regimes rather than through the validated
1037
+ reader's default, which refuses that mark.
1038
+
1039
+ An ABSENT calibration is "no change detected", not "not assessed" — a
1040
+ store with no fitted regime has no transition to report, and that is the
1041
+ state of every install that has never run `cctally quota`. `None` is
1042
+ reserved for a calibration that exists and could not be read, where the
1043
+ sibling `quota.calibration` check is already WARNing about it.
1044
+ """
1045
+ if rejection == "calibration-absent":
1046
+ return dict(_QUOTA_NO_CHANGE)
1047
+ mrc = c._load_sibling("_lib_meter_rate_change")
1048
+ glue = c._load_sibling("_cctally_quota_model")
1049
+ loaded = glue.read_stored_state_readonly()
1050
+ if loaded is None:
1051
+ return None
1052
+ regimes = glue.stored_regimes(loaded, None)
1053
+ active = mrc.active_rate_change(regimes)
1054
+ if active is None:
1055
+ return dict(_QUOTA_NO_CHANGE)
1056
+ return active
1057
+
1058
+
1024
1059
  def doctor_gather_state(
1025
1060
  *,
1026
1061
  now_utc: "dt.datetime | None" = None,
@@ -2028,6 +2063,31 @@ def _doctor_gather_state_impl(
2028
2063
  except Exception:
2029
2064
  pricing_coverage = None
2030
2065
 
2066
+ with _lib_perf.phase("doctor.quota_calibration"):
2067
+ # ── Quota (#661 S2 §7) ───────────────────────────────────────────
2068
+ # Through the §1.1 NON-MUTATING reader. `load_calibrations` renames a
2069
+ # malformed or version-ahead file aside through `_quarantine`, so
2070
+ # calling it here would make a documented read-only command a writer.
2071
+ # An already-quarantined file is reported simply as a missing primary:
2072
+ # the reader cannot tell it from an absent one without scanning
2073
+ # sidecars, and it deliberately does not scan them.
2074
+ quota_calibration = None
2075
+ quota_rate_change = None
2076
+ try:
2077
+ qcg = c._load_sibling("_cctally_quota_calibration")
2078
+ read = qcg.read_calibration_file(account_key=None)
2079
+ quota_calibration = {
2080
+ "rejection": (None if read.rejection is None
2081
+ else str(read.rejection.value)),
2082
+ "status": read.regime_status,
2083
+ "present": read.regime is not None,
2084
+ }
2085
+ quota_rate_change = _gather_quota_rate_change(
2086
+ c, quota_calibration["rejection"])
2087
+ except Exception:
2088
+ quota_calibration = None
2089
+ quota_rate_change = None
2090
+
2031
2091
  # ── Meta ─────────────────────────────────────────────────────────
2032
2092
  with _lib_perf.phase("doctor.journal"):
2033
2093
  # ── Journal (DB journal redesign §9) ─────────────────────────────
@@ -2413,6 +2473,9 @@ def _doctor_gather_state_impl(
2413
2473
  telemetry_reason=telemetry_reason,
2414
2474
  # Pricing-freshness check (spec §5.1): trailing-30d coverage gaps.
2415
2475
  pricing_coverage=pricing_coverage,
2476
+ # #661 S2 §7: the `quota` category's two inputs.
2477
+ quota_calibration=quota_calibration,
2478
+ quota_rate_change=quota_rate_change,
2416
2479
  # Conversation-sessions rollup consistency (#217 S1 / U9).
2417
2480
  conv_sessions_rollup_count=conv_sessions_rollup_count,
2418
2481
  conv_messages_distinct_sessions=conv_messages_distinct_sessions,