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
@@ -260,7 +260,10 @@ def _ensure_sibling_loaded(name: str) -> None:
260
260
 
261
261
 
262
262
  _ensure_sibling_loaded("_lib_forecast")
263
- from _lib_forecast import _compute_forecast, ForecastInputs, ForecastOutput, BudgetRow
263
+ from _lib_forecast import (
264
+ _compute_forecast, ForecastInputs, ForecastOutput, BudgetRow,
265
+ corrected_percent_point, projection_base,
266
+ )
264
267
  _ensure_sibling_loaded("_lib_dashboard_sources")
265
268
  _ensure_sibling_loaded("_cctally_dashboard_sources")
266
269
  from _cctally_dashboard_sources import (
@@ -423,6 +426,11 @@ def _build_alerts_envelope_array(*args, **kwargs):
423
426
  return sys.modules["cctally"]._build_alerts_envelope_array(*args, **kwargs)
424
427
 
425
428
 
429
+ def _build_meter_rate_change_array(*args, **kwargs):
430
+ return sys.modules["cctally"]._build_meter_rate_change_array(
431
+ *args, **kwargs)
432
+
433
+
426
434
  def _select_current_block_for_envelope(*args, **kwargs):
427
435
  return sys.modules["cctally"]._select_current_block_for_envelope(*args, **kwargs)
428
436
 
@@ -915,6 +923,12 @@ from _lib_view_models import ( # noqa: E402
915
923
  TuiSessionRow,
916
924
  )
917
925
 
926
+ # The ONE projection decomposition every consumer shares (#661 S2 spec
927
+ # section 3.3). The TUI used to re-derive `p_now + rate * remaining` at three
928
+ # render sites, which is how a censored reading reached the screen as a
929
+ # number the kernel had already withheld.
930
+ from _lib_view_models import _forecast_projection_pcts # noqa: E402
931
+
918
932
 
919
933
  @dataclass
920
934
  class TuiPercentMilestone:
@@ -1207,6 +1221,17 @@ class DataSnapshot:
1207
1221
  # `current_week.five_hour_block` is precomputed via
1208
1222
  # `_select_current_block_for_envelope`).
1209
1223
  alerts: list[dict] = field(default_factory=list)
1224
+ # #661 S2 §6.1: the non-threshold rate-change family's wire rows, built
1225
+ # beside `alerts` at sync time for the same reason — the envelope stays a
1226
+ # pure renderer.
1227
+ meter_rate_changes: list[dict] = field(default_factory=list)
1228
+ # #661 S2 §6.6: the DERIVED marker state — the active open regime has a
1229
+ # confirmed predecessor — read from the calibration file at sync time so
1230
+ # the envelope stays a pure renderer. `None` means no change is active,
1231
+ # which is also what an absent, malformed or quarantined calibration
1232
+ # renders. Distinct from `meter_rate_changes` above, which is the event
1233
+ # HISTORY; this is whether the current regime is a successor.
1234
+ quota_rate_change: "dict | None" = None
1210
1235
  # ---- 5h in-place credit (v1.7.x) ----
1211
1236
  # Already-envelope-shaped dicts for the CurrentWeekModal's new 5h
1212
1237
  # milestone timeline (spec §5.3, Codex r1 finding 3). Parallel to
@@ -1393,7 +1418,8 @@ class DataSnapshot:
1393
1418
  five_hour_resets_at=as_of.replace(minute=0, second=0, microsecond=0)
1394
1419
  + dt.timedelta(hours=3),
1395
1420
  spent_usd=spent_usd,
1396
- dollars_per_percent=spent_usd / used_pct,
1421
+ # The same corrected divisor `_tui_build_current_week` now uses.
1422
+ dollars_per_percent=spent_usd / corrected_percent_point(used_pct),
1397
1423
  latest_snapshot_at=as_of,
1398
1424
  freshness_label="fresh",
1399
1425
  freshness_age=12,
@@ -1416,25 +1442,31 @@ class DataSnapshot:
1416
1442
  # Recent 24h slightly lower: ~94% → r_recent = (94 - 53) / 82 ≈ 0.500.
1417
1443
  r_recent = (94.0 - used_pct) / remaining_hours if remaining_hours > 0 else 0.0
1418
1444
  p_24h_ago = max(0.0, used_pct - r_recent * 24.0)
1419
- dpp = spent_usd / used_pct
1420
- final_low = used_pct + r_recent * remaining_hours
1421
- final_high = used_pct + r_avg * remaining_hours
1445
+ # #661 S2. The CEILING-CORRECTED reading is the operand every render
1446
+ # path projects from, so the demo derives its projections and budget
1447
+ # headroom from the same one. Deriving them from the displayed
1448
+ # `used_pct` while `_tui_panel_forecast` reads the corrected base made
1449
+ # the demo screenshot's panel and header disagree by a point.
1450
+ base = corrected_percent_point(used_pct)
1451
+ dpp = spent_usd / base
1452
+ final_low = base + r_recent * remaining_hours
1453
+ final_high = base + r_avg * remaining_hours
1422
1454
  # Two BudgetRows mirroring the TUI's hard-coded targets [100, 90].
1423
1455
  budgets = [
1424
1456
  BudgetRow(
1425
1457
  target_percent=100,
1426
- pct_headroom=100.0 - used_pct,
1427
- dollars_per_day=((100.0 - used_pct) * dpp / remaining_days)
1458
+ pct_headroom=100.0 - base,
1459
+ dollars_per_day=((100.0 - base) * dpp / remaining_days)
1428
1460
  if remaining_days > 0 else None,
1429
- percent_per_day=((100.0 - used_pct) / remaining_days)
1461
+ percent_per_day=((100.0 - base) / remaining_days)
1430
1462
  if remaining_days > 0 else None,
1431
1463
  ),
1432
1464
  BudgetRow(
1433
1465
  target_percent=90,
1434
- pct_headroom=90.0 - used_pct,
1435
- dollars_per_day=((90.0 - used_pct) * dpp / remaining_days)
1466
+ pct_headroom=90.0 - base,
1467
+ dollars_per_day=((90.0 - base) * dpp / remaining_days)
1436
1468
  if remaining_days > 0 else None,
1437
- percent_per_day=((90.0 - used_pct) / remaining_days)
1469
+ percent_per_day=((90.0 - base) / remaining_days)
1438
1470
  if remaining_days > 0 else None,
1439
1471
  ),
1440
1472
  ]
@@ -1464,7 +1496,7 @@ class DataSnapshot:
1464
1496
  r_recent=r_recent,
1465
1497
  final_percent_low=final_low,
1466
1498
  final_percent_high=final_high,
1467
- week_avg_projection_pct=used_pct + r_avg * remaining_hours,
1499
+ week_avg_projection_pct=base + r_avg * remaining_hours,
1468
1500
  projected_cap=final_high >= 100.0,
1469
1501
  already_capped=False,
1470
1502
  cap_at=None,
@@ -1663,7 +1695,17 @@ def _tui_build_current_week(
1663
1695
  spent, total_tokens = _sum_cost_and_tokens_for_range(
1664
1696
  week_start_at, now_utc, mode="auto", skip_sync=skip_sync
1665
1697
  )
1666
- dpp = (spent / used_pct) if used_pct > 0 else None
1698
+ # #661 S2 spec section 3.4. This card computes its own `spent / used_pct`
1699
+ # independently of the forecast kernel, so a correction confined to that
1700
+ # kernel would leave this surface stating an uncorrected figure. The GATE
1701
+ # stays on the displayed reading (a displayed 0 corrects to 0.25, and
1702
+ # dividing by that would publish a rate on a week with no observed
1703
+ # usage); only the DIVISOR is corrected. A right-censored reading has no
1704
+ # corrected point, so the displayed reading is retained as the divisor
1705
+ # there — the same operand this card has always used.
1706
+ used_pct_corrected = corrected_percent_point(used_pct)
1707
+ dpp_divisor = used_pct if used_pct_corrected is None else used_pct_corrected
1708
+ dpp = (spent / dpp_divisor) if used_pct > 0 else None
1667
1709
  # Collect every textual variant of week_start_at that parses to the same
1668
1710
  # instant — mirrors `_fetch_current_week_snapshots` lines 9199-9210 so
1669
1711
  # legacy local-offset rows and newly UTC-canonicalized rows both contribute.
@@ -4384,6 +4426,8 @@ def _tui_build_snapshot_once(
4384
4426
  blocks_panel: list[BlocksPanelRow] = []
4385
4427
  daily_panel: list[DailyPanelRow] = []
4386
4428
  alerts: list[dict] = []
4429
+ meter_rate_changes: list[dict] = []
4430
+ quota_rate_change: "dict | None" = None
4387
4431
  # ── Sync-once (spec §4, #268) ──────────────────────────────────
4388
4432
  # Ingest new JSONL bytes into the cache EXACTLY ONCE at the top of
4389
4433
  # the rebuild, then read every builder with skip_sync=True (pure
@@ -4914,6 +4958,33 @@ def _tui_build_snapshot_once(
4914
4958
  alerts = _build_alerts_envelope_array(conn)
4915
4959
  except Exception as exc:
4916
4960
  capture_failure("alerts", "stats", exc)
4961
+ # Its OWN phase, not `build.alerts` (#661 S2 Stage C review, F13).
4962
+ # `cctally dashboard-perf --trace` reads these names as the
4963
+ # attribution of where a tick's cost went, so a second query billed to
4964
+ # a phase that did not run it makes that attribution wrong — however
4965
+ # small the query is. §6.1 keeps this family out of `AXIS_REGISTRY`
4966
+ # precisely because it is not one of the threshold axes, and the trace
4967
+ # names follow the same separation.
4968
+ with _perf.phase("build.meter_rate_changes"):
4969
+ try:
4970
+ meter_rate_changes = _build_meter_rate_change_array(conn)
4971
+ except Exception as exc:
4972
+ capture_failure("meter_rate_changes", "stats", exc)
4973
+ try:
4974
+ # §6.6's predicate, from the calibration FILE. One small
4975
+ # read through the non-mutating reader — never
4976
+ # `load_calibrations`, which quarantines by renaming — and
4977
+ # no store at all. An unreadable file yields no marker,
4978
+ # which is the same thing an absent one yields, because the
4979
+ # reader cannot tell them apart without scanning sidecars.
4980
+ _mrc = _cctally()._load_sibling("_lib_meter_rate_change")
4981
+ _qg = _cctally()._load_sibling("_cctally_quota_model")
4982
+ _state = _qg.read_stored_state_readonly()
4983
+ if _state is not None:
4984
+ quota_rate_change = _mrc.active_rate_change(
4985
+ _qg.stored_regimes(_state, None))
4986
+ except Exception as exc:
4987
+ capture_failure("quota_rate_change", "calibration", exc)
4917
4988
  # ---- 5h in-place credit (v1.7.x) ----
4918
4989
  # Load 5h milestones (pre + post credit) for the current
4919
4990
  # block's window so CurrentWeekModal can render a merged
@@ -5140,6 +5211,8 @@ def _tui_build_snapshot_once(
5140
5211
  blocks_panel=blocks_panel,
5141
5212
  daily_panel=daily_panel,
5142
5213
  alerts=alerts,
5214
+ meter_rate_changes=meter_rate_changes,
5215
+ quota_rate_change=quota_rate_change,
5143
5216
  five_hour_milestones=fh_milestones,
5144
5217
  week_index=week_index,
5145
5218
  daily_total_cost_usd=daily_total_cost_usd,
@@ -6275,6 +6348,10 @@ def _tui_verdict_of(forecast) -> str:
6275
6348
  if forecast is None or getattr(forecast.inputs, "confidence", "high") == "low":
6276
6349
  return "LOW CONF"
6277
6350
  high = forecast.final_percent_high
6351
+ if high is None:
6352
+ # A right-censored reading publishes no projection, and the meter is
6353
+ # at its cap by construction (#661 S2 spec section 3.2).
6354
+ return "OVER"
6278
6355
  if high >= 100:
6279
6356
  return "OVER"
6280
6357
  if high >= 90:
@@ -6282,6 +6359,41 @@ def _tui_verdict_of(forecast) -> str:
6282
6359
  return "GOOD"
6283
6360
 
6284
6361
 
6362
+ #: How each withheld-projection cause reads in the TUI's short register.
6363
+ #: The codes are members of the quota kernel's closed `EVIDENCE_CODES` union
6364
+ #: and arrive on `ForecastOutput.projection_code`.
6365
+ #: The SHORT register of spec section 8: every TUI slot that can carry a
6366
+ #: withheld figure is width-fixed, so the sentence form would wrap outside the
6367
+ #: panel border. The full sentences belong to the wide surfaces.
6368
+ _TUI_PROJECTION_CAUSE: dict = {
6369
+ "right-censored": "meter at its cap — no upper bound",
6370
+ "no-local-history": "no usage observed this week",
6371
+ }
6372
+
6373
+
6374
+ def _tui_projection_cause(forecast) -> str:
6375
+ """The short token a withheld projection states, never a blank."""
6376
+ code = getattr(forecast, "projection_code", None)
6377
+ return _TUI_PROJECTION_CAUSE.get(code, "projection unavailable")
6378
+
6379
+
6380
+ def _tui_projection_pair(forecast) -> "tuple[int | None, int | None]":
6381
+ """`(week_avg_pct, recent_24h_pct)` as whole percents, or `(None, None)`.
6382
+
6383
+ The week-average value is the kernel's SELECTED projection rather than a
6384
+ re-derivation of it (#661 S2 spec section 3.3), so a calibrated basis
6385
+ reaches the screen as the model's number and a withheld one reaches it as
6386
+ nothing at all. The recent-24h value is the second method and stays
6387
+ derived from the corrected base; it collapses onto the first when there
6388
+ is no 24-hour-prior sample.
6389
+ """
6390
+ week_avg, recent = _forecast_projection_pcts(forecast)
6391
+ if week_avg is None:
6392
+ return None, None
6393
+ return int(round(week_avg)), int(round(recent if recent is not None
6394
+ else week_avg))
6395
+
6396
+
6285
6397
  def _tui_panel_forecast(
6286
6398
  snap: DataSnapshot,
6287
6399
  runtime: RuntimeState,
@@ -6308,13 +6420,11 @@ def _tui_panel_forecast(
6308
6420
  cls = _tui_bar_color(val)
6309
6421
  return f"{{{cls}}}{b}{{/}}"
6310
6422
 
6311
- # Compute the two projection values DIRECTLY from the rate methods,
6312
- # not from final_low/final_high which are min/max aggregates and
6313
- # swap labels when the recent-24h rate is lower than week-avg.
6314
- p_now = fc.inputs.p_now
6315
- remaining = fc.inputs.remaining_hours
6316
- wa = int(round(p_now + fc.r_avg * remaining))
6317
- rc = wa if fc.r_recent is None else int(round(p_now + fc.r_recent * remaining))
6423
+ # The kernel's SELECTED week-average projection, and the recent-24h
6424
+ # method beside it (#661 S2 spec section 3.3). Not final_low/final_high,
6425
+ # which are min/max aggregates and swap labels when the recent-24h rate
6426
+ # is lower than the week average.
6427
+ wa, rc = _tui_projection_pair(fc)
6318
6428
  # Budget table row values
6319
6429
  b100 = next((r for r in fc.budgets if r.target_percent == 100), None)
6320
6430
  b90 = next((r for r in fc.budgets if r.target_percent == 90), None)
@@ -6322,12 +6432,25 @@ def _tui_panel_forecast(
6322
6432
  b90_str = f"${b90.dollars_per_day:.2f}/day" if b90 and b90.dollars_per_day is not None else "—"
6323
6433
  conf = "low" if verdict == "LOW CONF" else "high"
6324
6434
 
6435
+ if wa is None:
6436
+ # #661 S2 spec section 3.2: no projection is fabricated from a meter
6437
+ # that supplies no point estimate. The two bars are replaced by the
6438
+ # typed cause, not by a zero.
6439
+ projection_lines = [
6440
+ f" {{dim}}Projection{{/}} {{warn}}withheld{{/}}",
6441
+ f" {{faint}}{_tui_projection_cause(fc)}{{/}}",
6442
+ ]
6443
+ else:
6444
+ projection_lines = [
6445
+ f" {{dim}}Projection by week-avg{{/}} {bar_tagged(wa)} {{bright}}{wa:>3d}%{{/}}",
6446
+ f" {{dim}}Projection by recent 24h{{/}} {bar_tagged(rc)} {{bright}}{rc:>3d}%{{/}}",
6447
+ ]
6448
+
6325
6449
  return [
6326
6450
  "",
6327
6451
  f" {{badge.{vcls}}} [ {verdict} ] {{/}} {{dim}}{vmsg}{{/}}",
6328
6452
  "",
6329
- f" {{dim}}Projection by week-avg{{/}} {bar_tagged(wa)} {{bright}}{wa:>3d}%{{/}}",
6330
- f" {{dim}}Projection by recent 24h{{/}} {bar_tagged(rc)} {{bright}}{rc:>3d}%{{/}}",
6453
+ ] + projection_lines + [
6331
6454
  "",
6332
6455
  f" {{dim}}Budget to stay ≤100%{{/}} {{bright}}{b100_str}{{/}}",
6333
6456
  f" {{dim}}Budget to stay ≤90%{{/}} {{bright}}{b90_str}{{/}}",
@@ -6759,7 +6882,8 @@ def _tui_header_strip_a(
6759
6882
  # on final_percent_high). Using low here would display e.g.
6760
6883
  # "Fcst 74% WARN" where the WARN comes from a >=90% high
6761
6884
  # projection, understating risk in the most glanceable line.
6762
- fcst_pct = f"{int(round(fc.final_percent_high))}%"
6885
+ fcst_pct = ("\u2014" if fc.final_percent_high is None
6886
+ else f"{int(round(fc.final_percent_high))}%")
6763
6887
  hdr = preview_prefix + (
6764
6888
  f"{{bright.b}}Week {format_display_dt(cw.week_start_at, runtime.display_tz, fmt='%b %d', suffix=False)}–{format_display_dt(cw.week_end_at, runtime.display_tz, fmt='%b %d', suffix=False)}{{/}} "
6765
6889
  f"{{faint}}│{{/}} Used {{{used_cls}.b}}{cw.used_pct:.1f}%{{/}} "
@@ -7156,18 +7280,17 @@ def _tui_render_variant_b(
7156
7280
  vcls = _TUI_VERDICT_CLS[verdict]
7157
7281
  # _TUI_VERDICT_CLS always maps to ok/warn/bad after the SSoT fix, so vcls
7158
7282
  # is guaranteed to be a valid badge class here.
7283
+ wa = rc = None
7159
7284
  if snap.forecast:
7160
- # Compute projections directly from rate methods — final_low/final_high
7161
- # are min/max aggregates and swap labels when recent-24h rate is lower
7162
- # than week-average (mirrors the Variant A fix in commit 15b6fab).
7163
- p_now = snap.forecast.inputs.p_now
7164
- remaining = snap.forecast.inputs.remaining_hours
7165
- wa = int(round(p_now + snap.forecast.r_avg * remaining))
7166
- rc = wa if snap.forecast.r_recent is None else int(round(p_now + snap.forecast.r_recent * remaining))
7167
- else:
7168
- wa, rc = 0, 0
7285
+ # The kernel's selected projection and the second method beside it,
7286
+ # through the one decomposition (#661 S2 spec section 3.3).
7287
+ wa, rc = _tui_projection_pair(snap.forecast)
7169
7288
  vmsg = _TUI_VERDICT_SHORT[verdict]
7170
- ribbon_text = f" [ {verdict} ] {vmsg} · week-avg {wa}% · recent-24h {rc}%"
7289
+ if wa is None:
7290
+ ribbon_text = (f" [ {verdict} ] {vmsg} · "
7291
+ f"projection withheld")
7292
+ else:
7293
+ ribbon_text = f" [ {verdict} ] {vmsg} · week-avg {wa}% · recent-24h {rc}%"
7171
7294
  ribbon_pad = max(0, width - len(ribbon_text))
7172
7295
  ribbon = f"{{badge.{vcls}}}{ribbon_text}{' ' * ribbon_pad}{{/}}"
7173
7296
 
@@ -7267,11 +7390,21 @@ def _tui_render_variant_b(
7267
7390
  format_display_dt(cw.week_end_at, runtime.display_tz, fmt="%b %d %H:%M", suffix=True)
7268
7391
  if cw else "—"
7269
7392
  )
7393
+ if wa is None:
7394
+ # #661 S2 spec section 3.2: the strip states the withholding and
7395
+ # its cause rather than colouring a bar from a meter that supplies
7396
+ # no point estimate.
7397
+ projection_fragment = (
7398
+ f" {{dim}}projection{{/}} {{warn.b}}withheld{{/}} "
7399
+ f"{{faint}}{_tui_projection_cause(snap.forecast)}{{/}} ")
7400
+ else:
7401
+ projection_fragment = (
7402
+ f" {{dim}}wk-avg{{/}} {{{_tui_bar_color(wa)}.b}}{wa}%{{/}} "
7403
+ f"{{dim}}24h{{/}} {{{_tui_bar_color(rc)}.b}}{rc}%{{/}} ")
7270
7404
  fcstrip_body = [
7271
7405
  "",
7272
- (f" {{dim}}wk-avg{{/}} {{{_tui_bar_color(wa)}.b}}{wa}%{{/}} "
7273
- f"{{dim}}24h{{/}} {{{_tui_bar_color(rc)}.b}}{rc}%{{/}} "
7274
- f"{{faint}}│{{/}} "
7406
+ (projection_fragment
7407
+ + f"{{faint}}│{{/}} "
7275
7408
  f"{{dim}}≤100%{{/}} {{bright.b}}{b100_s}{{/}} "
7276
7409
  f"{{dim}}≤90%{{/}} {{bright.b}}{b90_s}{{/}} "
7277
7410
  f"{{faint}}│{{/}} "
@@ -7633,24 +7766,60 @@ def _tui_modal_forecast(snap, runtime, width):
7633
7766
  else:
7634
7767
  lines.append(" {dim}Used 24h ago{/} {dim}— (insufficient history){/}")
7635
7768
  lines.append("")
7636
- lines.append(" {dim.b}Two rate paths{/}")
7637
- lines.append(f" {{dim}} r_avg {inp.p_now:.1f} / {inp.elapsed_hours:.1f} = {{/}}{{b}}{fc.r_avg:.4f} %/h{{/}}")
7638
- if fc.r_recent is not None and inp.p_24h_ago is not None:
7639
- lines.append(f" {{dim}} r_recent ({inp.p_now:.1f}-{inp.p_24h_ago:.1f}) / {inp.t_24h_actual_hours:.1f} = {{/}}{{b}}{fc.r_recent:.4f} %/h{{/}}")
7769
+ # The hero band above prints the DISPLAYED readings, which is what every
7770
+ # other surface shows. The two rate lines below divide the CEILING-
7771
+ # CORRECTED midpoints of those readings, so the operands differ from the
7772
+ # numbers three lines up by half a point each. Saying so is the whole
7773
+ # point of an explain modal (#661 S2).
7774
+ lines.append(" {dim.b}Two rate paths{/} "
7775
+ "{faint}(operands are ceiling-corrected){/}")
7776
+ # `getattr`, because the modal is also driven by duck-typed inputs stubs
7777
+ # that predate the corrected pair; those fall back to the raw reading.
7778
+ _p_corrected = getattr(inp, "p_now_corrected", None)
7779
+ _p24_corrected = getattr(inp, "p_24h_ago_corrected", None)
7780
+ _rate_num = projection_base(inp)
7781
+ _censored = bool(getattr(fc, "right_censored", False))
7782
+ if fc.r_avg is None:
7783
+ lines.append(" {dim} r_avg unavailable — "
7784
+ + _tui_projection_cause(fc) + "{/}")
7785
+ else:
7786
+ lines.append(f" {{dim}} r_avg {_rate_num:.1f} / {inp.elapsed_hours:.1f} = {{/}}{{b}}{fc.r_avg:.4f} %/h{{/}}")
7787
+ if (fc.r_recent is not None and _p24_corrected is not None
7788
+ and _p_corrected is not None):
7789
+ lines.append(f" {{dim}} r_recent ({_p_corrected:.1f}-{_p24_corrected:.1f}) / {inp.t_24h_actual_hours:.1f} = {{/}}{{b}}{fc.r_recent:.4f} %/h{{/}}")
7790
+ elif _censored:
7791
+ # The cause is the CENSORING, not a missing sample: this shape can
7792
+ # carry a 24-hour-prior reading and still have no rate, because the
7793
+ # current reading has no point estimate to subtract from.
7794
+ lines.append(" {dim} r_recent unavailable — "
7795
+ + _tui_projection_cause(fc) + "{/}")
7640
7796
  else:
7641
7797
  lines.append(" {dim} r_recent unavailable — no 24h-prior sample{/}")
7642
7798
  lines.append("")
7643
7799
  lines.append(f" {{dim.b}}Project to week end ({inp.remaining_hours:.1f}h remaining){{/}}")
7644
- if fc.r_recent is not None:
7645
- wa = inp.p_now + fc.r_avg * inp.remaining_hours
7646
- rc = inp.p_now + fc.r_recent * inp.remaining_hours
7647
- lines.append(f" {{dim}} by week-avg = {{/}}{{warn}}{wa:.1f}%{{/}}")
7800
+ _wa_sel = getattr(fc, "week_avg_projection_pct", None)
7801
+ if _wa_sel is None:
7802
+ lines.append(" {dim} projection = withheld — "
7803
+ + _tui_projection_cause(fc) + "{/}")
7804
+ elif fc.r_recent is not None and fc.final_percent_high is not None:
7805
+ base = _rate_num
7806
+ rc = base + fc.r_recent * inp.remaining_hours
7807
+ lines.append(f" {{dim}} by week-avg = {{/}}{{warn}}{_wa_sel:.1f}%{{/}}")
7648
7808
  lines.append(f" {{dim}} by recent-24h = {{/}}{{ok}}{rc:.1f}%{{/}}")
7649
7809
  lines.append(f" {{dim}} high = {{/}}{{{vcls}.b}}{fc.final_percent_high:.1f}%{{/}} {{dim}}verdict:{{/}} {{{vcls}.b}}{verdict}{{/}}")
7650
7810
  else:
7651
- lines.append(f" {{dim}} projection = {{/}}{{{vcls}.b}}{fc.final_percent_high:.1f}%{{/}} {{dim}}verdict:{{/}} {{{vcls}.b}}{verdict}{{/}}")
7811
+ lines.append(f" {{dim}} projection = {{/}}{{{vcls}.b}}{_wa_sel:.1f}%{{/}} {{dim}}verdict:{{/}} {{{vcls}.b}}{verdict}{{/}}")
7652
7812
  lines.append("")
7653
7813
  lines.append(f" {{dim.b}}Daily $ budgets ({inp.remaining_days:.3f} days remaining){{/}}")
7814
+ if not fc.budgets:
7815
+ # The censored branch of `_compute_forecast` publishes NO budget rows
7816
+ # at all, because every one of them is priced off headroom the meter
7817
+ # cannot bound. The loop below then emitted nothing and the section
7818
+ # rendered as a bare header with no rows and no cause — beside a
7819
+ # projection line that states its own. A heading over silence reads
7820
+ # as a rendering fault, not as a withholding.
7821
+ lines.append(" {dim} — withheld — " + _tui_projection_cause(fc)
7822
+ + "{/}")
7654
7823
  for b in fc.budgets:
7655
7824
  if b.dollars_per_day is not None:
7656
7825
  lines.append(f" {{dim}} ≤{b.target_percent}% {{/}}{{b}}${b.dollars_per_day:.2f}/day{{/}}")
@@ -25,6 +25,8 @@ SETTINGS_LEAF_DISPOSITIONS = {
25
25
  "display.tz": WRITABLE,
26
26
  "alerts.enabled": WRITABLE,
27
27
  "alerts.projected_enabled": WRITABLE,
28
+ # #661 S2 section 6.2. Recording is unconditional; this gates the PUSH.
29
+ "alerts.rate_change_enabled": WRITABLE,
28
30
  "alerts.notifier": WRITABLE,
29
31
  "dashboard.cache_failure_markers": WRITABLE,
30
32
  "dashboard.live_tail": WRITABLE,
@@ -141,6 +141,23 @@ class DoctorState:
141
141
  # token_total); the kernel only reads `.kind`/`.model`/`.entry_count`/
142
142
  # `.token_total`, so any duck-typed equivalent works for tests.
143
143
  pricing_coverage: Optional[list] = None
144
+ # #661 S2 §7. The `quota` category's whole input, gathered through the
145
+ # §1.1 NON-MUTATING reader rather than `load_calibrations`, which renames
146
+ # a malformed or version-ahead file aside through `_quarantine` and would
147
+ # make a documented read-only command a writer.
148
+ #
149
+ # `quota_calibration` is `{"rejection": str | None, "status": str | None,
150
+ # "present": bool}`; `None` means the gather could not run at all (an
151
+ # import failure or a hand-built state), which reads as OK-with-detail
152
+ # exactly as `pricing_coverage is None` does.
153
+ #
154
+ # `quota_rate_change` is `{"active": bool, "effective_from": str | None,
155
+ # "previous_units_per_point": float | None,
156
+ # "new_units_per_point": float | None}` — §6.6's DERIVED marker
157
+ # predicate, which is "the active open regime has a confirmed
158
+ # predecessor" and requires no new durable state.
159
+ quota_calibration: Optional[dict] = None
160
+ quota_rate_change: Optional[dict] = None
144
161
  # Conversation viewer (Plan 2, spec §5): the resolved
145
162
  # `dashboard.expose_transcripts` opt-in. Only consequential when the bind
146
163
  # is LAN — `_check_safety_dashboard_bind` then surfaces an extra
@@ -952,7 +969,7 @@ def _check_db_version_ahead(s: DoctorState) -> CheckResult:
952
969
  # current index as a mismatch; keep this in lockstep with core
953
970
  # (#496 S5b §6.1). It stays a literal because this kernel is pure and
954
971
  # must not import `_cctally_core`.
955
- epoch = 1010
972
+ epoch = 1011
956
973
  mismatch = uv > legacy_head and uv != epoch
957
974
  return {"user_version": uv, "legacy_head": legacy_head, "epoch": epoch,
958
975
  "mismatch": mismatch}
@@ -1168,6 +1185,24 @@ def _check_data_codex_cache(s: DoctorState) -> CheckResult:
1168
1185
  )
1169
1186
 
1170
1187
 
1188
+ def _utc_day_label(stamp: object) -> str:
1189
+ """One ISO stamp as its UTC calendar day, or `""` when unusable.
1190
+
1191
+ A text slice is not this: `"2026-08-25T23:00:00+02:00"[:10]` is
1192
+ `2026-08-25` where the UTC day is the 25th only by luck of the offset,
1193
+ and the store holds mixed offset spellings.
1194
+ """
1195
+ if not stamp:
1196
+ return ""
1197
+ try:
1198
+ parsed = dt.datetime.fromisoformat(str(stamp).replace("Z", "+00:00"))
1199
+ except (ValueError, TypeError):
1200
+ return ""
1201
+ if parsed.tzinfo is None:
1202
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
1203
+ return parsed.astimezone(dt.timezone.utc).strftime("%Y-%m-%d")
1204
+
1205
+
1171
1206
  def _age_seconds(stamp: object, now_utc: dt.datetime) -> Optional[int]:
1172
1207
  """Seconds between an ISO-8601 UTC stamp and ``now_utc``; None if unusable."""
1173
1208
  if not stamp:
@@ -3535,6 +3570,15 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
3535
3570
  ("pricing", "Pricing", (
3536
3571
  ("pricing.coverage", "_check_pricing_coverage"),
3537
3572
  )),
3573
+ # #661 S2 §7. NEITHER check can FAIL, so `doctor`'s exit code is
3574
+ # unaffected — the same posture `pricing.coverage` and
3575
+ # `data.parse_health` take. A metering-rate change is the provider's
3576
+ # behaviour, not a cctally malfunction, and an absent calibration is the
3577
+ # ordinary state of an install that has never run `cctally quota`.
3578
+ ("quota", "Quota", (
3579
+ ("quota.meter_drift", "_check_quota_meter_drift"),
3580
+ ("quota.calibration", "_check_quota_calibration"),
3581
+ )),
3538
3582
  ("safety", "Safety", (
3539
3583
  ("safety.dashboard_bind", "_check_safety_dashboard_bind"),
3540
3584
  ("safety.backup_sync", "_check_safety_backup_sync"),
@@ -3549,6 +3593,120 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
3549
3593
  )
3550
3594
 
3551
3595
 
3596
+ #: Which `RegimeRejection` values are a WARNing rather than an ordinary
3597
+ #: state. §7: stale means a fingerprint or algorithm-revision mismatch, NOT
3598
+ #: calendar age — S1 defines no age — so a calibration fitted in January under
3599
+ #: the current constants is healthy and one fitted yesterday under other
3600
+ #: constants is not.
3601
+ _QUOTA_CALIBRATION_WARN: dict = {
3602
+ "calibration-schema-version":
3603
+ "the stored calibration is from a newer cctally",
3604
+ "calibration-fingerprint-mismatch":
3605
+ "the stored calibration was fitted under other constants",
3606
+ "calibration-revision-mismatch":
3607
+ "the stored calibration was fitted by an earlier algorithm",
3608
+ "calibration-malformed": "the stored calibration could not be parsed",
3609
+ "calibration-unreadable": "the stored calibration could not be read",
3610
+ }
3611
+
3612
+ #: The rejections that are ordinary rather than a problem. An
3613
+ #: already-quarantined file is reported simply as a missing primary: the
3614
+ #: reader cannot tell it from an absent one without scanning sidecars, and it
3615
+ #: does not scan them.
3616
+ _QUOTA_CALIBRATION_OK: dict = {
3617
+ "calibration-absent": "no calibration fitted yet",
3618
+ "calibration-detection-only":
3619
+ "a metering-rate change is fitted but not yet predictive",
3620
+ }
3621
+
3622
+
3623
+ def _check_quota_calibration(s: DoctorState) -> CheckResult:
3624
+ """WARN on an unreadable, version-ahead or fingerprint-mismatched
3625
+ calibration; OK on an absent one or one with insufficient history.
3626
+
3627
+ It cannot FAIL. Every state it reports is either normal or self-correcting
3628
+ on the next `cctally quota` run, and `doctor` FAIL exits 2.
3629
+ """
3630
+ state = s.quota_calibration
3631
+ if not state:
3632
+ return CheckResult(
3633
+ id="quota.calibration", title="Calibration",
3634
+ severity="ok", summary="not assessed",
3635
+ remediation=None,
3636
+ details={"rejection": None, "status": None, "present": False},
3637
+ )
3638
+ rejection = state.get("rejection")
3639
+ details = {
3640
+ "rejection": rejection,
3641
+ "status": state.get("status"),
3642
+ "present": bool(state.get("present")),
3643
+ }
3644
+ if rejection is None:
3645
+ return CheckResult(
3646
+ id="quota.calibration", title="Calibration",
3647
+ severity="ok", summary="fitted and usable",
3648
+ remediation=None, details=details,
3649
+ )
3650
+ warn = _QUOTA_CALIBRATION_WARN.get(str(rejection))
3651
+ if warn is not None:
3652
+ return CheckResult(
3653
+ id="quota.calibration", title="Calibration",
3654
+ severity="warn", summary=warn,
3655
+ remediation="Run `cctally quota` to refit it.",
3656
+ details=details,
3657
+ )
3658
+ return CheckResult(
3659
+ id="quota.calibration", title="Calibration",
3660
+ severity="ok",
3661
+ summary=_QUOTA_CALIBRATION_OK.get(
3662
+ str(rejection), "no usable calibration"),
3663
+ remediation=None, details=details,
3664
+ )
3665
+
3666
+
3667
+ def _check_quota_meter_drift(s: DoctorState) -> CheckResult:
3668
+ """OK-with-detail, stating whether §6.6's marker predicate holds.
3669
+
3670
+ The predicate is DERIVED, never stored: the active open regime has a
3671
+ confirmed predecessor, and the marker shows for the whole of that
3672
+ successor regime. A rate change is the provider's behaviour rather than a
3673
+ cctally malfunction, so this check states it and never warns.
3674
+ """
3675
+ state = s.quota_rate_change
3676
+ if not state:
3677
+ return CheckResult(
3678
+ id="quota.meter_drift", title="Metering rate",
3679
+ severity="ok", summary="not assessed",
3680
+ remediation=None, details={"active": False},
3681
+ )
3682
+ details = {
3683
+ "active": bool(state.get("active")),
3684
+ "effective_from": state.get("effective_from"),
3685
+ "previous_units_per_point": state.get("previous_units_per_point"),
3686
+ "new_units_per_point": state.get("new_units_per_point"),
3687
+ }
3688
+ if not details["active"]:
3689
+ return CheckResult(
3690
+ id="quota.meter_drift", title="Metering rate",
3691
+ severity="ok", summary="no change detected",
3692
+ remediation=None, details=details,
3693
+ )
3694
+ # The stamp is a clock INSTANT, so a bare `YYYY-MM-DD` slice of its text
3695
+ # names the UTC calendar day while reading as the user's own (#661 S2
3696
+ # Stage C review, F8). This kernel has no display-timezone plumbing —
3697
+ # every other datetime it emits is UTC through `_iso_z` — so the day is
3698
+ # parsed rather than sliced, normalized to UTC, and LABELLED as UTC.
3699
+ effective = _utc_day_label(details["effective_from"])
3700
+ summary = (f"rate changed {effective} UTC" if effective
3701
+ else "rate changed")
3702
+ return CheckResult(
3703
+ id="quota.meter_drift", title="Metering rate",
3704
+ severity="ok", summary=summary,
3705
+ remediation="Run `cctally quota` for the fitted budget.",
3706
+ details=details,
3707
+ )
3708
+
3709
+
3552
3710
  def _evaluate_one(check_id: str, check_fn_name: str,
3553
3711
  state: DoctorState) -> CheckResult:
3554
3712
  """Invoke a single check evaluator by name, catching any exception so