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
@@ -10,12 +10,62 @@ for the full design.
10
10
  from __future__ import annotations
11
11
 
12
12
  import json
13
+ import pathlib
13
14
  import re
15
+ import sys
14
16
  from dataclasses import dataclass, field
15
17
  from datetime import datetime, timezone
16
18
  from typing import Callable, Optional
17
19
 
18
20
 
21
+ def _load_lib(name: str):
22
+ """Late-import a sibling under ``bin/`` (the ``_lib_view_models`` recipe).
23
+
24
+ Late rather than top-level, so this KERNEL carries no import-time edge to
25
+ the quota modules and stays importable on its own.
26
+
27
+ WHAT THAT DOES NOT BUY, corrected after the #661 S2 review. An earlier
28
+ revision of this docstring claimed that a status line on an install which
29
+ has never fitted a calibration "loads none of them". At runtime it loads
30
+ all of them. ``_seven_day_projection`` imports ``_lib_forecast``
31
+ unconditionally, and ``_lib_forecast`` imports ``_lib_quota_model`` at
32
+ module scope, so the quota kernel arrives with the projection whether or
33
+ not a calibration exists. ``_rate_change_marker`` then calls
34
+ ``inj.quota_regimes()`` whenever any 5h or 7d reading exists, and that
35
+ port imports ``_cctally_quota_model``; a withheld figure imports
36
+ ``_lib_quota_copy``.
37
+
38
+ MEASURED here, as marginal cost in ONE process in the order the status
39
+ line pays it, three runs on an Apple M4 Max under CPython 3.13:
40
+ ``_cctally_core`` 13.1-13.4ms, then ``_lib_forecast`` 12.1-13.5ms,
41
+ ``_lib_quota_model`` 0.0ms because ``_lib_forecast`` already pulled it,
42
+ ``_cctally_quota_model`` 1.5-1.8ms, and ``_lib_quota_copy`` 0.1-0.3ms.
43
+ So about 14ms per prompt beyond ``_cctally_core``. Those durations are
44
+ this machine's; the STRUCTURAL fact — four modules, of which the largest
45
+ is on the unconditional projection path — is not.
46
+
47
+ That is an unstated cost rather than a violated constraint. Spec §9's
48
+ rule is about ``cache.db`` and ``analyse``, and neither is touched; see
49
+ ``tests/test_statusline_quota.py``, which makes both explode rather than
50
+ reading the source for their names. Gating the quota port behind a cheap
51
+ ``Path.exists()`` on the calibration file was measured and NOT taken: it
52
+ would remove ``_cctally_quota_model`` alone, 1.5ms of the 14, because
53
+ ``_lib_forecast`` and the kernel it pulls are already loaded by then, and
54
+ it would add a stat(2) to every prompt on an install that does have a
55
+ calibration.
56
+ """
57
+ cached = sys.modules.get(name)
58
+ if cached is not None:
59
+ return cached
60
+ import importlib.util as _ilu
61
+ p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
62
+ spec = _ilu.spec_from_file_location(name, p)
63
+ mod = _ilu.module_from_spec(spec)
64
+ sys.modules[name] = mod
65
+ spec.loader.exec_module(mod)
66
+ return mod
67
+
68
+
19
69
  # ---- Stdin payload (subset we care about) ----------------------------------
20
70
 
21
71
 
@@ -97,6 +147,18 @@ class StatuslineInjections:
97
147
  context_pct: Callable[[Optional[str], Optional[str]], Optional[float]]
98
148
  # Emits one-shot stderr warnings (deduped by message — caller maintains set).
99
149
  warn_once: Callable[[str], None]
150
+ # #661 S2 §9. The stored metering regimes for this install, oldest
151
+ # first, as plain dicts — a dedicated FILESYSTEM port and deliberately
152
+ # NOT the DB port beside it. The read takes no flock and no throttle:
153
+ # `save_calibrations` writes through `os.replace` in the same directory,
154
+ # so a lock-free reader always sees a complete old or new inode, and a
155
+ # flock here would let a writer stall a prompt. It returns `()` for
156
+ # every unusable state — absent, malformed, version-ahead, quarantined
157
+ # — because the status line cannot distinguish them without scanning
158
+ # quarantine sidecars and renders the same thing for all of them: no
159
+ # marker. The default supplies no regimes, so a caller that has not
160
+ # wired the port renders exactly what it rendered before.
161
+ quota_regimes: Callable[[], tuple] = staticmethod(lambda: ())
100
162
 
101
163
 
102
164
  # ---- ParseError sentinel ---------------------------------------------------
@@ -369,20 +431,147 @@ def _fmt_countdown(seconds: int) -> str:
369
431
  return f"{minutes}m"
370
432
 
371
433
 
434
+ #: The 7d meter's window length. The projection measures a rate over the
435
+ #: elapsed part of THIS window, so it needs the window's span, and the reset
436
+ #: epoch alone gives only its end.
437
+ _SEVEN_DAY_HOURS = 7 * 24.0
438
+
439
+ #: The marker §6.6's predicate raises. Short because it shares a line with
440
+ #: four other segments, and literal because a bare glyph would have to be
441
+ #: looked up: the word says the rate changed and `cctally quota` says how.
442
+ _RATE_CHANGE_MARKER = "Δrate"
443
+
444
+ #: How much of the window must have elapsed before a pace projection is
445
+ #: rendered here. NOT a new threshold: it is
446
+ #: `ForecastConfidenceCause.ELAPSED_HOURS`, the estate's existing rule for
447
+ #: when a week-pace projection is low-confidence, restated as a number
448
+ #: because the status line has no room for the `LOW CONF` qualifier
449
+ #: `forecast` prints beside one. Without it the slot publishes arithmetic
450
+ #: that is correct and useless: a 42% reading ten hours into a week
451
+ #: projects to 697%, and a per-prompt line is the worst place to put a
452
+ #: figure that moves by hundreds of points between prompts.
453
+ _PROJECTION_MIN_ELAPSED_HOURS = 24.0
454
+
455
+ @dataclass(frozen=True)
456
+ class _ProjectionInputs:
457
+ """The duck-typed operand `select_projection_basis` reads.
458
+
459
+ The selector is shared with `forecast` and the dashboard and takes
460
+ whatever object carries these attributes, so the status line builds the
461
+ smallest one that answers it rather than reaching for a forecast.
462
+
463
+ `calibrated_projection_pct` is deliberately absent. Producing it needs a
464
+ whole-week `session_entries` scan, and spec §9 forbids this surface from
465
+ touching `cache.db` for quota at all, so the basis on this surface is
466
+ always the corrected meter. The selector is still asked rather than
467
+ bypassed, so the token the line prints names the basis it actually used
468
+ and would name a different one the day the basis becomes cheap.
469
+ """
470
+ p_now: "float | None"
471
+ p_now_corrected: "float | None"
472
+ right_censored: bool
473
+ elapsed_hours: "float | None"
474
+ remaining_hours: "float | None"
475
+
476
+
477
+ def _seven_day_projection(seven_pct, seven_resets, now_epoch) -> Optional[str]:
478
+ """The `→ …` token for the 7d slot, or None when there is none.
479
+
480
+ None — rather than a withheld token — in the three cases where there is
481
+ nothing to say rather than a typed reason not to say it: the reset epoch
482
+ is unknown, so there is no window to project over; the reset instant has
483
+ already passed, so the window it named has closed; or too little of the
484
+ window has elapsed for a pace to mean anything. Otherwise the slot
485
+ carries a value with its basis, or the withholding cause in the short
486
+ register.
487
+ """
488
+ if seven_pct is None or seven_resets is None:
489
+ return None
490
+ if seven_resets <= now_epoch:
491
+ # A CLOSED window. The clamp below would drive `remaining` to 0 and
492
+ # `elapsed` to the whole 168 hours, so the projection would equal the
493
+ # corrected reading and the line would print a projected end-of-week
494
+ # percent for a week that has already ended. It is reachable from the
495
+ # DB-latest fallback row and from a long-idle machine, and it is a
496
+ # missing window rather than a statement about the reading — so it is
497
+ # withheld the way an unknown reset is, above the censoring branch
498
+ # and above the elapsed gate.
499
+ return None
500
+ fc = _load_lib("_lib_forecast")
501
+ remaining = (seven_resets - now_epoch) / 3600.0
502
+ remaining = max(0.0, min(_SEVEN_DAY_HOURS, remaining))
503
+ elapsed = _SEVEN_DAY_HOURS - remaining
504
+ shown = float(seven_pct)
505
+ corrected = fc.corrected_percent_point(shown)
506
+ selected = fc.select_projection_basis(_ProjectionInputs(
507
+ p_now=shown,
508
+ p_now_corrected=corrected,
509
+ # Censoring is read off the correction rather than re-tested here.
510
+ # `corrected_percent_point` returns None exactly when the reading is
511
+ # right-censored — a displayed 100 denotes `[99, +inf)` and has no
512
+ # finite point estimate — and a second spelling of that test would
513
+ # be a second place to disagree with it.
514
+ right_censored=corrected is None,
515
+ elapsed_hours=elapsed,
516
+ remaining_hours=remaining,
517
+ ))
518
+ copy = _load_lib("_lib_quota_copy")
519
+ if selected.basis is fc.ProjectionBasis.WITHHELD:
520
+ # A withholding is a statement about the READING and does not
521
+ # depend on how much of the window has elapsed, so it is rendered
522
+ # before the confidence gate below. A right-censored 100 is the
523
+ # case: it has no point estimate at any point in the week.
524
+ return f"→ {copy.short_form(selected.code)}"
525
+ if elapsed < _PROJECTION_MIN_ELAPSED_HOURS:
526
+ # Too early in the window for a pace to mean anything. The slot
527
+ # keeps the shape it has always had rather than printing a token,
528
+ # for the same reason an unknown reset epoch does: there is nothing
529
+ # to say, and a per-prompt line must not fill with non-statements.
530
+ return None
531
+ basis = copy.basis_presentation(selected.basis.value).get(
532
+ "short", selected.basis.value)
533
+ return f"→ {int(round(selected.value))}% {basis}"
534
+
535
+
536
+ def _rate_change_marker(inj: StatuslineInjections) -> Optional[str]:
537
+ """`Δrate` while §6.6's predicate holds, else None.
538
+
539
+ The predicate is DERIVED — the active open regime has a confirmed
540
+ predecessor — so the marker clears on its own when that regime closes
541
+ and needs no durable state, which §1 forbids this session from adding.
542
+ """
543
+ try:
544
+ regimes = list(inj.quota_regimes() or ())
545
+ except Exception: # noqa: BLE001
546
+ # A port that raises must not take the prompt down with it. The
547
+ # marker is information; the status line is the shell's.
548
+ return None
549
+ if not regimes:
550
+ return None
551
+ mrc = _load_lib("_lib_meter_rate_change")
552
+ return _RATE_CHANGE_MARKER if mrc.active_rate_change(regimes) else None
553
+
554
+
372
555
  def resolve_cctally_extensions(
373
556
  inp: StatuslineInput,
374
557
  now: datetime,
375
558
  inj: StatuslineInjections,
376
559
  *,
377
560
  include_countdowns: bool = True,
561
+ include_projection: bool = True,
378
562
  ) -> Optional[str]:
379
- """Segment 5 — cctally-only `5h X% (...) · 7d Y% (...)`.
563
+ """Segment 5 — cctally-only `5h X% (...) · 7d Y% (...) → Z% meter`.
380
564
 
381
565
  Source priority chain (spec §3.5):
382
566
  1. stdin rate_limits (preferred — freshest)
383
567
  2. DB latest weekly_usage_snapshots row (if stdin EMPTY)
384
568
  3. HWM monotonic clamp (within window only)
385
569
  4. If all empty → return None (segment 5 suppressed)
570
+
571
+ #661 S2 §9: the 7d slot also carries the projected end-of-week percent
572
+ with the basis it came from, and a `Δrate` marker while §6.6's predicate
573
+ holds. `include_projection` is False for `--usage-only`, which is the
574
+ compact two-reading form other tools embed and keeps its shape.
386
575
  """
387
576
  five_pct = inp.rate_limits_5h_pct
388
577
  five_resets = inp.rate_limits_5h_resets_at
@@ -421,7 +610,16 @@ def resolve_cctally_extensions(
421
610
  s = f"7d {int(round(seven_pct))}%"
422
611
  if include_countdowns and seven_resets is not None:
423
612
  s += f" ({_fmt_countdown(seven_resets - now_epoch)})"
613
+ if include_projection:
614
+ projection = _seven_day_projection(
615
+ seven_pct, seven_resets, now_epoch)
616
+ if projection is not None:
617
+ s += f" {projection}"
424
618
  parts.append(s)
619
+ if include_projection:
620
+ marker = _rate_change_marker(inj)
621
+ if marker is not None:
622
+ parts.append(marker)
425
623
  return " · ".join(parts)
426
624
 
427
625
 
@@ -471,7 +669,7 @@ def render_statusline(
471
669
  """
472
670
  if args.usage_only:
473
671
  ext = resolve_cctally_extensions(
474
- inp, now, inj, include_countdowns=False
672
+ inp, now, inj, include_countdowns=False, include_projection=False
475
673
  )
476
674
  return "" if ext is None else _colorize_usage_segment(ext, args)
477
675
 
@@ -57,6 +57,7 @@ PUBLICATIONS = ("final", "partial", "seed", "degraded")
57
57
  #: validation lives in the recorder rather than in the dataclass because a
58
58
  #: frozen field typed `str` enforces nothing on its own.
59
59
  CONVERSATION_STATUSES = ("ok", "store_unavailable", "error")
60
+ CONVERSATION_MODES = ("caught_up", "targeted", "full", "not_observed")
60
61
 
61
62
  _INGEST = "ingest"
62
63
  _BUILD = "build"
@@ -82,6 +83,7 @@ class TickRecord:
82
83
  published_at: str
83
84
  period_ns: "int | None"
84
85
  cache_pin_ns: int
86
+ cpu_ns: int
85
87
 
86
88
  def as_wire(self) -> dict:
87
89
  return {f.name: getattr(self, f.name) for f in dataclasses.fields(self)}
@@ -119,6 +121,10 @@ class ConversationSyncRecord:
119
121
  cpu_ns: int
120
122
  period_ns: "int | None"
121
123
  status: str
124
+ claude_mode: str
125
+ codex_mode: str
126
+ claude_files: int
127
+ codex_files: int
122
128
 
123
129
  def as_wire(self) -> dict:
124
130
  return {f.name: getattr(self, f.name) for f in dataclasses.fields(self)}
@@ -233,14 +239,17 @@ class _Span:
233
239
  class TickContext:
234
240
  """A tick in progress. Thread-confined; only ``finish`` touches the lock."""
235
241
 
236
- __slots__ = ("_now", "_standalone", "_started_ns", "_spans", "_finished",
242
+ __slots__ = ("_now", "_cpu_now", "_standalone", "_started_ns",
243
+ "_started_cpu_ns", "_spans", "_finished",
237
244
  "_ingest_ns", "_builder_ns", "_ingest_ran", "_dispatch",
238
245
  "_codex_regime", "_publication", "_cold", "_cache_pin_ns")
239
246
 
240
- def __init__(self, *, monotonic_ns, standalone: bool):
247
+ def __init__(self, *, monotonic_ns, thread_time_ns, standalone: bool):
241
248
  self._now = monotonic_ns
249
+ self._cpu_now = thread_time_ns
242
250
  self._standalone = standalone
243
251
  self._started_ns = monotonic_ns()
252
+ self._started_cpu_ns = thread_time_ns()
244
253
  self._spans: list[_Span] = []
245
254
  self._finished = False
246
255
  self._ingest_ns = 0
@@ -388,6 +397,7 @@ class TickContext:
388
397
  while self._spans:
389
398
  self._spans.pop().__exit__(None, None, None)
390
399
  ended_ns = self._now()
400
+ ended_cpu_ns = self._cpu_now()
391
401
  if getattr(_tls, "tick", None) is self:
392
402
  _tls.tick = None
393
403
 
@@ -419,6 +429,7 @@ class TickContext:
419
429
  published_at=published_at,
420
430
  period_ns=period,
421
431
  cache_pin_ns=self._cache_pin_ns,
432
+ cpu_ns=max(0, ended_cpu_ns - self._started_cpu_ns),
422
433
  )
423
434
  if self._standalone:
424
435
  _STATE = dataclasses.replace(prior, standalone=record)
@@ -442,7 +453,9 @@ class TickContext:
442
453
 
443
454
 
444
455
  def record_conversation_pass(
445
- *, seq, started_ns, ended_ns, duration_ns, cpu_ns, status
456
+ *, seq, started_ns, ended_ns, duration_ns, cpu_ns, status,
457
+ claude_mode="not_observed", codex_mode="not_observed",
458
+ claude_files=0, codex_files=0,
446
459
  ) -> None:
447
460
  """Append one conversation sync pass to its ring, under the SHARED lock.
448
461
 
@@ -469,6 +482,10 @@ def record_conversation_pass(
469
482
  """
470
483
  global _STATE
471
484
  safe_status = status if status in CONVERSATION_STATUSES else "error"
485
+ safe_claude_mode = (
486
+ claude_mode if claude_mode in CONVERSATION_MODES else "not_observed")
487
+ safe_codex_mode = (
488
+ codex_mode if codex_mode in CONVERSATION_MODES else "not_observed")
472
489
  start = int(started_ns)
473
490
  record = ConversationSyncRecord(
474
491
  seq=int(seq),
@@ -478,6 +495,10 @@ def record_conversation_pass(
478
495
  cpu_ns=max(0, int(cpu_ns)),
479
496
  period_ns=None,
480
497
  status=safe_status,
498
+ claude_mode=safe_claude_mode,
499
+ codex_mode=safe_codex_mode,
500
+ claude_files=max(0, int(claude_files)),
501
+ codex_files=max(0, int(codex_files)),
481
502
  )
482
503
  with _LOCK:
483
504
  prior = _STATE
@@ -495,7 +516,9 @@ def record_conversation_pass(
495
516
  )
496
517
 
497
518
 
498
- def begin_tick(*, monotonic_ns=None, standalone: bool = False) -> TickContext:
519
+ def begin_tick(
520
+ *, monotonic_ns=None, thread_time_ns=None, standalone: bool = False,
521
+ ) -> TickContext:
499
522
  """Open a tick context and make it ``current()`` on this thread.
500
523
 
501
524
  ``standalone`` records a ``tui --render-once`` or
@@ -505,6 +528,7 @@ def begin_tick(*, monotonic_ns=None, standalone: bool = False) -> TickContext:
505
528
  """
506
529
  ctx = TickContext(
507
530
  monotonic_ns=monotonic_ns or time.monotonic_ns,
531
+ thread_time_ns=thread_time_ns or time.thread_time_ns,
508
532
  standalone=standalone,
509
533
  )
510
534
  _tls.tick = ctx
@@ -1515,7 +1515,12 @@ def _forecast_verdict_of(output) -> str:
1515
1515
  inputs = getattr(output, "inputs", None)
1516
1516
  if inputs is not None and getattr(inputs, "confidence", "high") == "low":
1517
1517
  return _FORECAST_VERDICT_LOW_CONF
1518
- high = float(getattr(output, "final_percent_high", 0.0))
1518
+ high = getattr(output, "final_percent_high", 0.0)
1519
+ if high is None:
1520
+ # #661 S2 spec section 3.2: a right-censored reading publishes no
1521
+ # projection, and the meter is at its cap by construction.
1522
+ return _FORECAST_VERDICT_OVER
1523
+ high = float(high)
1519
1524
  if high >= 100:
1520
1525
  return _FORECAST_VERDICT_OVER
1521
1526
  if high >= 90:
@@ -1538,25 +1543,38 @@ def _forecast_dashboard_verdict_of(output) -> str:
1538
1543
  def _forecast_projection_pcts(output) -> "tuple[float | None, float | None]":
1539
1544
  """Return (week_avg_projection_pct, recent_24h_projection_pct).
1540
1545
 
1541
- Decomposes the dual-method projections from ``r_avg`` / ``r_recent``
1542
- + ``inputs.p_now`` + ``inputs.remaining_hours``. Mirrors the routing
1543
- in ``snapshot_to_envelope``: recent-24h is ``None`` when ``r_recent``
1544
- is ``None`` or its projection equals the week-avg projection (no
1545
- new info — a second method that agrees with the first contributes
1546
- nothing to the user-facing range).
1546
+ The week-average value is READ from ``ForecastOutput`` rather than
1547
+ re-derived (#661 S2 spec section 3.3). It is the one selector's answer,
1548
+ so it is the calibrated model's number where that basis was reached and
1549
+ ``None`` where the kernel withheld it. Re-deriving
1550
+ ``p_now + r_avg * remaining`` here published a second answer to the same
1551
+ question: the committed ``over`` dashboard golden carried
1552
+ ``week_avg_projection_pct: 126.0`` in the envelope while its own nested
1553
+ ``forecast.explain`` block said the projection was withheld because the
1554
+ meter was right-censored.
1555
+
1556
+ The recent-24h value is the SECOND method and stays derived, because the
1557
+ kernel publishes only one selected projection. It is ``None`` when
1558
+ ``r_recent`` is ``None`` — which includes every censored reading — or
1559
+ when its projection equals the week-average one (no new info; a second
1560
+ method that agrees with the first contributes nothing to the range).
1547
1561
  """
1548
1562
  if output is None:
1549
1563
  return None, None
1550
1564
  inputs = getattr(output, "inputs", None)
1551
1565
  if inputs is None:
1552
1566
  return None, None
1553
- p_now = getattr(inputs, "p_now", None)
1567
+ from _lib_forecast import projection_base
1568
+
1569
+ week_avg_pct = getattr(output, "week_avg_projection_pct", None)
1570
+ if getattr(output, "projection_basis", None) == "withheld":
1571
+ # The kernel withheld the projection itself, so there is no second
1572
+ # method to offer either: the recent-24h rate over a censored or
1573
+ # empty meter would publish the very number the withholding removed.
1574
+ return None, None
1575
+ p_now = projection_base(inputs)
1554
1576
  rem = getattr(inputs, "remaining_hours", None)
1555
- r_avg = getattr(output, "r_avg", None)
1556
1577
  r_recent = getattr(output, "r_recent", None)
1557
- week_avg_pct = None
1558
- if p_now is not None and rem is not None and r_avg is not None:
1559
- week_avg_pct = p_now + r_avg * rem
1560
1578
  recent_pct = None
1561
1579
  if p_now is not None and rem is not None and r_recent is not None:
1562
1580
  candidate = p_now + r_recent * rem
package/bin/cctally CHANGED
@@ -568,6 +568,29 @@ _iter_codex_jsonl_entries_with_offsets = _lib_jsonl._iter_codex_jsonl_entries_wi
568
568
  # Provider-neutral quota interpretation kernel (#294 S2). The module is pure
569
569
  # and contains no database, config, filesystem, notifier, parser, or command
570
570
  # dependency; adapters added in later stages own those concerns.
571
+ # Quota model kernel (#661 S1): the pure-fn layer behind `cctally quota`,
572
+ # re-exported here like the other _lib_* kernels so tests and the glue reach
573
+ # one loaded instance.
574
+ _lib_quota_model = _load_sibling("_lib_quota_model")
575
+ CalibrationStatus = _lib_quota_model.CalibrationStatus
576
+ WithholdingCause = _lib_quota_model.WithholdingCause
577
+ Verdict = _lib_quota_model.Verdict
578
+ BlockingReason = _lib_quota_model.BlockingReason
579
+ CalibrationEvidence = _lib_quota_model.CalibrationEvidence
580
+ QuotaAnalysis = _lib_quota_model.QuotaAnalysis
581
+ DailyObservation = _lib_quota_model.DailyObservation
582
+ DetectorResult = _lib_quota_model.DetectorResult
583
+ EntryRecord = _lib_quota_model.EntryRecord
584
+ SnapshotRecord = _lib_quota_model.SnapshotRecord
585
+ CreditRecord = _lib_quota_model.CreditRecord
586
+ Segment = _lib_quota_model.Segment
587
+ Interval = _lib_quota_model.Interval
588
+ Support = _lib_quota_model.Support
589
+ quota_model_analyse = _lib_quota_model.analyse
590
+ QUOTA_MODEL_ALGORITHM_REVISION = _lib_quota_model.QUOTA_MODEL_ALGORITHM_REVISION
591
+ QUOTA_MODEL_CONSTANTS_FINGERPRINT = (
592
+ _lib_quota_model.QUOTA_MODEL_CONSTANTS_FINGERPRINT)
593
+
571
594
  _lib_quota = _load_sibling("_lib_quota")
572
595
  QuotaWindowIdentity = _lib_quota.QuotaWindowIdentity
573
596
  QuotaObservation = _lib_quota.QuotaObservation
@@ -889,6 +912,12 @@ cmd_pricing_check = _cctally_pricing_check.cmd_pricing_check
889
912
  _pricing_observed_models = _cctally_pricing_check._pricing_observed_models
890
913
  _PRICING_SCAN_DEFAULT_WINDOW = _cctally_pricing_check._PRICING_SCAN_DEFAULT_WINDOW
891
914
 
915
+ # Eager re-export of bin/_cctally_quota_model.py — `cmd_quota` is invoked
916
+ # via the parser's `set_defaults(func=c.cmd_quota)`, which resolves through
917
+ # cctally's namespace, so it must live here.
918
+ _cctally_quota_model = _load_sibling("_cctally_quota_model")
919
+ cmd_quota = _cctally_quota_model.cmd_quota
920
+
892
921
  # Eager re-export of bin/_cctally_doctor.py — `cmd_doctor` is the parser's
893
922
  # `set_defaults(func=c.cmd_doctor)` target; `doctor_gather_state` is a
894
923
  # PATCHABLE binding reached by the dashboard shim
@@ -1477,6 +1506,7 @@ _project_detail_for_window = _cctally_dashboard._project_detail_for_window
1477
1506
  _handle_get_project_detail_impl = _cctally_dashboard._handle_get_project_detail_impl
1478
1507
  _select_current_block_for_envelope = _cctally_dashboard._select_current_block_for_envelope
1479
1508
  _build_alerts_envelope_array = _cctally_dashboard._build_alerts_envelope_array
1509
+ _build_meter_rate_change_array = _cctally_dashboard._build_meter_rate_change_array
1480
1510
  snapshot_to_envelope = _cctally_dashboard.snapshot_to_envelope
1481
1511
  _session_detail_to_envelope = _cctally_dashboard._session_detail_to_envelope
1482
1512
  DashboardHTTPHandler = _cctally_dashboard.DashboardHTTPHandler
@@ -1654,6 +1684,8 @@ _assess_forecast_confidence = _cctally_forecast._assess_forecast_confidence
1654
1684
  _pick_p_24h_ago = _cctally_forecast._pick_p_24h_ago
1655
1685
  _load_forecast_inputs = _cctally_forecast._load_forecast_inputs
1656
1686
  _compute_forecast = _cctally_forecast._compute_forecast
1687
+ corrected_percent_point = _cctally_forecast.corrected_percent_point
1688
+ corrected_percent_interval = _cctally_forecast.corrected_percent_interval
1657
1689
  _parse_forecast_targets = _cctally_forecast._parse_forecast_targets
1658
1690
  TOOL_VERSION = _cctally_forecast.TOOL_VERSION
1659
1691
  _build_forecast_json_payload = _cctally_forecast._build_forecast_json_payload