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
@@ -0,0 +1,146 @@
1
+ """The non-mutating read of the persisted quota calibration (#661 S2).
2
+
3
+ The only I/O half of the §1.1 adapters: open the sidecar, hand its bytes to
4
+ the pure validator in `bin/_lib_quota_calibration.py`, and report a typed
5
+ cause when anything about it is unusable.
6
+
7
+ WHY THIS EXISTS RATHER THAN A CALL TO `load_calibrations`. That loader
8
+ QUARANTINES a malformed or version-ahead file by renaming it aside. `doctor`
9
+ is documented read-only, the status line runs per prompt, and the dashboard
10
+ serves a read request — a rename from any of them makes a documented reader a
11
+ writer, and the rename is invisible to the user who triggered it. This module
12
+ therefore never imports or calls `load_calibrations`, `_quarantine`, or
13
+ `save_calibrations`, and takes no lock: `save_calibrations` publishes through
14
+ `os.replace` in the same directory, so a lock-free reader always observes a
15
+ complete old or new inode, and a flock here would let a writer stall a prompt.
16
+
17
+ Spec: docs/superpowers/specs/2026-08-28-661-s2-quota-insight-surfaces.md §1.1
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import dataclasses
22
+ import json
23
+
24
+ import _cctally_quota_model
25
+ import _lib_quota_model as qm
26
+ from _lib_quota_calibration import ( # re-exported for callers and tests
27
+ AppliedQuota, ApplyRejection, RegimeRejection, ValidatedRegime,
28
+ apply_regime, validate_regime,
29
+ )
30
+
31
+ __all__ = [
32
+ "AppliedQuota", "ApplyRejection", "CalibrationRead", "RegimeRejection",
33
+ "ValidatedRegime", "apply_regime", "read_calibration_file",
34
+ "validate_regime",
35
+ ]
36
+
37
+
38
+ @dataclasses.dataclass(frozen=True)
39
+ class CalibrationRead:
40
+ """Exactly one of `regime` and `rejection` is set.
41
+
42
+ `regime_status` carries the stored `status` of the regime the prediction
43
+ gate refused, and is `None` in every other outcome. S1 records WHY it held
44
+ a successor fit below the gate, and a surface that states
45
+ `insufficient-history` while the store says `unstable-fit` names a cause
46
+ the reader cannot reconcile with `cctally quota`.
47
+ """
48
+
49
+ regime: "ValidatedRegime | None"
50
+ rejection: "RegimeRejection | None"
51
+ regime_status: "str | None" = None
52
+
53
+
54
+ def _read_text(path) -> str:
55
+ """Open and read in one step, with NO prior existence check.
56
+
57
+ A concurrent quarantine rename can remove the primary name between a
58
+ check and an open, so a `path.exists()` guard would turn a race into a
59
+ traceback. The absent case arrives here as `OSError` like every other
60
+ failure and is separated by its errno at the call site.
61
+ """
62
+ with open(str(path), "r", encoding="utf-8") as handle:
63
+ return handle.read()
64
+
65
+
66
+ def _state_key(account_key) -> str:
67
+ """The stored bucket name for an account. `None` means the merged view."""
68
+ return (_cctally_quota_model.MERGED_STATE_KEY if account_key is None
69
+ else str(account_key))
70
+
71
+
72
+ def read_calibration_file(*, account_key, path=None,
73
+ expected_fingerprint=None,
74
+ expected_revision=None,
75
+ require_prediction_ready=True) -> CalibrationRead:
76
+ """The open regime for one account, validated, or a typed cause.
77
+
78
+ `expected_fingerprint` and `expected_revision` default to THIS binary's
79
+ constants, so a caller that names neither still refuses a regime fitted
80
+ under other coefficients rather than applying it silently.
81
+
82
+ `require_prediction_ready` defaults to True, so every projection consumer
83
+ is refused a regime S1 marked `detection-only`. A detection consumer —
84
+ section 6's rate-change alert — passes False and reads the same record.
85
+ """
86
+ if path is None:
87
+ path = _cctally_quota_model.calibration_path()
88
+ if expected_fingerprint is None:
89
+ expected_fingerprint = qm.QUOTA_MODEL_CONSTANTS_FINGERPRINT
90
+ if expected_revision is None:
91
+ expected_revision = qm.QUOTA_MODEL_ALGORITHM_REVISION
92
+
93
+ try:
94
+ raw = _read_text(path)
95
+ except FileNotFoundError:
96
+ return CalibrationRead(None, RegimeRejection.ABSENT)
97
+ except (IsADirectoryError, NotADirectoryError):
98
+ return CalibrationRead(None, RegimeRejection.ABSENT)
99
+ except OSError:
100
+ return CalibrationRead(None, RegimeRejection.UNREADABLE)
101
+
102
+ try:
103
+ state = json.loads(raw)
104
+ except ValueError:
105
+ return CalibrationRead(None, RegimeRejection.MALFORMED)
106
+ if not isinstance(state, dict):
107
+ return CalibrationRead(None, RegimeRejection.MALFORMED)
108
+
109
+ version = state.get("schemaVersion")
110
+ if isinstance(version, bool) or not isinstance(version, int) \
111
+ or version > _cctally_quota_model.CALIBRATION_STATE_SCHEMA_VERSION:
112
+ return CalibrationRead(None, RegimeRejection.SCHEMA_VERSION)
113
+
114
+ accounts = state.get("accounts")
115
+ if not isinstance(accounts, dict):
116
+ return CalibrationRead(None, RegimeRejection.MALFORMED)
117
+ bucket = accounts.get(_state_key(account_key))
118
+ regimes = bucket.get("regimes") if isinstance(bucket, dict) else None
119
+ if not isinstance(regimes, list):
120
+ return CalibrationRead(None, RegimeRejection.ABSENT)
121
+
122
+ # The LAST open regime, matching `_cctally_quota_model._open_regime`. A
123
+ # store carrying several open records is malformed history rather than a
124
+ # choice this reader makes, and the newest one is what a writer would
125
+ # have updated in place.
126
+ open_regime = None
127
+ for candidate in reversed(regimes):
128
+ if isinstance(candidate, dict) and candidate.get("effectiveUntil") \
129
+ is None:
130
+ open_regime = candidate
131
+ break
132
+ if open_regime is None:
133
+ return CalibrationRead(None, RegimeRejection.ABSENT)
134
+
135
+ outcome = validate_regime(
136
+ open_regime, account_key=account_key,
137
+ expected_fingerprint=expected_fingerprint,
138
+ expected_revision=expected_revision,
139
+ require_prediction_ready=require_prediction_ready)
140
+ if isinstance(outcome, RegimeRejection):
141
+ status = None
142
+ if outcome is RegimeRejection.DETECTION_ONLY:
143
+ stored = open_regime.get("status")
144
+ status = str(stored) if stored is not None else None
145
+ return CalibrationRead(None, outcome, status)
146
+ return CalibrationRead(outcome, None)