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
@@ -28,6 +28,7 @@ Spec: docs/superpowers/specs/2026-05-31-extract-forecast-budget-cmd-design.md
28
28
  from __future__ import annotations
29
29
 
30
30
  import argparse
31
+ import dataclasses
31
32
  import datetime as dt
32
33
  import json
33
34
  import math
@@ -85,7 +86,9 @@ _ensure_sibling_loaded("_lib_forecast")
85
86
  from _lib_forecast import (
86
87
  ForecastInputs, BudgetRow, ForecastOutput, _compute_forecast,
87
88
  ForecastConfidenceAssessment, ForecastConfidenceCause,
88
- assess_forecast_confidence,
89
+ assess_forecast_confidence, corrected_percent_interval,
90
+ corrected_percent_point, select_projection_basis, ProjectionBasis,
91
+ SelectedProjection, dollars_per_percent_source_label,
89
92
  )
90
93
 
91
94
  # #279 S6 W4: the canonical None-safe UTC-Z serializer. forecast's former local
@@ -366,6 +369,397 @@ def _build_budget_status_inputs(
366
369
  )
367
370
 
368
371
 
372
+ #: Why a week's realized meter movement could not be measured. A closed set,
373
+ #: local to this consumer: these causes never reach the wire, because a week
374
+ #: whose movement is withheld is simply absent from the trailing median.
375
+ #:
376
+ #: The two `*-baseline-absent` members name the RECORD that created the
377
+ #: segment. Reporting a reset-created segment as `credit-baseline-absent`
378
+ #: named the wrong boundary kind — the withholding itself was right either
379
+ #: way, but the cause pointed a reader at a table with nothing in it.
380
+ #: `boundary-records-unreadable` is the fail-closed member: a present
381
+ #: boundary table that could not be read withholds the week rather than
382
+ #: summing it as if the credit had zeroed the meter.
383
+ MOVEMENT_WITHHELD_CAUSES = ("credit-baseline-absent", "reset-baseline-absent",
384
+ "boundary-records-unreadable", "right-censored",
385
+ "no-readings")
386
+
387
+ #: The `source` `_insert_credit_snapshot` stamps on the synthetic post-credit
388
+ #: baseline it writes. That writer is the sole producer of the string, and it
389
+ #: is what separates a new baseline from an ordinary reading.
390
+ _CREDIT_SNAPSHOT_SOURCE = "record-credit"
391
+
392
+ #: A displayed reading at or above this denotes `[99, inf)` and has no point
393
+ #: estimate, so the week is right-censored (spec §3.2, §4.1).
394
+ _CENSORED_READING = 100.0
395
+
396
+
397
+ @dataclass(frozen=True)
398
+ class WeekMovement:
399
+ """The meter movement one prior week realized, or why it is unknowable.
400
+
401
+ `points` and `withheld_cause` are mutually exclusive: a withheld week
402
+ carries no number, and is absent from the trailing median rather than
403
+ contributing a zero.
404
+ """
405
+
406
+ points: "float | None"
407
+ withheld_cause: "str | None"
408
+ segments: int = 0
409
+
410
+
411
+ def _credit_snapshot_effective(payload_json):
412
+ """The credit instant a synthetic post-credit snapshot names, or None.
413
+
414
+ `_insert_credit_snapshot` writes `{"kind", "from", "to", "effective"}`
415
+ with the effective moment normalized to a `+00:00` spelling. A payload
416
+ this cannot read yields None, which the caller treats as unbound rather
417
+ than as a mismatch.
418
+ """
419
+ if not payload_json:
420
+ return None
421
+ try:
422
+ payload = json.loads(payload_json)
423
+ except (TypeError, ValueError):
424
+ return None
425
+ if not isinstance(payload, dict):
426
+ return None
427
+ try:
428
+ return parse_iso_datetime(
429
+ str(payload.get("effective")), "credit.effective")
430
+ except (TypeError, ValueError):
431
+ return None
432
+
433
+
434
+ class BoundaryRecordsUnreadable(Exception):
435
+ """A recorded boundary table exists but could not be read.
436
+
437
+ The fail-closed signal for `_realized_week_movement`. Losing either
438
+ boundary leg does not degrade the answer, it changes it: a credited week
439
+ whose credit rows are missing collapses to one segment and is summed as
440
+ if the credit had zeroed the meter, which spec §4.1 calls unknowable.
441
+ """
442
+
443
+
444
+ def _boundary_table_present(conn, table: str) -> bool:
445
+ """Whether `table` exists, RAISING when the catalogue itself is unreadable.
446
+
447
+ The one permitted relaxation: a store with no `weekly_credit_floors`
448
+ table has recorded no credits, and one with no `week_reset_events` table
449
+ has recorded no resets, so an absent table really does contribute no
450
+ boundaries. Everything else — a locked store, a corrupt file, a renamed
451
+ column — is a read that failed and must withhold the week.
452
+ """
453
+ try:
454
+ return bool(conn.execute(f"PRAGMA table_info({table})").fetchall())
455
+ except sqlite3.Error as exc:
456
+ raise BoundaryRecordsUnreadable(table) from exc
457
+
458
+
459
+ def _week_segment_boundaries(conn, week_start_at, week_end_at,
460
+ week_start_date, *, account_key=None):
461
+ """The recorded reset and credit instants strictly inside the week.
462
+
463
+ Returns `[(instant, kind)]` sorted by instant, where `kind` is `"reset"`
464
+ or `"credit"` — the caller names the boundary kind in its withholding
465
+ cause, so a reset-created segment is not reported as a missing credit
466
+ baseline.
467
+
468
+ Spec §4.1: `week_reset_events` and `weekly_credit_floors` are the
469
+ authoritative boundary records, and a decrease is never used to infer
470
+ one. Both legs are scoped the way `_reset_aware_floor` scopes them, to
471
+ the same account and the same week, so this reducer and #290's floor
472
+ agree on which record belongs to which week. The reset leg's upper
473
+ comparison is strict here where the floor's is inclusive, because a
474
+ record landing exactly on `week_end_at` opens the NEXT week rather than
475
+ segmenting this one. `unixepoch()` rather than a textual comparison,
476
+ because the two legs carry mixed offset spellings.
477
+
478
+ Both legs fail CLOSED, by raising `BoundaryRecordsUnreadable`, on any
479
+ store error other than the table not existing — and the credit leg also
480
+ fails closed on a week key it cannot query with. Ten lines away
481
+ `_snapshot_columns` already fails closed for the same class of defect,
482
+ and the whole fail-closed argument for this reducer depends on seeing
483
+ the credit rows. An ABSENT table is the one permitted relaxation: a
484
+ store with no `weekly_credit_floors` records no credits at all, so there
485
+ is no row to miss.
486
+ """
487
+ acct = "" if account_key is None else " AND account_key = ?"
488
+ acct_p: tuple = () if account_key is None else (account_key,)
489
+ stamps: list = []
490
+ if _boundary_table_present(conn, "week_reset_events"):
491
+ try:
492
+ stamps += [
493
+ (row[0], "reset") for row in conn.execute(
494
+ "SELECT effective_reset_at_utc FROM week_reset_events"
495
+ " WHERE unixepoch(effective_reset_at_utc) >= unixepoch(?)"
496
+ " AND unixepoch(effective_reset_at_utc) < unixepoch(?)"
497
+ + acct,
498
+ (week_start_at.isoformat(), week_end_at.isoformat())
499
+ + acct_p,
500
+ ).fetchall()]
501
+ except sqlite3.Error as exc:
502
+ raise BoundaryRecordsUnreadable("week_reset_events") from exc
503
+ if _boundary_table_present(conn, "weekly_credit_floors"):
504
+ if not week_start_date:
505
+ # The THIRD route to the same wrong arithmetic (#661 S2 Stage C
506
+ # review). This table is keyed by `week_start_date`, so a falsy
507
+ # key cannot select the week's credit rows: the leg used to be
508
+ # SKIPPED here, silently, and a credited week then summed as one
509
+ # unsegmented run. That is the arithmetic the two `raise`
510
+ # statements around it exist to prevent, so this leg fails closed
511
+ # too. `weekly_usage_snapshots.week_start_date` is `TEXT NOT
512
+ # NULL`, so only an empty string reaches this in production and
513
+ # the branch costs nothing; it makes the fail-closed claim true
514
+ # rather than nearly true.
515
+ raise BoundaryRecordsUnreadable("weekly_credit_floors")
516
+ try:
517
+ stamps += [
518
+ (row[0], "credit") for row in conn.execute(
519
+ "SELECT effective_at_utc FROM weekly_credit_floors"
520
+ " WHERE week_start_date = ?" + acct,
521
+ (week_start_date,) + acct_p,
522
+ ).fetchall()]
523
+ except sqlite3.Error as exc:
524
+ raise BoundaryRecordsUnreadable("weekly_credit_floors") from exc
525
+ found: dict = {}
526
+ for stamp, kind in stamps:
527
+ try:
528
+ at = parse_iso_datetime(str(stamp), "movement.boundary")
529
+ except (TypeError, ValueError):
530
+ continue
531
+ if week_start_at < at < week_end_at:
532
+ # A credit record wins a shared instant: it is the one that
533
+ # writes the synthetic baseline the segment is measured from.
534
+ if kind == "credit" or at not in found:
535
+ found[at] = kind
536
+ return [(at, found[at]) for at in sorted(found)]
537
+
538
+
539
+ def _realized_week_movement(conn, week_start_at, week_end_at, week_start_date,
540
+ readings, *,
541
+ account_key: "str | None" = None) -> WeekMovement:
542
+ """How many meter points one completed week actually consumed.
543
+
544
+ `readings` is the week's snapshots as `(captured_at, weekly_percent,
545
+ source, payload_json)`; the caller supplies them because it has already
546
+ selected them, and `conn` is used only for the two boundary tables.
547
+
548
+ The reducer SEGMENTS at each recorded reset and credit boundary and sums
549
+ positive deltas within each segment. The first segment measures from
550
+ zero, because a week starts at an empty meter and its first reading is
551
+ itself consumption. Every later segment measures from the synthetic
552
+ post-credit snapshot `_apply_credit` writes, which is a new baseline and
553
+ not fresh consumption.
554
+
555
+ When a segment holding readings has no such baseline the WHOLE WEEK is
556
+ withheld. It is not rescued by counting the first post-credit observation
557
+ as consumption: on the #290 fixture's readings of 46 then 31, a naive
558
+ positive-adjacent sum yields 46 because the drop contributes zero, and
559
+ counting 31 yields 77 by assuming the credit zeroed the meter. Neither is
560
+ knowable. A segment holding no readings needs no baseline, because a
561
+ reset ends a week and the readings after it carry the next window's
562
+ bounds.
563
+
564
+ A week whose meter reached a displayed 100% is right-censored and is
565
+ withheld for the same reason §3.2 refuses a point estimate there.
566
+ """
567
+ parsed: list = []
568
+ for row in readings:
569
+ captured, percent = row[0], row[1]
570
+ source = row[2] if len(row) > 2 else None
571
+ payload = row[3] if len(row) > 3 else None
572
+ if percent is None:
573
+ continue
574
+ try:
575
+ at = parse_iso_datetime(str(captured), "movement.captured_at")
576
+ except (TypeError, ValueError):
577
+ continue
578
+ parsed.append((at, float(percent), str(source or ""), payload))
579
+ if not parsed:
580
+ return WeekMovement(None, "no-readings")
581
+ parsed.sort(key=lambda row: row[0])
582
+ if any(row[1] >= _CENSORED_READING for row in parsed):
583
+ return WeekMovement(None, "right-censored")
584
+
585
+ try:
586
+ boundaries = _week_segment_boundaries(
587
+ conn, week_start_at, week_end_at, week_start_date,
588
+ account_key=account_key)
589
+ except BoundaryRecordsUnreadable:
590
+ # Fail CLOSED. Without the recorded boundaries `edges` collapses to
591
+ # the week start and a credited week is summed as one segment, which
592
+ # is the "assume the credit zeroed the meter" arithmetic §4.1 calls
593
+ # unknowable — and on the #290 shape it yields 46 as the denominator
594
+ # instead of a withholding.
595
+ return WeekMovement(None, "boundary-records-unreadable")
596
+ edges = [(week_start_at, "week-start")] + boundaries
597
+ total = 0.0
598
+ segments = 0
599
+ for position, (low, kind) in enumerate(edges):
600
+ high = edges[position + 1][0] if position + 1 < len(edges) else None
601
+ chunk = [row for row in parsed
602
+ if (position == 0 or low <= row[0])
603
+ and (high is None or row[0] < high)]
604
+ if not chunk:
605
+ continue
606
+ segments += 1
607
+ if position == 0:
608
+ previous = 0.0
609
+ rest = chunk
610
+ else:
611
+ first = chunk[0]
612
+ stated = _credit_snapshot_effective(first[3])
613
+ if first[2] != _CREDIT_SNAPSHOT_SOURCE or (
614
+ stated is not None and stated != low):
615
+ # Name the record that created the segment. A reset-created
616
+ # segment reported as `credit-baseline-absent` names the
617
+ # wrong boundary kind; the withholding itself is right
618
+ # either way.
619
+ return WeekMovement(None, f"{kind}-baseline-absent")
620
+ previous = first[1]
621
+ rest = chunk[1:]
622
+ for row in rest:
623
+ if row[1] > previous:
624
+ total += row[1] - previous
625
+ previous = row[1]
626
+ return WeekMovement(total, None, segments)
627
+
628
+
629
+ def _snapshot_columns(conn) -> set:
630
+ """The column names `weekly_usage_snapshots` actually carries.
631
+
632
+ Every production store has `source` and `payload_json`, and several
633
+ hand-built fixtures do not. Selecting them unconditionally would turn a
634
+ thin fixture into an `OperationalError`; selecting NULL instead makes the
635
+ reducer unable to identify a synthetic post-credit baseline, which
636
+ withholds a credited week rather than mispricing it.
637
+ """
638
+ try:
639
+ rows = conn.execute(
640
+ "PRAGMA table_info(weekly_usage_snapshots)").fetchall()
641
+ except sqlite3.Error:
642
+ return set()
643
+ return {str(row[1]) for row in rows}
644
+
645
+
646
+ def _dpp_candidate_regime(current_week_start, *, account_key):
647
+ """The S1 regime the TARGET week sits in, or None when there is none.
648
+
649
+ None means "no regime restriction is knowable", which is the inert state
650
+ and the common one. It arises when the validated reader refuses — no
651
+ calibration, a fingerprint or revision mismatch, or S1's `detection-only`
652
+ prediction gate — and equally when the target week falls outside the OPEN
653
+ regime the reader publishes. The reader returns only that one regime, so
654
+ a target week inside a closed predecessor cannot be matched against
655
+ anything, and restricting it would exclude every candidate rather than
656
+ the incomparable ones.
657
+
658
+ `account_key=None` reads the MERGED `*` bucket, which spec §5.3 declares
659
+ invalid as a source of published quota on a decorated install, and this
660
+ path deliberately carries no decoration gate. Three reasons, recorded
661
+ here so the question is not re-opened from the §5.3 text alone.
662
+
663
+ First, §5.3 forbids PUBLISHING a modelled figure attributed to an
664
+ account from a merged calibration. Nothing of the kind happens here: the
665
+ regime is used only to decide which prior weeks are comparable, and its
666
+ `units_per_point` never enters the returned rate, which is
667
+ `week_cost / realized_points` end to end. A test pins that behaviourally
668
+ — two regimes differing only in `unitsPerPoint` publish the same rate.
669
+
670
+ Second, the regime's scope and the population's scope always agree,
671
+ because both come from this same `account_key`: a merged call reads the
672
+ merged bucket against merged candidate weeks and a merged entry
673
+ population, and `forecast --account X` reads X's bucket against X's.
674
+
675
+ Third, the residual case is bounded. On a decorated install `cctally
676
+ quota` resolves real account keys and never writes the `*` bucket, so
677
+ the reader refuses and the restriction is inert. The one reachable state
678
+ is a `*` bucket fitted while the install had a single account and read
679
+ after it became decorated, and there the regime can only DROP candidates
680
+ — pushing the branch toward the fallback it already has — never publish
681
+ a wrong number.
682
+ """
683
+ c = _cctally()
684
+ try:
685
+ qcg = c._load_sibling("_cctally_quota_calibration")
686
+ read = qcg.read_calibration_file(account_key=account_key)
687
+ except Exception: # noqa: BLE001
688
+ return None
689
+ regime = getattr(read, "regime", None)
690
+ if regime is None:
691
+ return None
692
+ if current_week_start < regime.effective_from:
693
+ return None
694
+ if regime.effective_until is not None \
695
+ and current_week_start >= regime.effective_until:
696
+ return None
697
+ return regime
698
+
699
+
700
+ def _dpp_candidate_in_regime(regime, week_start_at, week_end_at) -> bool:
701
+ """Whether one candidate week sits inside `regime`'s own interval.
702
+
703
+ Split out of `_dpp_candidate_comparability` (#661 S2 Stage C review)
704
+ because the two halves of that verdict have different costs and
705
+ different failure modes. This half is a pure date comparison that
706
+ reaches no store, so the caller keeps applying it after a population
707
+ read has failed; the other half opens `cache.db`, so the caller stops
708
+ applying it once one read has failed. Folding the two together let one
709
+ unreadable probe admit every later week, including weeks on the far side
710
+ of a metering-rate boundary, which is precisely what spec section 4.2's
711
+ sparse fallback exists to avoid.
712
+ """
713
+ if week_start_at < regime.effective_from:
714
+ return False
715
+ if regime.effective_until is not None \
716
+ and week_end_at > regime.effective_until:
717
+ return False
718
+ return True
719
+
720
+
721
+ def _dpp_candidate_comparability(regime, week_start_at, week_end_at, *,
722
+ account_key, cache_conn_factory=None):
723
+ """One candidate's verdict, over four distinct outcomes.
724
+
725
+ - `"comparable"` — in regime, and its own population passes the support
726
+ test.
727
+ - `"incomparable"` — in regime, population read, support test failed.
728
+ This is drift, and only this.
729
+ - `"excluded"` — on the far side of a rate boundary.
730
+ - `"unreadable"` — the population could not be read at all. Folding this
731
+ into `"excluded"` made a locked `cache.db` look like twelve weeks that
732
+ had each crossed a rate boundary, which silently collapsed the branch
733
+ to `this_week_sparse`; the caller now acts on the distinction.
734
+
735
+ Spec §4.2. Same-regime is necessary but not sufficient, because an S1
736
+ regime describes weighted units per meter point while dollars per point
737
+ also varies with model mix, pricing, cache mix and speed tier. The
738
+ additional condition is §1.1's apply adapter run over the candidate
739
+ week's own population: a week whose composition sits outside the regime's
740
+ recorded radii is exactly the week `cctally quota` would refuse to model,
741
+ so no second notion of comparability is invented here.
742
+
743
+ `cache_conn_factory`, when given, returns a `cache.db` connection the
744
+ caller reuses across candidates, so one selector call opens that database
745
+ once rather than once per probe.
746
+ """
747
+ if not _dpp_candidate_in_regime(regime, week_start_at, week_end_at):
748
+ return "excluded"
749
+ c = _cctally()
750
+ try:
751
+ qcg = c._load_sibling("_cctally_quota_calibration")
752
+ entries = _week_entry_records(
753
+ week_start_at, week_end_at, account_key=account_key,
754
+ cache_conn_factory=cache_conn_factory)
755
+ except Exception: # noqa: BLE001
756
+ return "unreadable"
757
+ applied = qcg.apply_regime(regime, entries)
758
+ if isinstance(applied, qcg.ApplyRejection):
759
+ return "incomparable"
760
+ return "comparable"
761
+
762
+
369
763
  def _select_dollars_per_percent(
370
764
  conn: sqlite3.Connection,
371
765
  now_utc: dt.datetime,
@@ -376,9 +770,21 @@ def _select_dollars_per_percent(
376
770
  skip_sync: bool = False,
377
771
  use_weekref_cost_cache: bool = False,
378
772
  account_key: "str | None" = None,
773
+ p_now_corrected: "float | None" = None,
379
774
  ) -> "tuple[float | None, str]":
380
775
  """Return (dollars_per_percent, source_label). See spec §1 selection rule.
381
776
 
777
+ #661 S2 spec section 3.1: ``p_now_corrected`` is the ceiling-corrected
778
+ estimate of what was actually consumed and is the DIVISOR for the two
779
+ current-week paths. ``p_now`` stays the displayed reading and remains the
780
+ GATE, deliberately: the ``>= 10`` threshold is about having a stable
781
+ sample, and dividing 10 into 9.5 would flip a displayed 10 into the
782
+ trailing-median branch; the ``> 0`` threshold is what withholds the rate
783
+ on a week with no observed usage, and the corrected point for a displayed
784
+ 0 is 0.25, so correcting the gate would publish a rate there. Omitting
785
+ the argument keeps the displayed reading as the divisor, which is the
786
+ right-censored case: there is no corrected point to divide by.
787
+
382
788
  #620 S1 D5: the rate is ``None`` when no usage has been observed. It used
383
789
  to be ``0.0`` paired with the ``this_week_sparse`` label, which published
384
790
  a rate of exactly $0.00 per percent and blamed a sparse week — a
@@ -386,7 +792,11 @@ def _select_dollars_per_percent(
386
792
  spend. Absence is now typed, and the cause travels on the existing
387
793
  companion source field rather than a new key.
388
794
 
389
- Eligible prior week: week_end_at < now_utc AND final_weekly_percent >= 1.
795
+ Eligible prior week (#661 S2 spec §4): it closed before `now_utc`, its
796
+ REALIZED meter movement is known and at least 1 point, and — when a
797
+ trustworthy S1 regime covers the target week — it sits in that same
798
+ regime and its own population still passes the regime's composition
799
+ support test. The denominator is that movement, not `_floored_week_max`.
390
800
  Uses the existing `_sum_cost_for_range` helper (which opens the cache DB
391
801
  via `get_entries`); `conn` is only used for snapshot queries.
392
802
 
@@ -396,14 +806,21 @@ def _select_dollars_per_percent(
396
806
  the trailing-4wk-median prior-week snapshot + cost reads to that account.
397
807
  """
398
808
  c = _cctally()
809
+ divisor = p_now if p_now_corrected is None else p_now_corrected
399
810
  # Path 1: current week, stable sample.
400
811
  if p_now >= 10.0 and p_now > 0:
401
- return spent_usd / p_now, "this_week"
402
-
403
- # Path 2: trailing 4-week median, reset-aware floored (#290).
812
+ return spent_usd / divisor, "this_week"
813
+
814
+ # Path 2: trailing 4-week median over REALIZED meter movement (#661 S2
815
+ # spec §4). The denominator used to be `_floored_week_max(week)` — #290's
816
+ # reset-aware, display-oriented high-water mark, which on a credited week
817
+ # is the POST-credit reading and therefore a fragment of the week's
818
+ # cost-bearing consumption. `_floored_week_max` is unchanged and still
819
+ # serves its current-high-water and MAX-clamp consumers; the defect was
820
+ # in this consumer.
404
821
  import statistics
405
822
  # Bounded candidate selection: the <=12 most-recent prior weeks. The median
406
- # needs 4 eligible; 12 leaves margin so flooring a credited week below 1%
823
+ # needs 4 eligible; 12 leaves margin so a withheld or incomparable week
407
824
  # can't starve it, while keeping this hot path (live forecast view +
408
825
  # dashboard refresh) from materializing all history.
409
826
  _acct_pred = "" if account_key is None else " AND account_key = ?"
@@ -417,18 +834,29 @@ def _select_dollars_per_percent(
417
834
  (current_week_start.isoformat(),) + _acct_p,
418
835
  ).fetchall()
419
836
  we_by_ws: dict[dt.datetime, dt.datetime] = {}
420
- rows_in: list = []
837
+ wsd_by_ws: dict[dt.datetime, str] = {}
838
+ readings_by_ws: dict = {}
421
839
  ws_iso_list = [row[0] for row in cand]
422
840
  if ws_iso_list:
423
841
  placeholders = ",".join("?" * len(ws_iso_list))
842
+ # `source` and `payload_json` are what separate a synthetic
843
+ # post-credit baseline from an ordinary reading. A store without them
844
+ # cannot supply that distinction, so they are selected as NULL there
845
+ # and the reducer withholds any credited week — the fail-closed
846
+ # direction.
847
+ present = _snapshot_columns(conn)
848
+ source_col = ("source" if "source" in present else "NULL")
849
+ payload_col = ("payload_json" if "payload_json" in present
850
+ else "NULL")
424
851
  snap = conn.execute(
425
852
  "SELECT week_start_date, week_start_at, week_end_at, "
426
- " captured_at_utc, weekly_percent "
853
+ " captured_at_utc, weekly_percent, "
854
+ f" {source_col}, {payload_col} "
427
855
  "FROM weekly_usage_snapshots "
428
856
  "WHERE week_start_at IN (" + placeholders + ")" + _acct_pred,
429
857
  ws_iso_list + list(_acct_p),
430
858
  ).fetchall()
431
- for wsd, ws_iso, we_iso, cap_iso, pct in snap:
859
+ for wsd, ws_iso, we_iso, cap_iso, pct, source, payload in snap:
432
860
  if pct is None:
433
861
  continue
434
862
  try:
@@ -437,22 +865,131 @@ def _select_dollars_per_percent(
437
865
  except ValueError:
438
866
  continue
439
867
  we_by_ws.setdefault(ws, we) # first-wins; all rows share one instant
440
- rows_in.append((ws, wsd, ws_iso, we_iso, cap_iso, pct))
441
- floored = c._floored_week_max(
442
- conn, rows_in, account_key=account_key) # {ws_instant -> floored max}
443
- eligible: list[tuple[dt.datetime, dt.datetime, float]] = [
444
- (ws, we_by_ws[ws], floored[ws])
445
- for ws in floored
446
- if ws in we_by_ws
447
- and ws < current_week_start
448
- and we_by_ws[ws] < now_utc
449
- and floored[ws] >= 1.0
450
- ]
451
- eligible.sort(key=lambda x: x[0], reverse=True)
868
+ if wsd is not None:
869
+ wsd_by_ws.setdefault(ws, wsd)
870
+ readings_by_ws.setdefault(ws, []).append(
871
+ (cap_iso, pct, source, payload))
872
+ # Spec §4.2. Both clauses are gated on a trustworthy regime existing: the
873
+ # validated reader publishes only the OPEN regime and refuses one S1
874
+ # marked `detection-only`, so on an install with no calibration there is
875
+ # no regime information and the restriction is inert. Applying it anyway
876
+ # would exclude every candidate rather than the incomparable ones.
877
+ regime = _dpp_candidate_regime(current_week_start, account_key=account_key)
878
+ eligible: list[tuple[dt.datetime, dt.datetime, float]] = []
879
+ drifted = False
880
+ unverified = False
881
+ # Cost bound (#661 S2 Stage C review). Each comparability probe opens
882
+ # `cache.db`, scans one subscription week of `session_entries` and turns
883
+ # the rows into kernel records, so an unbounded per-candidate probe
884
+ # added up to twelve opens and twelve scans to the live forecast view
885
+ # and to every dashboard refresh, arriving the day a user's calibration
886
+ # first became prediction-ready. Two bounds, in the spirit of the
887
+ # `use_weekref_cost_cache` read beside it:
888
+ # * the loop stops at the four candidates the median consumes, so the
889
+ # usual cost is four scans rather than twelve; and
890
+ # * one `cache.db` connection serves every probe in the call, so the
891
+ # open cost is paid once rather than per candidate.
892
+ # MEASURED, over the twelve most recent completed subscription weeks of
893
+ # the maintainer's August 2026 store snapshot (476,216 `session_entries`
894
+ # rows), on an Apple M4 Max Mac Studio under CPython 3.14 with the file
895
+ # cache warm: a median 16,923 rows and 28 ms per probe, worst 27,664
896
+ # rows and 46 ms; the SQL scan alone is a median 13 ms and the rest is
897
+ # building the records. So the usual bounded cost is about 110 ms, and
898
+ # the WORST CASE — a trustworthy prediction-ready regime with eight or
899
+ # more candidates rejected before four survive — is one open plus twelve
900
+ # probes: about 340 ms at the MEDIAN per-probe cost, and up to about
901
+ # 550 ms if every one of the twelve is as heavy as the heaviest week
902
+ # measured. Both figures are stated because an earlier revision gave only
903
+ # the first while calling it the worst case, which mixes the statistics:
904
+ # a reader taking "worst case" literally computes 12 x 46 ms from the
905
+ # figures two lines above and gets a different number. Those durations
906
+ # are this machine's; another host will differ, and the two LAN runners
907
+ # alone differ by about 1.4x. The
908
+ # STRUCTURAL bound, at most twelve week-scans per call, is hardware
909
+ # independent and is what the tests pin. With no trustworthy regime —
910
+ # every install that has never fit one — no probe runs at all and the
911
+ # cost is zero.
912
+ #
913
+ # NO COMMITTED PROBE-COST HARNESS, deliberately, unlike the realized-error
914
+ # measurement at `tests/quota_budget_error_harness.py`. That one exists
915
+ # because spec §4's acceptance criterion is a RE-RUN of it against a
916
+ # committed artifact, so the number has to be reproducible to be checked.
917
+ # This one is a duration on one machine over one private store, and a
918
+ # committed harness would either need that store — which validates a
919
+ # figure but must never supply one — or a synthetic corpus whose timings
920
+ # say nothing about the real one. The bound a reader can act on is the
921
+ # structural one, and it is pinned by test rather than by this comment.
922
+ shared_cache: dict = {"conn": None, "opened": False}
923
+
924
+ def _shared_cache_conn():
925
+ # Opened on FIRST probe and never before: a selector call that never
926
+ # reaches a probe — no regime, or every candidate rejected on
927
+ # movement — must not touch `cache.db` at all.
928
+ if not shared_cache["opened"]:
929
+ shared_cache["opened"] = True
930
+ try:
931
+ shared_cache["conn"] = c._load_sibling(
932
+ "_cctally_cache").open_cache_db()
933
+ except Exception: # noqa: BLE001
934
+ shared_cache["conn"] = None
935
+ return shared_cache["conn"]
936
+
937
+ try:
938
+ for ws in sorted(readings_by_ws, reverse=True):
939
+ if len(eligible) >= 4:
940
+ # Only the first four are ever used (`eligible[:4]` below),
941
+ # so a fifth probe cannot change the published rate. It also
942
+ # cannot change `drifted`, which qualifies the SELECTED
943
+ # population: a candidate examined after the fourth survivor
944
+ # was never a candidate for selection.
945
+ break
946
+ we = we_by_ws.get(ws)
947
+ if we is None or ws >= current_week_start or we >= now_utc:
948
+ continue
949
+ movement = _realized_week_movement(
950
+ conn, ws, we, wsd_by_ws.get(ws), readings_by_ws[ws],
951
+ account_key=account_key)
952
+ if movement.points is None or movement.points < 1.0:
953
+ continue
954
+ if regime is not None:
955
+ # The BOUNDARY half is applied to EVERY candidate, including
956
+ # the ones examined after a read has failed (#661 S2 Stage C
957
+ # review). It is a date comparison that reaches no store, so
958
+ # keeping it costs nothing, and dropping it admitted weeks
959
+ # from the far side of a metering-rate boundary into the
960
+ # median — the failure section 4.2 names outright. Only the
961
+ # POPULATION half below goes inert on an unreadable store.
962
+ if not _dpp_candidate_in_regime(regime, ws, we):
963
+ continue
964
+ if regime is not None and not unverified:
965
+ verdict = _dpp_candidate_comparability(
966
+ regime, ws, we, account_key=account_key,
967
+ cache_conn_factory=_shared_cache_conn)
968
+ if verdict == "incomparable":
969
+ drifted = True
970
+ continue
971
+ if verdict == "unreadable":
972
+ # The same store serves every candidate, so a read that
973
+ # failed here fails for all of them. Dropping them would
974
+ # exclude every candidate rather than the incomparable
975
+ # ones — the exact failure `_dpp_candidate_regime`'s
976
+ # docstring refuses — so the POPULATION test becomes
977
+ # inert for this call and the rate says it was not
978
+ # verified. The boundary test above is unaffected.
979
+ unverified = True
980
+ elif verdict != "comparable":
981
+ continue
982
+ eligible.append((ws, we, movement.points))
983
+ finally:
984
+ if shared_cache["conn"] is not None:
985
+ try:
986
+ shared_cache["conn"].close()
987
+ except Exception: # noqa: BLE001
988
+ pass
452
989
  prior = eligible[:4]
453
990
  if len(prior) >= 4:
454
991
  values: list[float] = []
455
- for ws, we, final_pct in prior:
992
+ for ws, we, realized_points in prior:
456
993
  if use_weekref_cost_cache:
457
994
  # #269 §4: every `prior` week satisfies `we < now_utc` (an
458
995
  # eligibility filter above), so all four are CLOSED and
@@ -475,12 +1012,30 @@ def _select_dollars_per_percent(
475
1012
  ws, we, mode="auto", skip_sync=skip_sync,
476
1013
  account_key=account_key,
477
1014
  )
478
- values.append(week_cost / final_pct)
479
- return statistics.median(values), "trailing_4wk_median"
1015
+ # `realized_points` is the week's REALIZED meter movement, not a
1016
+ # final percentage: since §4 the denominator is what the meter
1017
+ # actually moved through, so a credited week no longer divides by
1018
+ # its post-credit fragment.
1019
+ values.append(week_cost / realized_points)
1020
+ # Spec §4.2's third clause: the selected rate carries reduced
1021
+ # confidence when the historical population has drifted. The
1022
+ # qualification travels on the existing companion source field rather
1023
+ # than a new key, which is the same decision #620 S1 D5 made for the
1024
+ # withheld-rate cause. `unverified` is the third register: the
1025
+ # comparability test could not run at all, which is neither drift nor
1026
+ # a clean verification, and it outranks `drifted` because a drift
1027
+ # verdict from a partly-unreadable population is not one.
1028
+ if unverified:
1029
+ label = "trailing_4wk_median_unverified"
1030
+ elif drifted:
1031
+ label = "trailing_4wk_median_drifted"
1032
+ else:
1033
+ label = "trailing_4wk_median"
1034
+ return statistics.median(values), label
480
1035
 
481
1036
  # Path 3: fall back to current week even if sparse.
482
1037
  if p_now > 0:
483
- return spent_usd / p_now, "this_week_sparse"
1038
+ return spent_usd / divisor, "this_week_sparse"
484
1039
  # p_now == 0: there is no signal to divide by. Withhold the rate and say
485
1040
  # why (#620 S1 D5). Every dollar figure derived from it becomes
486
1041
  # unavailable; percent projections are unaffected because they never
@@ -525,6 +1080,191 @@ def _pick_p_24h_ago(
525
1080
  return pick[1], t_actual
526
1081
 
527
1082
 
1083
+ #: Which member of the quota kernel's closed `EVIDENCE_CODES` union states
1084
+ #: each calibration-read rejection. `stale` is the union's word for "fitted
1085
+ #: under other constants", which is what a fingerprint or revision mismatch
1086
+ #: means; every other rejection is reported as `unavailable`, because the
1087
+ #: reader deliberately cannot distinguish a quarantined file from an absent
1088
+ #: one without scanning sidecars, and it does not scan them.
1089
+ _CALIBRATION_REJECTION_CODES: dict = {
1090
+ "calibration-fingerprint-mismatch": "stale",
1091
+ "calibration-revision-mismatch": "stale",
1092
+ }
1093
+
1094
+ #: The cause a `detection-only` refusal states when the stored regime's own
1095
+ #: status is not itself a member of the closed union. S1 only marks a
1096
+ #: successor `detection-only` while its status is NOT `ok`, so the stored
1097
+ #: status is normally the precise cause and this is the floor under it.
1098
+ _DETECTION_ONLY_FALLBACK_CODE = "insufficient-history"
1099
+
1100
+
1101
+ @dataclasses.dataclass(frozen=True)
1102
+ class CalibratedWeek:
1103
+ """Everything one calibrated week-scan produces, or the cause it did not.
1104
+
1105
+ A single dataclass rather than a widening tuple because the dashboard
1106
+ needs the CONSUMPTION and the headroom (spec §10) as well as the
1107
+ projection, and reading them from a second call would run a second
1108
+ unbounded week scan per refresh for numbers the first scan already
1109
+ computed.
1110
+ """
1111
+ projection_pct: "float | None" = None
1112
+ consumption_pct: "float | None" = None
1113
+ consumption_lo: "float | None" = None
1114
+ consumption_hi: "float | None" = None
1115
+ headroom_pct: "float | None" = None
1116
+ code: "str | None" = None
1117
+
1118
+
1119
+ def _calibrated_projection(
1120
+ now_utc: dt.datetime,
1121
+ week_start_at: dt.datetime,
1122
+ week_end_at: dt.datetime,
1123
+ *,
1124
+ account_key: "str | None",
1125
+ ) -> "tuple[float | None, str | None]":
1126
+ """`(projected_end_of_week_percent, withheld_code)` from the S1 model.
1127
+
1128
+ The two-value form every caller outside the dashboard envelope wants.
1129
+ `_calibrated_week_detail` is the one implementation.
1130
+ """
1131
+ detail = _calibrated_week_detail(
1132
+ now_utc, week_start_at, week_end_at, account_key=account_key)
1133
+ return detail.projection_pct, detail.code
1134
+
1135
+
1136
+ def _calibrated_week_detail(
1137
+ now_utc: dt.datetime,
1138
+ week_start_at: dt.datetime,
1139
+ week_end_at: dt.datetime,
1140
+ *,
1141
+ account_key: "str | None",
1142
+ ) -> CalibratedWeek:
1143
+ """The S1 model's view of THIS week, or the typed cause it has none.
1144
+
1145
+ Reads the persisted calibration through the section 1.1 NON-MUTATING
1146
+ reader — never `load_calibrations`, which quarantines by renaming — and
1147
+ only opens the entry cache when a regime actually validates, so the
1148
+ common no-calibration install pays one small file read and nothing else.
1149
+
1150
+ The support test runs against THIS week's own population rather than
1151
+ trusting the regime's stored status, and the pace projection mirrors the
1152
+ kernel's `_project`: consumption scaled by span over elapsed.
1153
+ """
1154
+ c = _cctally()
1155
+ try:
1156
+ qcg = c._load_sibling("_cctally_quota_calibration")
1157
+ except Exception: # noqa: BLE001
1158
+ # A `CalibratedWeek`, never the two-value tuple this function returned
1159
+ # before it grew a dataclass. `_calibrated_projection` reads
1160
+ # `detail.projection_pct` off the result, so a tuple here raises
1161
+ # `AttributeError` instead of withholding — the same defect class as
1162
+ # `persist_and_detect`'s `None, None`.
1163
+ return CalibratedWeek(code="unavailable")
1164
+ read = qcg.read_calibration_file(account_key=account_key)
1165
+ if read.regime is None:
1166
+ rejection = getattr(read.rejection, "value", None)
1167
+ if rejection == "calibration-detection-only":
1168
+ # S1 held this successor fit below its prediction gate, and the
1169
+ # regime records which blocking status it carried. State that
1170
+ # rather than a generic word: the user reconciles it against
1171
+ # `cctally quota`, which prints the same status.
1172
+ qm = c._load_sibling("_lib_quota_model")
1173
+ stored = read.regime_status
1174
+ return CalibratedWeek(code=(
1175
+ stored if stored in qm.EVIDENCE_CODES
1176
+ else _DETECTION_ONLY_FALLBACK_CODE))
1177
+ return CalibratedWeek(code=_CALIBRATION_REJECTION_CODES.get(
1178
+ rejection, "unavailable"))
1179
+
1180
+ horizon = min(now_utc, week_end_at)
1181
+ try:
1182
+ entries = _week_entry_records(
1183
+ week_start_at, horizon, account_key=account_key)
1184
+ except sqlite3.Error:
1185
+ return CalibratedWeek(code="unavailable")
1186
+ applied = qcg.apply_regime(read.regime, entries)
1187
+ if isinstance(applied, qcg.ApplyRejection):
1188
+ return CalibratedWeek(code=applied.value)
1189
+
1190
+ elapsed = (horizon - week_start_at).total_seconds()
1191
+ span = (week_end_at - week_start_at).total_seconds()
1192
+ if elapsed <= 0 or span <= 0:
1193
+ return CalibratedWeek(code="unavailable")
1194
+ projected = applied.consumed_points * (span / elapsed)
1195
+ if not math.isfinite(projected):
1196
+ # A non-finite product is not a projection. Returning it would let
1197
+ # the selector fall back to the meter with no cause on the wire —
1198
+ # `calibration_code` would read null beside a `corrected-meter`
1199
+ # basis, which is the silent fallback this function's own contract
1200
+ # forbids. The refusal is stated here, at the producer.
1201
+ return CalibratedWeek(code="unavailable")
1202
+ consumed = applied.consumed_points
1203
+ return CalibratedWeek(
1204
+ projection_pct=projected,
1205
+ consumption_pct=consumed,
1206
+ consumption_lo=getattr(applied, "consumed_lo", None),
1207
+ consumption_hi=getattr(applied, "consumed_hi", None),
1208
+ # Headroom is measured against the meter's 100-point ceiling and is
1209
+ # clamped at zero: a week already past its quota has no headroom
1210
+ # left, and a negative one would read as an overdraft the meter does
1211
+ # not express.
1212
+ headroom_pct=max(0.0, 100.0 - consumed),
1213
+ )
1214
+
1215
+
1216
+ def _week_entry_records(start, end, *, account_key,
1217
+ cache_conn_factory=None):
1218
+ """One subscription week's priced requests as quota-kernel `EntryRecord`s.
1219
+
1220
+ Bounded by the subscription week, and read straight out of `cache.db`
1221
+ rather than through `analyse_account`, which runs two unbounded reads
1222
+ plus a detector.
1223
+
1224
+ Named for a week rather than for THE current week because §4.2's
1225
+ comparability probe calls it for prior weeks too.
1226
+
1227
+ `cache_conn_factory`, when given, returns a shared `cache.db` connection
1228
+ this call reads from and does NOT close; the caller owns its lifetime.
1229
+ The comparability probe passes one so a selector call opens that database
1230
+ once instead of once per candidate. It is a FACTORY rather than a live
1231
+ connection so a selector call that never reaches a probe never opens the
1232
+ database at all.
1233
+ """
1234
+ c = _cctally()
1235
+ qm = c._load_sibling("_lib_quota_model")
1236
+ glue = c._load_sibling("_cctally_quota_model")
1237
+ shared = None if cache_conn_factory is None else cache_conn_factory()
1238
+ owned = shared is None
1239
+ cache = (c._load_sibling("_cctally_cache").open_cache_db()
1240
+ if owned else shared)
1241
+ try:
1242
+ sql = (
1243
+ "SELECT timestamp_utc, model, input_tokens, output_tokens,"
1244
+ " cache_create_tokens, cache_create_1h_tokens, cache_read_tokens"
1245
+ " FROM session_entries"
1246
+ " WHERE timestamp_utc >= ? AND timestamp_utc < ?"
1247
+ )
1248
+ params: list = [start.isoformat(), end.isoformat()]
1249
+ clause, extra = glue._account_clause("account_key", account_key)
1250
+ sql += clause
1251
+ params.extend(extra)
1252
+ rows = cache.execute(sql, params).fetchall()
1253
+ finally:
1254
+ if owned:
1255
+ cache.close()
1256
+ records = []
1257
+ for row in rows:
1258
+ at = glue.parse_instant(row[0], "timestamp_utc")
1259
+ if at is None or not (start <= at < end):
1260
+ continue
1261
+ records.append(qm.EntryRecord(
1262
+ at=at, model=str(row[1] or ""), fresh=row[2] or 0,
1263
+ output=row[3] or 0, cache_create_total=row[4] or 0,
1264
+ cache_1h=row[5], cache_read=row[6] or 0))
1265
+ return records
1266
+
1267
+
528
1268
  def _load_forecast_inputs(
529
1269
  conn: sqlite3.Connection,
530
1270
  now_utc: dt.datetime,
@@ -578,6 +1318,20 @@ def _load_forecast_inputs(
578
1318
  )
579
1319
  p_24h_ago, t_24h = _pick_p_24h_ago(samples, now_utc)
580
1320
 
1321
+ # #661 S2 spec section 3.1. The ceiling correction is applied HERE,
1322
+ # before `ForecastInputs` is constructed, for two independent reasons:
1323
+ # `_compute_forecast` does not select the dollars-per-percent
1324
+ # denominator, so a kernel-only correction would leave the call below
1325
+ # raw; and the kernel computes the recent rate as a DIFFERENCE of the two
1326
+ # endpoints, so correcting only the current one would corrupt that
1327
+ # difference rather than fix it. Both endpoints are corrected together.
1328
+ p_now_corrected = corrected_percent_point(p_now)
1329
+ p_now_interval = corrected_percent_interval(p_now)
1330
+ p_24h_ago_corrected = corrected_percent_point(p_24h_ago)
1331
+ right_censored = p_now_corrected is None
1332
+ calibrated = _calibrated_week_detail(
1333
+ now_utc, week_start_at, week_end_at, account_key=account_key)
1334
+
581
1335
  # Cache is warm for this invocation after the spent_usd lookup. Suppress
582
1336
  # re-syncs in downstream cost lookups (trailing-4wk-median loop hits
583
1337
  # _sum_cost_for_range once per historical week).
@@ -585,6 +1339,7 @@ def _load_forecast_inputs(
585
1339
  conn, now_utc, week_start_at, p_now, spent_usd, skip_sync=True,
586
1340
  use_weekref_cost_cache=use_weekref_cost_cache,
587
1341
  account_key=account_key,
1342
+ p_now_corrected=p_now_corrected,
588
1343
  )
589
1344
  target_24h = now_utc - dt.timedelta(hours=24)
590
1345
  has_sample_ge_24h = any(s[0] <= target_24h for s in samples)
@@ -612,6 +1367,17 @@ def _load_forecast_inputs(
612
1367
  dollars_per_percent_source=dpp_source,
613
1368
  confidence=confidence,
614
1369
  low_confidence_reasons=reasons,
1370
+ p_now_corrected=p_now_corrected,
1371
+ p_now_interval=p_now_interval,
1372
+ p_24h_ago_corrected=p_24h_ago_corrected,
1373
+ right_censored=right_censored,
1374
+ calibrated_projection_pct=calibrated.projection_pct,
1375
+ calibrated_withheld_code=calibrated.code,
1376
+ calibrated_consumption_pct=calibrated.consumption_pct,
1377
+ calibrated_consumption_interval=(
1378
+ None if calibrated.consumption_pct is None
1379
+ else (calibrated.consumption_lo, calibrated.consumption_hi)),
1380
+ calibrated_headroom_pct=calibrated.headroom_pct,
615
1381
  )
616
1382
 
617
1383
 
@@ -639,6 +1405,17 @@ def _parse_forecast_targets(raw: str) -> list[int]:
639
1405
 
640
1406
  TOOL_VERSION = "forecast-v1" # Bumped on material JSON-schema changes.
641
1407
 
1408
+ #: `forecast --json`'s envelope version (#661 S2 spec section 3.5).
1409
+ #:
1410
+ #: Bumped from 1 because `final_percent_low`, `final_percent_high` and
1411
+ #: `week_avg_projection_pct` changed from always-number to NULLABLE, and
1412
+ #: because their meaning changed from raw-derived to corrected-derived.
1413
+ #: `docs/cli-contract.md` classifies a changed value type and a changed value
1414
+ #: meaning as breaking, and this change is both. The new corrected, interval,
1415
+ #: censoring and basis keys are additive and would not on their own have
1416
+ #: required a bump.
1417
+ FORECAST_JSON_SCHEMA_VERSION = 2
1418
+
642
1419
 
643
1420
  def _build_forecast_json_payload(out: ForecastOutput) -> dict:
644
1421
  """Dict shape for the forecast JSON endpoint and for the dashboard
@@ -657,6 +1434,18 @@ def _build_forecast_json_payload(out: ForecastOutput) -> dict:
657
1434
  },
658
1435
  "current": {
659
1436
  "weekly_percent": round(i.p_now, 3),
1437
+ # #661 S2 spec section 3.1. `weekly_percent` stays the RAW
1438
+ # displayed reading; the two keys below state the ceiling-
1439
+ # corrected operand every rate and projection above is computed
1440
+ # from, so a consumer can re-derive them rather than re-deriving
1441
+ # from a number the kernel did not use. Both null when the
1442
+ # reading is right-censored.
1443
+ "weekly_percent_corrected": (
1444
+ None if i.p_now_corrected is None
1445
+ else round(i.p_now_corrected, 3)),
1446
+ "weekly_percent_interval": (
1447
+ None if i.p_now_interval is None
1448
+ else [i.p_now_interval[0], i.p_now_interval[1]]),
660
1449
  "five_hour_percent": (None if i.five_hour_percent is None
661
1450
  else round(i.five_hour_percent, 3)),
662
1451
  "spent_usd": round(i.spent_usd, 6),
@@ -664,7 +1453,11 @@ def _build_forecast_json_payload(out: ForecastOutput) -> dict:
664
1453
  "latest_snapshot_at": _iso_z(i.latest_snapshot_at),
665
1454
  },
666
1455
  "rates": {
667
- "week_average_pct_per_hour": round(out.r_avg, 6),
1456
+ # #661 S2 spec section 3.2: a right-censored reading supplies no
1457
+ # rate either, so this nulls rather than dividing the displayed
1458
+ # value the model declares censored.
1459
+ "week_average_pct_per_hour": (None if out.r_avg is None
1460
+ else round(out.r_avg, 6)),
668
1461
  "recent_24h_pct_per_hour": (None if out.r_recent is None
669
1462
  else round(out.r_recent, 6)),
670
1463
  "dollars_per_percent": (
@@ -674,9 +1467,28 @@ def _build_forecast_json_payload(out: ForecastOutput) -> dict:
674
1467
  "dollars_per_percent_source": i.dollars_per_percent_source,
675
1468
  },
676
1469
  "forecast": {
677
- "final_percent_low": round(out.final_percent_low, 3),
678
- "final_percent_high": round(out.final_percent_high, 3),
679
- "week_avg_projection_pct": round(out.week_avg_projection_pct, 3),
1470
+ # #661 S2 spec section 3.2: a right-censored reading has no point
1471
+ # estimate, so these three null rather than carrying a value the
1472
+ # observation cannot supply. `right_censored` is the additive
1473
+ # companion field that says which state the nulls mean.
1474
+ "final_percent_low": (None if out.final_percent_low is None
1475
+ else round(out.final_percent_low, 3)),
1476
+ "final_percent_high": (None if out.final_percent_high is None
1477
+ else round(out.final_percent_high, 3)),
1478
+ "week_avg_projection_pct": (
1479
+ None if out.week_avg_projection_pct is None
1480
+ else round(out.week_avg_projection_pct, 3)),
1481
+ "right_censored": bool(out.right_censored),
1482
+ # #661 S2 spec section 3.3: one named typed state per surface.
1483
+ "projection_basis": out.projection_basis,
1484
+ "projection_code": out.projection_code,
1485
+ # Why the CALIBRATED basis was not reached, as a member of the
1486
+ # quota kernel's closed `EVIDENCE_CODES` union; null when it was.
1487
+ # `projection_code` is the cause of a WITHHELD projection and
1488
+ # cannot carry this: falling back to the corrected meter is not a
1489
+ # withholding, and a surface that says nothing here is silently
1490
+ # falling back (spec section 1.1).
1491
+ "calibration_code": i.calibrated_withheld_code,
680
1492
  "projected_cap": out.projected_cap,
681
1493
  "cap_at": (None if out.cap_at is None else _iso_z(out.cap_at)),
682
1494
  "already_capped": out.already_capped,
@@ -710,10 +1522,35 @@ def _emit_forecast_json(out: ForecastOutput, *, extra: "dict | None" = None) ->
710
1522
  if extra: # #341 R8 decoration (accountKey/accountLabel; empty unless --account)
711
1523
  payload.update(extra)
712
1524
  return json.dumps(
713
- _cctally().stamp_schema_version(payload),
1525
+ _cctally().stamp_schema_version(
1526
+ payload, version=FORECAST_JSON_SCHEMA_VERSION),
714
1527
  indent=2)
715
1528
 
716
1529
 
1530
+ #: The sentence a withheld projection states in the terminal's wide register
1531
+ #: (spec section 8). Keyed on `ForecastOutput.projection_code`, a member of
1532
+ #: the quota kernel's closed `EVIDENCE_CODES` union.
1533
+ _WITHHELD_PROJECTION_WORDING: dict = {
1534
+ "right-censored": "the meter reads at its cap, so consumption has no "
1535
+ "upper bound",
1536
+ # The uncensored withholding: the corrected point, the elapsed span or
1537
+ # the remaining span is missing, so there is no pace to project along.
1538
+ "unavailable": "the week's window supplies no pace to project along",
1539
+ # RETAINED although `select_projection_basis` no longer emits it. Spec
1540
+ # section 3.6 reversed the zero-end withholding, but `projection_code`
1541
+ # is a wire field a dashboard tab can still be holding from an older
1542
+ # server across an `execvp`, and a code with no wording renders the
1543
+ # generic fallback where a specific sentence exists.
1544
+ "no-local-history": "no usage has been observed this week",
1545
+ }
1546
+
1547
+
1548
+ def _withheld_projection_wording(out: ForecastOutput) -> str:
1549
+ """Why the projection is absent, never a blank."""
1550
+ return _WITHHELD_PROJECTION_WORDING.get(
1551
+ out.projection_code, "the projection is unavailable")
1552
+
1553
+
717
1554
  def _render_forecast_status_line(out: ForecastOutput, color: bool) -> str:
718
1555
  """Compact one-line status-line segment (spec §5)."""
719
1556
  c = _cctally()
@@ -721,12 +1558,25 @@ def _render_forecast_status_line(out: ForecastOutput, color: bool) -> str:
721
1558
  return c._style_ansi(s, code, color)
722
1559
 
723
1560
  i = out.inputs
1561
+ # `already_capped` is the whole predicate here: `_compute_forecast` sets
1562
+ # it only in the right-censored branch. That is NOT the only branch that
1563
+ # nulls `final_percent_high` — the WITHHELD branch does too — so the
1564
+ # `low is None or high is None` arm below is what handles that shape, and
1565
+ # the two arms are disjoint rather than one subsuming the other.
724
1566
  if out.already_capped:
725
1567
  return _c("\u26a0 CAPPED", "31") # red
726
1568
  if i.confidence == "low":
727
1569
  return _c("tracking\u2026", "2") # dim
728
1570
  low = out.final_percent_low
729
1571
  high = out.final_percent_high
1572
+ if low is None or high is None:
1573
+ # A projection the kernel withheld without the meter being capped:
1574
+ # the corrected point, the elapsed span or the remaining span was
1575
+ # missing, which `_load_forecast_inputs` does not produce. This arm
1576
+ # therefore guards the KERNEL's contract rather than the loader's
1577
+ # current output \u2014 `_compute_forecast` may return this shape for any
1578
+ # caller-built inputs, and the arms below would `round(None)`.
1579
+ return _c("tracking\u2026", "2") # dim
730
1580
  low_disp = round(low)
731
1581
  high_disp = round(high)
732
1582
  pct_range = f"{low_disp}\u2013{high_disp}%"
@@ -966,6 +1816,16 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
966
1816
  forecast_extra_lines = [
967
1817
  c._style_ansi(row, "33", color) for row in conf_rows[1:]
968
1818
  ]
1819
+ elif out.final_percent_high is None or out.final_percent_low is None:
1820
+ # A projection the kernel withheld without the meter being capped —
1821
+ # today, a week with no observed usage. Through `_load_forecast_inputs`
1822
+ # that reading also trips `percent<2` and the LOW CONF arm above
1823
+ # catches it, so this arm guards the KERNEL's contract rather than the
1824
+ # loader's current output: the rounding below would `round(None)` for
1825
+ # any caller-built inputs in that state.
1826
+ forecast_line = c._style_ansi(
1827
+ f"Forecast withheld — {_withheld_projection_wording(out)}",
1828
+ "33", color)
969
1829
  else:
970
1830
  low, high = out.final_percent_low, out.final_percent_high
971
1831
  low_rnd = round(low)
@@ -980,10 +1840,15 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
980
1840
  forecast_line = c._style_ansi(
981
1841
  f"Forecast {low_rnd}%\u2013{high_disp}", glyph_color, color) + warn
982
1842
 
1843
+ # A withheld projection collapses the bar's projection band onto the
1844
+ # observed reading, so the bar shows what was measured and draws no
1845
+ # forecast region at all (#661 S2 spec section 3.2).
983
1846
  bar_lines = _render_forecast_progress_bar(
984
1847
  used=i.p_now,
985
- low=out.final_percent_low,
986
- high=out.final_percent_high,
1848
+ low=(i.p_now if out.final_percent_low is None
1849
+ else out.final_percent_low),
1850
+ high=(i.p_now if out.final_percent_high is None
1851
+ else out.final_percent_high),
987
1852
  width=inner_w - 2,
988
1853
  unicode_ok=unicode_ok,
989
1854
  color=color,
@@ -1007,7 +1872,9 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
1007
1872
  # ── Footer
1008
1873
  footer_bits = []
1009
1874
  footer_bits.append(c._style_ansi(
1010
- f"rate source: {i.dollars_per_percent_source.replace('_', ' ')}", "2", color))
1875
+ "rate source: "
1876
+ + dollars_per_percent_source_label(i.dollars_per_percent_source),
1877
+ "2", color))
1011
1878
  if out.cap_at is not None:
1012
1879
  # format_display_dt: zone-label suffix disambiguates --tz vs host-local
1013
1880
  # in the rendered footer (matches the reset-chip subtitle).
@@ -1041,8 +1908,9 @@ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
1041
1908
  # --explain footer
1042
1909
  if getattr(args, "explain", False):
1043
1910
  r_rec = "\u2014" if out.r_recent is None else f"{out.r_recent:.3f}%/h"
1911
+ r_av = "\u2014" if out.r_avg is None else f"{out.r_avg:.3f}%/h"
1044
1912
  lines.append(c._style_ansi(
1045
- f" r_avg={out.r_avg:.3f}%/h \u00b7 r_recent={r_rec} \u00b7 "
1913
+ f" r_avg={r_av} \u00b7 r_recent={r_rec} \u00b7 "
1046
1914
  f"{i.snapshot_count} snapshots \u00b7 $/1% source={i.dollars_per_percent_source}",
1047
1915
  "2", color))
1048
1916
 
@@ -1704,14 +2572,12 @@ def cmd_forecast(args: argparse.Namespace) -> int:
1704
2572
  )
1705
2573
  now_x = (i.now_utc - i.week_start_at).total_seconds() / 3600.0
1706
2574
  end_x = (i.week_end_at - i.week_start_at).total_seconds() / 3600.0
1707
- if output.already_capped:
1708
- # Flat ray: y stays at p_now across the remaining window.
1709
- projected_series.append(
1710
- (now_label, now_x, float(i.p_now))
1711
- )
1712
- projected_series.append(
1713
- (end_label, end_x, float(i.p_now))
1714
- )
2575
+ if output.final_percent_high is None:
2576
+ # #661 S2 spec section 3.2. The ray used to run flat at
2577
+ # `p_now` across the remaining window, which draws a
2578
+ # projection under a "projected" label out of a reading that
2579
+ # supplies no point estimate. There is no ray to draw.
2580
+ pass
1715
2581
  else:
1716
2582
  projected_series.append(
1717
2583
  (now_label, now_x, float(i.p_now))
@@ -1730,8 +2596,15 @@ def cmd_forecast(args: argparse.Namespace) -> int:
1730
2596
  actual_series=actual_series,
1731
2597
  projected_series=projected_series,
1732
2598
  current_pct=float(i.p_now),
1733
- projected_low_pct=float(output.final_percent_low),
1734
- projected_high_pct=float(output.final_percent_high),
2599
+ # #661 S2 spec section 3.2. Substituting `p_now` printed
2600
+ # "Projected end-of-week % 103.0% — 103.0%" — the CURRENT reading
2601
+ # under a "Projected" label, with nothing saying it was withheld.
2602
+ # `None` reaches the table as the same withheld token the ceiling
2603
+ # distances already use.
2604
+ projected_low_pct=(None if output.final_percent_low is None
2605
+ else float(output.final_percent_low)),
2606
+ projected_high_pct=(None if output.final_percent_high is None
2607
+ else float(output.final_percent_high)),
1735
2608
  days_remaining=float(i.remaining_days),
1736
2609
  # #620 S1 D5: no `float(...)` coercion — that would turn a
1737
2610
  # withheld rate back into $0.00 one layer below the fix.