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
@@ -1493,9 +1493,69 @@ def maybe_record_codex_budget_milestone(
1493
1493
  return fired
1494
1494
 
1495
1495
 
1496
- def _weekly_pct_week_avg_projection(conn, now_utc):
1496
+ def _forecast_reaches_the_calibrated_basis(
1497
+ now_utc, week_start_at, week_end_at, *, account_key=None) -> bool:
1498
+ """True when the FORECAST would publish a model-backed projection here.
1499
+
1500
+ Spec section 3.3 conditions the twin's abstention on the alert path being
1501
+ unable to reach "the basis the forecast published" — not on a regime
1502
+ merely validating. Those are different questions, and the gap between
1503
+ them is reachable: `apply_regime` re-tests support against THIS week's
1504
+ population and rejects an unsupported or empty one, so a calibration can
1505
+ validate while the forecast still falls back to the corrected meter. At
1506
+ the start of every week the population is empty, and gating on
1507
+ readability there returned `None` from the twin and silently disabled the
1508
+ weekly 90%/100% projected alert.
1509
+
1510
+ This calls the SAME helper the loader calls, with the SAME account key,
1511
+ so the two cannot answer differently. The earlier probe always read the
1512
+ merged bucket while the loader used its caller's key, which on a
1513
+ decorated multi-account install let the forecast publish a calibrated
1514
+ projection while the twin saw no merged regime and fired on the meter.
1515
+
1516
+ Any failure to reach the helper is reported as "not calibrated", which
1517
+ keeps the twin firing rather than going quiet on an unrelated defect.
1518
+
1519
+ COST. This is NOT a cheap probe once a regime validates. `_calibrated_projection`
1520
+ reads the calibration file first — one small file read on every install
1521
+ that has never run `cctally quota`, and the common case — but when a
1522
+ regime does validate it then opens `cache.db` and runs a full current-week
1523
+ `session_entries` SELECT, on a path `record-usage` and `hook-tick` reach
1524
+ once per prompt. Measured over the twelve most recent completed
1525
+ subscription weeks of the maintainer's August 2026 store snapshot
1526
+ (476,216 `session_entries` rows), on an Apple M4 Max Mac Studio under
1527
+ CPython 3.14 with the file cache warm, a WHOLE week is a median 16,923
1528
+ rows and 28 ms and at worst 27,664 rows and 46 ms. This read covers the
1529
+ current week only as far as now, so it is a fraction of that early in
1530
+ the week and reaches it by the week's end. The durations are this
1531
+ machine's and another host will differ; what does not vary is that the
1532
+ read is one unbounded week-scan rather than a bounded probe. A cheap bound is available in principle — the same
1533
+ query with `COUNT(*)`, or a bounded `LIMIT 1` existence probe, would
1534
+ answer the empty-population half of `apply_regime`'s rejection without
1535
+ materializing the rows — but it would not answer the composition-support
1536
+ half, which needs the whole population. Not implemented here.
1537
+ """
1538
+ try:
1539
+ value, _code = _cctally()._load_sibling(
1540
+ "_cctally_forecast")._calibrated_projection(
1541
+ now_utc, week_start_at, week_end_at, account_key=account_key)
1542
+ return value is not None
1543
+ except Exception:
1544
+ return False
1545
+
1546
+
1547
+ def _weekly_pct_week_avg_projection(conn, now_utc, *, account_key=None):
1497
1548
  """Compute the week-AVERAGE weekly-% projection for the current
1498
- subscription week, snapshot-only (CHEAP — no cost SUM, no ``sync_cache``).
1549
+ subscription week from snapshots alone.
1550
+
1551
+ ``account_key`` is an AFFORDANCE, not a threaded production value. The one
1552
+ shipped caller (the projected-alert leg in ``maybe_record_projected_alert``)
1553
+ passes none, and the window it resolves just above comes from a merged
1554
+ ``_fetch_current_week_snapshots``, so the whole projected-alert path is
1555
+ account-blind today. Making it per-account is a #341 change to that leg
1556
+ rather than to this helper, and this session does not make it. The
1557
+ parameter exists so the abstention gate below can be given the SAME key
1558
+ the forecast loader would use once the leg is threaded.
1499
1559
 
1500
1560
  Returns ``(projected_pct, low_conf)`` or ``None`` when no current-week
1501
1561
  snapshot resolves. The value is computed by the IDENTICAL formula +
@@ -1518,12 +1578,33 @@ def _weekly_pct_week_avg_projection(conn, now_utc):
1518
1578
 
1519
1579
  Deliberately does NOT call ``_sum_cost_for_range`` (the weekly-% projection
1520
1580
  needs no spend; the forecast kernel's ``week_avg_projection_pct`` is also
1521
- spend-free).
1581
+ spend-free), and never calls ``sync_cache``.
1582
+
1583
+ It is NOT, however, snapshot-only any more. The #661 S2 abstention gate
1584
+ below calls ``_forecast_reaches_the_calibrated_basis``, which reads the
1585
+ calibration file and — when a regime validates — opens ``cache.db`` for a
1586
+ full current-week ``session_entries`` SELECT. See that helper's COST note.
1522
1587
  """
1523
- fetched = _fetch_current_week_snapshots(conn, now_utc)
1588
+ fetched = _fetch_current_week_snapshots(conn, now_utc,
1589
+ account_key=account_key)
1524
1590
  if fetched is None:
1525
1591
  return None
1526
1592
  week_start_at, week_end_at, samples = fetched
1593
+ # #661 S2 spec section 3.3, invariant PROJECTED1-b. This helper computes
1594
+ # the week average from snapshots alone, and the calibrated basis needs
1595
+ # the current week's weighted units. So when the FORECAST reaches that
1596
+ # basis it publishes a model-backed projection this value cannot equal,
1597
+ # and the twin ABSTAINS rather than firing on the corrected meter, which
1598
+ # would alert on a number the screen does not show.
1599
+ #
1600
+ # The gate is the basis the forecast actually reached, resolved with the
1601
+ # window this helper just resolved, so a calibration that validates but
1602
+ # does not apply to this week's population leaves the twin firing. It
1603
+ # never calls `analyse_account`: the probe reads the calibration file and,
1604
+ # only when a regime validates, one bounded current-week query.
1605
+ if _forecast_reaches_the_calibrated_basis(
1606
+ now_utc, week_start_at, week_end_at, account_key=account_key):
1607
+ return None
1527
1608
  week_start_at, samples = _apply_midweek_reset_override(
1528
1609
  conn, week_start_at, week_end_at, samples
1529
1610
  )
@@ -1532,8 +1613,35 @@ def _weekly_pct_week_avg_projection(conn, now_utc):
1532
1613
  p_now = samples[-1][1]
1533
1614
  elapsed_hours = (now_utc - week_start_at).total_seconds() / 3600.0
1534
1615
  remaining_hours = max(0.0, (week_end_at - now_utc).total_seconds() / 3600.0)
1535
- r_avg = p_now / elapsed_hours if elapsed_hours > 0 else 0.0
1536
- projected_pct = p_now + r_avg * remaining_hours
1616
+ # #661 S2 spec section 3.1/3.3: the same CEILING-CORRECTED operand
1617
+ # `_compute_forecast` projects from. The reconcile invariant PROJECTED1
1618
+ # binds this value to `forecast --json`'s `week_avg_projection_pct`
1619
+ # within 1e-9, so the two must share the operand as well as the formula.
1620
+ # A right-censored reading has no corrected point and therefore no
1621
+ # projection at all: the detector abstains rather than firing on a
1622
+ # number the observation cannot supply.
1623
+ # Imported HERE rather than at module scope, and NOT for a measured
1624
+ # saving. An earlier revision of this comment claimed the module-scope
1625
+ # form cost `record-usage` and `hook-tick` 18.5 ms per run, about 11 ms of
1626
+ # it `_lib_quota_model` "which neither command previously loaded at all".
1627
+ # That is false: `bin/cctally` loads `_lib_quota_model` at line 574 and
1628
+ # `_cctally_forecast` at line 1667, both unconditionally, and
1629
+ # `_cctally_forecast` honest-imports `_lib_forecast` at its own module
1630
+ # top. By the time either command reaches this helper the module is
1631
+ # already in `sys.modules`, and the incremental import measures 0.000 ms.
1632
+ # 18.5 ms is the COLD import into a bare interpreter, which no shipped
1633
+ # path performs. What the local form does buy is correctness: it pairs
1634
+ # with `_ensure_sibling_loaded`, which the module-scope form omitted, so
1635
+ # it does not depend on `_load_sibling` having happened to insert `bin/`
1636
+ # into `sys.path` at `bin/cctally:156`.
1637
+ _ensure_sibling_loaded("_lib_forecast")
1638
+ from _lib_forecast import corrected_percent_point
1639
+
1640
+ p_corrected = corrected_percent_point(p_now)
1641
+ if p_corrected is None:
1642
+ return None
1643
+ r_avg = p_corrected / elapsed_hours if elapsed_hours > 0 else 0.0
1644
+ projected_pct = p_corrected + r_avg * remaining_hours
1537
1645
 
1538
1646
  # Confidence comes from the predicate in one call, fourth trigger
1539
1647
  # included, so this LOW CONF gate == forecast's and glue no longer
@@ -946,8 +946,8 @@ def _build_forecast_snapshot(
946
946
  actual_series: list[tuple[str, float, float]],
947
947
  projected_series: list[tuple[str, float, float]],
948
948
  current_pct: float,
949
- projected_low_pct: float,
950
- projected_high_pct: float,
949
+ projected_low_pct: "float | None",
950
+ projected_high_pct: "float | None",
951
951
  days_remaining: float,
952
952
  dollars_per_percent: "float | None",
953
953
  dollars_per_percent_source: str,
@@ -975,10 +975,11 @@ def _build_forecast_snapshot(
975
975
  wraps `ForecastInputs`). No helper extraction was needed; we pass
976
976
  the actual scalars in directly.
977
977
  - `ForecastInputs` carries a single `dollars_per_percent` value plus
978
- a `dollars_per_percent_source` enum (`this_week` /
979
- `trailing_4wk_median` / `this_week_sparse`); there is no separate
980
- `dpp_week_avg` and `dpp_24h`. The table renders one $/1% row with
981
- the source as a paren suffix in the metric cell.
978
+ a `dollars_per_percent_source` code — the six members of
979
+ `_lib_forecast.DOLLARS_PER_PERCENT_SOURCES`, which is the one place
980
+ that union is written down. There is no separate `dpp_week_avg` and
981
+ `dpp_24h`. The table renders one $/1% row with the source's human
982
+ copy as a paren suffix in the metric cell.
982
983
  - The plan's single `projected_eow_pct` is split into a low/high
983
984
  range (matching `--render-forecast-terminal`'s "Forecast 80–95%"
984
985
  band). The table shows both ends; the projected_series ray uses
@@ -1031,13 +1032,20 @@ def _build_forecast_snapshot(
1031
1032
  # single value (no recent-24h sample), low == high.
1032
1033
  # 0.05 threshold: below .1f display precision — tighter spreads would
1033
1034
  # render as identical decimals, so collapse to a single value.
1034
- if abs(projected_high_pct - projected_low_pct) < 0.05:
1035
+ # #661 S2 spec section 3.2: a right-censored reading has no point
1036
+ # estimate, so the row states the withholding and its cause rather than
1037
+ # printing the current reading under a "Projected" label.
1038
+ if projected_high_pct is None or projected_low_pct is None:
1039
+ projected_text = "withheld — meter at cap"
1040
+ elif abs(projected_high_pct - projected_low_pct) < 0.05:
1035
1041
  projected_text = f"{projected_high_pct:.1f}%"
1036
1042
  else:
1037
1043
  projected_text = (
1038
1044
  f"{projected_low_pct:.1f}% — {projected_high_pct:.1f}%"
1039
1045
  )
1040
- dpp_source_label = dollars_per_percent_source.replace("_", " ")
1046
+ dpp_source_label = sys.modules["cctally"]._load_sibling(
1047
+ "_lib_forecast").dollars_per_percent_source_label(
1048
+ dollars_per_percent_source)
1041
1049
  # #620 S1 D5: a withheld rate renders as the same `n/a` the report trend
1042
1050
  # table already uses for an absent $/1%. Forcing it through MoneyCell
1043
1051
  # would print $0.00, which is the fabrication this contract removes.
@@ -1857,6 +1857,39 @@ def _build_statusline_injections(warn_once):
1857
1857
  return None
1858
1858
  return ctx_tokens / window * 100.0
1859
1859
 
1860
+ def _quota_regimes() -> tuple:
1861
+ """#661 S2 §9. The stored metering regimes, read without mutating.
1862
+
1863
+ Three properties this read must have, and each is a decision:
1864
+
1865
+ * It takes NO flock and NO throttle. `save_calibrations` writes
1866
+ through `os.replace` in the same directory, so a lock-free reader
1867
+ always sees a complete old or new inode; a flock here would let a
1868
+ writer stall a prompt.
1869
+ * It goes through `read_stored_state_readonly`, not through
1870
+ `load_calibrations`. The loader renames a malformed or
1871
+ version-ahead file aside through `_quarantine`, and a status line
1872
+ that quarantines the user's calibration once per prompt would be a
1873
+ writer on the hottest path in the product.
1874
+ * It catches `OSError` on the open and the read rather than
1875
+ pre-checking existence, which `read_stored_state_readonly` already
1876
+ does — a quarantine rename can remove the primary name between a
1877
+ check and an open, and the reader cannot tell "quarantined" from
1878
+ "absent" without scanning sidecars. It does not scan them; both
1879
+ render the same thing, which is no marker.
1880
+
1881
+ It also NEVER opens `cache.db` and never calls `analyse`. The
1882
+ marker is derived from the regime pair alone.
1883
+ """
1884
+ try:
1885
+ glue = _cctally()._load_sibling("_cctally_quota_model")
1886
+ state = glue.read_stored_state_readonly()
1887
+ if state is None:
1888
+ return ()
1889
+ return tuple(glue.stored_regimes(state, None))
1890
+ except Exception: # noqa: BLE001
1891
+ return ()
1892
+
1860
1893
  return _lib_statusline.StatuslineInjections(
1861
1894
  cctally_session_cost=_cctally_session_cost,
1862
1895
  today_cost=_today_cost,
@@ -1865,4 +1898,5 @@ def _build_statusline_injections(warn_once):
1865
1898
  db_latest_rate_limits=_db_latest_rate_limits,
1866
1899
  context_pct=_context_pct,
1867
1900
  warn_once=warn_once,
1901
+ quota_regimes=_quota_regimes,
1868
1902
  )