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.
- package/CHANGELOG.md +56 -0
- package/bin/_cctally_alerts.py +65 -4
- package/bin/_cctally_config.py +62 -2
- package/bin/_cctally_core.py +62 -1
- package/bin/_cctally_dashboard.py +11 -0
- package/bin/_cctally_dashboard_envelope.py +319 -14
- package/bin/_cctally_dashboard_share.py +56 -25
- package/bin/_cctally_doctor.py +63 -0
- package/bin/_cctally_forecast.py +917 -44
- package/bin/_cctally_journal.py +153 -5
- package/bin/_cctally_parser.py +66 -0
- package/bin/_cctally_project.py +535 -10
- package/bin/_cctally_quota.py +13 -0
- package/bin/_cctally_quota_calibration.py +146 -0
- package/bin/_cctally_quota_model.py +1616 -0
- package/bin/_cctally_record.py +114 -6
- package/bin/_cctally_share.py +16 -8
- package/bin/_cctally_statusline.py +34 -0
- package/bin/_cctally_tui.py +214 -45
- package/bin/_lib_dashboard_settings_contract.py +2 -0
- package/bin/_lib_doctor.py +159 -1
- package/bin/_lib_forecast.py +337 -43
- package/bin/_lib_meter_rate_change.py +294 -0
- package/bin/_lib_quota_calibration.py +311 -0
- package/bin/_lib_quota_copy.py +131 -0
- package/bin/_lib_quota_model.py +2333 -0
- package/bin/_lib_rederive.py +10 -0
- package/bin/_lib_render.py +6 -0
- package/bin/_lib_share_templates.py +37 -5
- package/bin/_lib_statusline.py +226 -2
- package/bin/_lib_view_models.py +30 -12
- package/bin/cctally +32 -0
- package/dashboard/static/assets/index-D19TO7Mg.js +97 -0
- package/dashboard/static/assets/index-klO46NcU.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +7 -1
- package/dashboard/static/assets/index-Di2hljvB.css +0 -1
- package/dashboard/static/assets/index-XYCIWjVG.js +0 -97
|
@@ -0,0 +1,1616 @@
|
|
|
1
|
+
"""I/O glue for `cctally quota` (#661 S1).
|
|
2
|
+
|
|
3
|
+
The arithmetic lives in the pure kernel `bin/_lib_quota_model.py`, which has
|
|
4
|
+
no clock, no file access and no database access. This module supplies
|
|
5
|
+
everything the kernel refuses to do for itself: it parses instants, reads both
|
|
6
|
+
stores coherently, resolves account scope, owns the coefficient-era catalogue,
|
|
7
|
+
persists the fitted calibration, renders text and JSON, and maps the result to
|
|
8
|
+
an exit code.
|
|
9
|
+
|
|
10
|
+
The split follows `_lib_pricing_check.py` / `_cctally_pricing_check.py`.
|
|
11
|
+
|
|
12
|
+
Spec: docs/superpowers/specs/2026-08-28-661-s1-quota-model-and-calibration.md
|
|
13
|
+
(each part is normative over the parts before it; Part V is the current one).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import dataclasses
|
|
19
|
+
import datetime as dt
|
|
20
|
+
import fcntl
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sqlite3
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
import _cctally_core
|
|
27
|
+
import _lib_quota_model as qm
|
|
28
|
+
from _lib_quota_model import ( # re-exported for callers and tests
|
|
29
|
+
BlockingReason, CalibrationEvidence, CalibrationStatus, Verdict,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
UTC = dt.timezone.utc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _cctally():
|
|
36
|
+
"""Resolve the current `cctally` module at call time."""
|
|
37
|
+
return sys.modules["cctally"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def eprint(*args, **kwargs):
|
|
41
|
+
print(*args, file=sys.stderr, **kwargs)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# The coefficient-era catalogue (spec section 6).
|
|
46
|
+
#
|
|
47
|
+
# The kernel's token-class coefficients were fitted on Opus-5-dominated
|
|
48
|
+
# traffic. `docs/quota-model.md` records the changeover and states that the
|
|
49
|
+
# tool "refuses to treat earlier data as calibrated": the Opus 4.8 era meters
|
|
50
|
+
# at a materially different units-per-point, which is a BUDGET difference the
|
|
51
|
+
# shared coefficients cannot absorb. Section 6 therefore says such eras are
|
|
52
|
+
# not fitted and persist as gaps carrying `unvalidated-coefficient-era`, a
|
|
53
|
+
# cause distinct from `unsupported-model-mix`.
|
|
54
|
+
#
|
|
55
|
+
# This catalogue is the glue's, not the kernel's, exactly as section 6 and
|
|
56
|
+
# Part III section 28 require: the kernel has no producer for that status and
|
|
57
|
+
# receives it through `analyse(extra_statuses=...)`.
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
#: First UTC date whose composition the shipped coefficients are validated
|
|
60
|
+
#: for. Every observation before it is excluded from the analysis window.
|
|
61
|
+
SUPPORTED_COMPOSITION_FROM: dt.date = dt.date(2026, 7, 25)
|
|
62
|
+
|
|
63
|
+
#: Provenance, so a later reader can re-test the boundary rather than inherit
|
|
64
|
+
#: it. Each entry states the era, its state and where the state came from.
|
|
65
|
+
COEFFICIENT_ERAS: tuple = (
|
|
66
|
+
{
|
|
67
|
+
"from": None,
|
|
68
|
+
"until": SUPPORTED_COMPOSITION_FROM.isoformat(),
|
|
69
|
+
"state": "unvalidated",
|
|
70
|
+
"provenance": (
|
|
71
|
+
"docs/quota-model.md: the Opus 4.8 era fits at about 1,989,101 "
|
|
72
|
+
"units per point against 2.4M for Opus 5, a budget difference the "
|
|
73
|
+
"shared token-class coefficients do not absorb. The shipped "
|
|
74
|
+
"estimator gives 1,265,184 to 1,479,027 over the same weeks, and "
|
|
75
|
+
"spec section 6 declines to adjudicate between the two."
|
|
76
|
+
),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"from": SUPPORTED_COMPOSITION_FROM.isoformat(),
|
|
80
|
+
"until": None,
|
|
81
|
+
"state": "validated",
|
|
82
|
+
"provenance": (
|
|
83
|
+
"docs/quota-model.md: 2026-07-25 is the Opus 5 changeover, the "
|
|
84
|
+
"epoch the token-class weights were fitted on."
|
|
85
|
+
),
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
#: Snapshot sources that are synthetic by construction and are never read as
|
|
90
|
+
#: observations (spec section 4). A closed committed set: `record-credit`
|
|
91
|
+
#: writes a post-credit reading by construction, and the two repair sources
|
|
92
|
+
#: were observed once each on the production store with no tracked writer.
|
|
93
|
+
SYNTHETIC_SNAPSHOT_SOURCES: tuple = (
|
|
94
|
+
"record-credit", "remediation", "manual-recovery",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
#: Sources this repository knows how to produce. An unrecognized value is
|
|
98
|
+
#: RETAINED and counted in a diagnostic rather than silently dropped, because
|
|
99
|
+
#: dropping it would remove a genuine observation on the strength of a name.
|
|
100
|
+
KNOWN_SNAPSHOT_SOURCES: frozenset = frozenset(
|
|
101
|
+
{"statusline", "tampermonkey", "api", "userscript"}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Instants.
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
def parse_instant(value, label: str = "timestamp"):
|
|
109
|
+
"""Parse a stored ISO-8601 string to an aware UTC datetime.
|
|
110
|
+
|
|
111
|
+
The kernel rejects naive datetimes, and text ordering mis-sorts: `+`
|
|
112
|
+
(0x2B) sorts before `.` (0x2E) against fractional-second stamps, and
|
|
113
|
+
`value[:10]` buckets by LOCAL date. So every stored string is parsed here
|
|
114
|
+
and compared as an instant afterwards.
|
|
115
|
+
|
|
116
|
+
A value carrying no offset is read as UTC, which is what every writer in
|
|
117
|
+
this repository stores; it is not read as host-local.
|
|
118
|
+
"""
|
|
119
|
+
if value is None:
|
|
120
|
+
return None
|
|
121
|
+
text = str(value).strip()
|
|
122
|
+
if not text:
|
|
123
|
+
return None
|
|
124
|
+
if text.endswith("Z") or text.endswith("z"):
|
|
125
|
+
text = text[:-1] + "+00:00"
|
|
126
|
+
parsed = dt.datetime.fromisoformat(text)
|
|
127
|
+
if parsed.tzinfo is None:
|
|
128
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
129
|
+
return parsed.astimezone(UTC)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_date_argument(value, label: str):
|
|
133
|
+
"""Parse a `--since` / `--watch-from` argument to an aware UTC instant.
|
|
134
|
+
|
|
135
|
+
A bare date means that date's first instant in UTC. Raises `ValueError`,
|
|
136
|
+
which the command turns into exit 2 — argument errors only.
|
|
137
|
+
"""
|
|
138
|
+
if value is None:
|
|
139
|
+
return None
|
|
140
|
+
text = str(value).strip()
|
|
141
|
+
if not text:
|
|
142
|
+
raise ValueError(f"{label}: empty value")
|
|
143
|
+
try:
|
|
144
|
+
if len(text) == 10:
|
|
145
|
+
return dt.datetime.combine(
|
|
146
|
+
dt.date.fromisoformat(text), dt.time(0, 0), tzinfo=UTC)
|
|
147
|
+
return parse_instant(text, label)
|
|
148
|
+
except ValueError as exc:
|
|
149
|
+
raise ValueError(f"{label}: {text!r} is not an ISO-8601 date or "
|
|
150
|
+
f"timestamp ({exc})") from exc
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# The two-store read (spec section 16).
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
@dataclasses.dataclass(frozen=True)
|
|
157
|
+
class LoadResult:
|
|
158
|
+
"""One account's parsed population, or a typed reason there is none."""
|
|
159
|
+
|
|
160
|
+
entries: tuple = ()
|
|
161
|
+
snapshots: tuple = ()
|
|
162
|
+
credits: tuple = ()
|
|
163
|
+
newest_entry_at: "dt.datetime | None" = None
|
|
164
|
+
analysis_start: "dt.datetime | None" = None
|
|
165
|
+
diagnostics: dict = dataclasses.field(default_factory=dict)
|
|
166
|
+
#: `None` on a usable read; a `CalibrationStatus` the glue contributes
|
|
167
|
+
#: through `analyse(extra_statuses=...)` otherwise.
|
|
168
|
+
status: "CalibrationStatus | None" = None
|
|
169
|
+
cause: "str | None" = None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _account_clause(column: str, account_key):
|
|
173
|
+
"""SQL fragment + params scoping one column to `account_key`.
|
|
174
|
+
|
|
175
|
+
`None` is the merged view and adds no clause. The reserved
|
|
176
|
+
`unattributed` sentinel matches BOTH the literal stamp and a NULL, which
|
|
177
|
+
is this repository's read rule.
|
|
178
|
+
"""
|
|
179
|
+
if account_key is None:
|
|
180
|
+
return "", []
|
|
181
|
+
import _lib_accounts
|
|
182
|
+
if account_key == _lib_accounts.UNATTRIBUTED:
|
|
183
|
+
return f" AND ({column} IS NULL OR {column} = ?)", [account_key]
|
|
184
|
+
return f" AND {column} = ?", [account_key]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _store_probe(conn, path) -> str:
|
|
188
|
+
"""A cheap fingerprint of one store's current state.
|
|
189
|
+
|
|
190
|
+
`PRAGMA data_version` advances when ANOTHER connection commits, which is
|
|
191
|
+
exactly the event that would make two reads describe two different states.
|
|
192
|
+
The file size is folded in so a change the pragma cannot see on a fresh
|
|
193
|
+
connection is still visible.
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
version = conn.execute("PRAGMA data_version").fetchone()[0]
|
|
197
|
+
except (sqlite3.Error, IndexError, TypeError):
|
|
198
|
+
version = "?"
|
|
199
|
+
try:
|
|
200
|
+
size = path.stat().st_size
|
|
201
|
+
except OSError:
|
|
202
|
+
size = -1
|
|
203
|
+
return f"{version}:{size}"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _probe_bundle(stats_conn, cache_conn) -> tuple:
|
|
207
|
+
"""Both stores' signatures, taken as ONE observation.
|
|
208
|
+
|
|
209
|
+
`bin/_cctally_diagnosis_sources.py` probes each component around its own
|
|
210
|
+
read. That protocol cannot see the hazard section 16 names, because a
|
|
211
|
+
cache ingest landing BETWEEN the stats read and the cache read falls
|
|
212
|
+
before the cache component's own opening probe. The pair is therefore
|
|
213
|
+
taken around the whole read rather than around each half, which is
|
|
214
|
+
strictly stronger and costs one extra pragma per attempt.
|
|
215
|
+
"""
|
|
216
|
+
return (
|
|
217
|
+
_store_probe(stats_conn, _cctally_core.DB_PATH),
|
|
218
|
+
_store_probe(cache_conn, _cctally_core.CACHE_DB_PATH),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _read_stats_component(conn, account_key, start):
|
|
223
|
+
"""Meter readings and authoritative credit instants for one account.
|
|
224
|
+
|
|
225
|
+
Returns `(snapshots, credits, diagnostics)`. Synthetic sources are
|
|
226
|
+
excluded at the query as a closed committed set; an unrecognized source is
|
|
227
|
+
retained and counted.
|
|
228
|
+
"""
|
|
229
|
+
placeholders = ",".join("?" for _ in SYNTHETIC_SNAPSHOT_SOURCES)
|
|
230
|
+
sql = (
|
|
231
|
+
"SELECT id, captured_at_utc, week_start_at, week_start_date,"
|
|
232
|
+
" weekly_percent, source FROM weekly_usage_snapshots"
|
|
233
|
+
f" WHERE source NOT IN ({placeholders})"
|
|
234
|
+
)
|
|
235
|
+
params: list = list(SYNTHETIC_SNAPSHOT_SOURCES)
|
|
236
|
+
if start is not None:
|
|
237
|
+
sql += " AND captured_at_utc >= ?"
|
|
238
|
+
params.append(start.isoformat())
|
|
239
|
+
clause, extra = _account_clause("account_key", account_key)
|
|
240
|
+
sql += clause
|
|
241
|
+
params.extend(extra)
|
|
242
|
+
|
|
243
|
+
snapshots = []
|
|
244
|
+
unrecognised: dict = {}
|
|
245
|
+
legacy_anchors = 0
|
|
246
|
+
for rowid, captured, week_at, week_date, percent, source in conn.execute(
|
|
247
|
+
sql, params):
|
|
248
|
+
at = parse_instant(captured, "captured_at_utc")
|
|
249
|
+
if at is None:
|
|
250
|
+
continue
|
|
251
|
+
anchor = parse_instant(week_at, "week_start_at")
|
|
252
|
+
if anchor is None:
|
|
253
|
+
# A pre-`week_start_at` row. Its date-only boundary is read as
|
|
254
|
+
# that date's first UTC instant rather than dropped: dropping it
|
|
255
|
+
# would remove a genuine observation, and the kernel canonicalizes
|
|
256
|
+
# every anchor to the nearest hour anyway.
|
|
257
|
+
anchor = parse_instant(f"{str(week_date)[:10]}T00:00:00+00:00",
|
|
258
|
+
"week_start_date")
|
|
259
|
+
if anchor is None:
|
|
260
|
+
continue
|
|
261
|
+
legacy_anchors += 1
|
|
262
|
+
if source not in KNOWN_SNAPSHOT_SOURCES:
|
|
263
|
+
unrecognised[source] = unrecognised.get(source, 0) + 1
|
|
264
|
+
snapshots.append(qm.SnapshotRecord(
|
|
265
|
+
at=at, week_start=anchor, percent=float(percent),
|
|
266
|
+
source=str(source), rowid=int(rowid)))
|
|
267
|
+
|
|
268
|
+
credits = []
|
|
269
|
+
for table, column, kind in (
|
|
270
|
+
("week_reset_events", "effective_reset_at_utc", "reset"),
|
|
271
|
+
("weekly_credit_floors", "effective_at_utc", "floor")):
|
|
272
|
+
csql = f"SELECT {column} FROM {table} WHERE 1=1"
|
|
273
|
+
cparams: list = []
|
|
274
|
+
if start is not None:
|
|
275
|
+
csql += f" AND {column} >= ?"
|
|
276
|
+
cparams.append(start.isoformat())
|
|
277
|
+
cclause, cextra = _account_clause("account_key", account_key)
|
|
278
|
+
csql += cclause
|
|
279
|
+
cparams.extend(cextra)
|
|
280
|
+
for (raw,) in conn.execute(csql, cparams):
|
|
281
|
+
at = parse_instant(raw, column)
|
|
282
|
+
if at is not None:
|
|
283
|
+
credits.append(qm.CreditRecord(at=at, kind=kind))
|
|
284
|
+
|
|
285
|
+
diagnostics = {
|
|
286
|
+
"unrecognisedSnapshotSources": unrecognised,
|
|
287
|
+
"legacyDateOnlyWeekAnchors": legacy_anchors,
|
|
288
|
+
}
|
|
289
|
+
return snapshots, credits, diagnostics
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _read_cache_component(conn, account_key, start):
|
|
293
|
+
"""Priced requests for one account, plus the store-wide ingest tail.
|
|
294
|
+
|
|
295
|
+
`newest_entry_at` is deliberately NOT account-scoped. It answers "has the
|
|
296
|
+
ingest finished covering this day", which is a property of the store; an
|
|
297
|
+
account-scoped maximum would report every day of a dormant account as
|
|
298
|
+
`no-local-history`.
|
|
299
|
+
"""
|
|
300
|
+
sql = (
|
|
301
|
+
"SELECT timestamp_utc, model, input_tokens, output_tokens,"
|
|
302
|
+
" cache_create_tokens, cache_create_1h_tokens, cache_read_tokens"
|
|
303
|
+
" FROM session_entries WHERE 1=1"
|
|
304
|
+
)
|
|
305
|
+
params: list = []
|
|
306
|
+
if start is not None:
|
|
307
|
+
sql += " AND timestamp_utc >= ?"
|
|
308
|
+
params.append(start.isoformat())
|
|
309
|
+
clause, extra = _account_clause("account_key", account_key)
|
|
310
|
+
sql += clause
|
|
311
|
+
params.extend(extra)
|
|
312
|
+
sql += " ORDER BY timestamp_utc, id"
|
|
313
|
+
|
|
314
|
+
entries = []
|
|
315
|
+
for row in conn.execute(sql, params):
|
|
316
|
+
at = parse_instant(row[0], "timestamp_utc")
|
|
317
|
+
if at is None:
|
|
318
|
+
continue
|
|
319
|
+
entries.append(qm.EntryRecord(
|
|
320
|
+
at=at, model=str(row[1] or ""), fresh=row[2] or 0,
|
|
321
|
+
output=row[3] or 0, cache_create_total=row[4] or 0,
|
|
322
|
+
cache_1h=row[5], cache_read=row[6] or 0))
|
|
323
|
+
newest = parse_instant(
|
|
324
|
+
conn.execute("SELECT MAX(timestamp_utc) FROM session_entries")
|
|
325
|
+
.fetchone()[0], "timestamp_utc")
|
|
326
|
+
return entries, newest
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _retained_snapshot_span(conn, account_key):
|
|
330
|
+
"""`(earliest, latest)` captured instants ignoring the era floor.
|
|
331
|
+
|
|
332
|
+
The `unvalidated-coefficient-era` finding is a statement about retained
|
|
333
|
+
rows, so it needs the span the floor hides. An empty store returns
|
|
334
|
+
`(None, None)` and says nothing.
|
|
335
|
+
"""
|
|
336
|
+
placeholders = ",".join("?" for _ in SYNTHETIC_SNAPSHOT_SOURCES)
|
|
337
|
+
sql = ("SELECT MIN(captured_at_utc), MAX(captured_at_utc)"
|
|
338
|
+
" FROM weekly_usage_snapshots"
|
|
339
|
+
f" WHERE source NOT IN ({placeholders})")
|
|
340
|
+
params: list = list(SYNTHETIC_SNAPSHOT_SOURCES)
|
|
341
|
+
clause, extra = _account_clause("account_key", account_key)
|
|
342
|
+
sql += clause
|
|
343
|
+
params.extend(extra)
|
|
344
|
+
row = conn.execute(sql, params).fetchone()
|
|
345
|
+
if row is None:
|
|
346
|
+
return None, None
|
|
347
|
+
return parse_instant(row[0]), parse_instant(row[1])
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def era_floor_instant() -> "dt.datetime":
|
|
351
|
+
"""The first instant the shipped coefficients are validated for."""
|
|
352
|
+
return dt.datetime.combine(
|
|
353
|
+
SUPPORTED_COMPOSITION_FROM, dt.time(0, 0), tzinfo=UTC)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def resolve_analysis_start(since):
|
|
357
|
+
"""`max(--since, the era floor)`.
|
|
358
|
+
|
|
359
|
+
A `--since` earlier than the floor does not widen the window: section 6
|
|
360
|
+
says an era whose composition the coefficients are not validated for is
|
|
361
|
+
not fitted, and a flag cannot licence fitting it.
|
|
362
|
+
"""
|
|
363
|
+
floor = era_floor_instant()
|
|
364
|
+
if since is None:
|
|
365
|
+
return floor
|
|
366
|
+
return max(since, floor)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def load_population(account_key, *, since, now) -> LoadResult:
|
|
370
|
+
"""Read both stores coherently and return one account's population.
|
|
371
|
+
|
|
372
|
+
The stores are opened through the guarded helpers, so `CCTALLY_DATA_DIR`,
|
|
373
|
+
the current schemas, migrations, corruption handling and the busy timeout
|
|
374
|
+
all apply. Every failure is caught and reported as a typed `unavailable`
|
|
375
|
+
result; an uncaught `OperationalError` from a store read is never allowed
|
|
376
|
+
to reach the user.
|
|
377
|
+
"""
|
|
378
|
+
qm.require_aware(now, "now")
|
|
379
|
+
start = resolve_analysis_start(since)
|
|
380
|
+
stats_conn = None
|
|
381
|
+
cache_conn = None
|
|
382
|
+
try:
|
|
383
|
+
try:
|
|
384
|
+
stats_conn = _cctally_core.open_db()
|
|
385
|
+
cache_conn = _cctally().open_cache_db()
|
|
386
|
+
except Exception:
|
|
387
|
+
return LoadResult(analysis_start=start,
|
|
388
|
+
status=CalibrationStatus.UNAVAILABLE,
|
|
389
|
+
cause="store-unavailable")
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
earliest, _latest = _retained_snapshot_span(
|
|
393
|
+
stats_conn, account_key)
|
|
394
|
+
except sqlite3.Error:
|
|
395
|
+
return LoadResult(analysis_start=start,
|
|
396
|
+
status=CalibrationStatus.UNAVAILABLE,
|
|
397
|
+
cause="store-unavailable")
|
|
398
|
+
|
|
399
|
+
payload = None
|
|
400
|
+
for attempt in (0, 1):
|
|
401
|
+
before = _probe_bundle(stats_conn, cache_conn)
|
|
402
|
+
try:
|
|
403
|
+
snapshots, credits, diagnostics = _read_stats_component(
|
|
404
|
+
stats_conn, account_key, start)
|
|
405
|
+
entries, newest = _read_cache_component(
|
|
406
|
+
cache_conn, account_key, start)
|
|
407
|
+
except sqlite3.Error:
|
|
408
|
+
return LoadResult(analysis_start=start,
|
|
409
|
+
status=CalibrationStatus.UNAVAILABLE,
|
|
410
|
+
cause="store-unavailable")
|
|
411
|
+
after = _probe_bundle(stats_conn, cache_conn)
|
|
412
|
+
if before == after:
|
|
413
|
+
payload = (snapshots, credits, diagnostics, entries, newest)
|
|
414
|
+
break
|
|
415
|
+
if payload is None:
|
|
416
|
+
# A store that moves twice while we read it is under active
|
|
417
|
+
# write, and publishing a fit over it would describe a state that
|
|
418
|
+
# never existed as a whole — in the direction that manufactures a
|
|
419
|
+
# rate change.
|
|
420
|
+
return LoadResult(analysis_start=start,
|
|
421
|
+
status=CalibrationStatus.UNAVAILABLE,
|
|
422
|
+
cause="generation-incoherent")
|
|
423
|
+
finally:
|
|
424
|
+
for conn in (stats_conn, cache_conn):
|
|
425
|
+
if conn is not None:
|
|
426
|
+
try:
|
|
427
|
+
conn.close()
|
|
428
|
+
except sqlite3.Error:
|
|
429
|
+
pass
|
|
430
|
+
|
|
431
|
+
snapshots, credits, diagnostics, entries, newest = payload
|
|
432
|
+
diagnostics = dict(diagnostics)
|
|
433
|
+
diagnostics["analysisStart"] = start.isoformat()
|
|
434
|
+
if not snapshots and earliest is not None and earliest < start:
|
|
435
|
+
# Retained history exists, and all of it predates the era the shipped
|
|
436
|
+
# coefficients are validated for. That is not thin evidence: it is
|
|
437
|
+
# evidence the model has no validated coefficients for, and reporting
|
|
438
|
+
# it as `insufficient-history` would tell the user to wait for days
|
|
439
|
+
# that would never help.
|
|
440
|
+
return LoadResult(
|
|
441
|
+
analysis_start=start, newest_entry_at=newest,
|
|
442
|
+
diagnostics=diagnostics,
|
|
443
|
+
status=CalibrationStatus.UNVALIDATED_COEFFICIENT_ERA,
|
|
444
|
+
cause="history-predates-supported-composition")
|
|
445
|
+
return LoadResult(
|
|
446
|
+
entries=tuple(entries), snapshots=tuple(snapshots),
|
|
447
|
+
credits=tuple(credits), newest_entry_at=newest,
|
|
448
|
+
analysis_start=start, diagnostics=diagnostics)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ---------------------------------------------------------------------------
|
|
452
|
+
# Account scoping (spec section 17).
|
|
453
|
+
# ---------------------------------------------------------------------------
|
|
454
|
+
def accounts_are_decorated() -> bool:
|
|
455
|
+
"""The #341 R8 gate: does the Claude provider render account decoration?
|
|
456
|
+
|
|
457
|
+
True only above one REAL account. A lone `unattributed` bucket, or a
|
|
458
|
+
single real account, decorates nothing and keeps today's output.
|
|
459
|
+
"""
|
|
460
|
+
import _cctally_account
|
|
461
|
+
try:
|
|
462
|
+
conn = _cctally_core.open_db()
|
|
463
|
+
except Exception:
|
|
464
|
+
return False
|
|
465
|
+
try:
|
|
466
|
+
return _cctally_account.provider_is_decorated(conn, "claude")
|
|
467
|
+
except sqlite3.Error:
|
|
468
|
+
return False
|
|
469
|
+
finally:
|
|
470
|
+
conn.close()
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _real_account_keys() -> list:
|
|
474
|
+
import _cctally_account
|
|
475
|
+
import _lib_accounts
|
|
476
|
+
try:
|
|
477
|
+
conn = _cctally_core.open_db()
|
|
478
|
+
except Exception:
|
|
479
|
+
return []
|
|
480
|
+
try:
|
|
481
|
+
rows = _cctally_account.load_accounts(conn, "claude")
|
|
482
|
+
except sqlite3.Error:
|
|
483
|
+
return []
|
|
484
|
+
finally:
|
|
485
|
+
conn.close()
|
|
486
|
+
return [str(row["account_key"]) for row in rows
|
|
487
|
+
if row["account_key"] not in (_lib_accounts.UNATTRIBUTED,
|
|
488
|
+
_lib_accounts.VENDOR_WIDE)]
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _unattributed_bucket_has_rows() -> bool:
|
|
492
|
+
import _lib_accounts
|
|
493
|
+
try:
|
|
494
|
+
conn = _cctally_core.open_db()
|
|
495
|
+
except Exception:
|
|
496
|
+
return False
|
|
497
|
+
try:
|
|
498
|
+
placeholders = ",".join("?" for _ in SYNTHETIC_SNAPSHOT_SOURCES)
|
|
499
|
+
row = conn.execute(
|
|
500
|
+
"SELECT 1 FROM weekly_usage_snapshots WHERE source NOT IN "
|
|
501
|
+
f"({placeholders}) AND (account_key IS NULL OR account_key = ?)"
|
|
502
|
+
" LIMIT 1",
|
|
503
|
+
list(SYNTHETIC_SNAPSHOT_SOURCES) + [_lib_accounts.UNATTRIBUTED],
|
|
504
|
+
).fetchone()
|
|
505
|
+
except sqlite3.Error:
|
|
506
|
+
return False
|
|
507
|
+
finally:
|
|
508
|
+
conn.close()
|
|
509
|
+
return row is not None
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def resolve_accounts(args) -> tuple:
|
|
513
|
+
"""Which populations this invocation analyses, and an exit code or None.
|
|
514
|
+
|
|
515
|
+
`--account` narrows to one, resolved case-insensitively through the shared
|
|
516
|
+
ref resolver; an ambiguous or unknown ref is a native usage error at exit
|
|
517
|
+
2 with the candidates on stderr.
|
|
518
|
+
|
|
519
|
+
Without `--account`, the answer depends on the R8 gate rather than on a
|
|
520
|
+
merge. At one real account, or a lone unattributed bucket, the single
|
|
521
|
+
analysed population is the merged view `None` — which is byte-identical to
|
|
522
|
+
today's behaviour and is not a merge of two meters. Above one real
|
|
523
|
+
account, each account is analysed independently, because two accounts have
|
|
524
|
+
independent weekly meters and one budget fitted across both is meaningless.
|
|
525
|
+
"""
|
|
526
|
+
import _cctally_account
|
|
527
|
+
import _lib_accounts
|
|
528
|
+
ref = getattr(args, "account", None)
|
|
529
|
+
if ref is not None:
|
|
530
|
+
key, code = _cctally_account.resolve_account_filter(args, "claude")
|
|
531
|
+
if code is not None:
|
|
532
|
+
return [], code
|
|
533
|
+
return [key], None
|
|
534
|
+
if not accounts_are_decorated():
|
|
535
|
+
return [None], None
|
|
536
|
+
keys = _real_account_keys()
|
|
537
|
+
if _unattributed_bucket_has_rows():
|
|
538
|
+
keys.append(_lib_accounts.UNATTRIBUTED)
|
|
539
|
+
return keys, None
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def account_display_label(account_key) -> "str | None":
|
|
543
|
+
"""The label the account decoration renders, or None for the merged view."""
|
|
544
|
+
if account_key is None:
|
|
545
|
+
return None
|
|
546
|
+
import _cctally_account
|
|
547
|
+
try:
|
|
548
|
+
conn = _cctally_core.open_db()
|
|
549
|
+
except Exception:
|
|
550
|
+
return str(account_key)[:8]
|
|
551
|
+
try:
|
|
552
|
+
return _cctally_account.display_account_label(conn, account_key)
|
|
553
|
+
except sqlite3.Error:
|
|
554
|
+
return str(account_key)[:8]
|
|
555
|
+
finally:
|
|
556
|
+
conn.close()
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
# ---------------------------------------------------------------------------
|
|
560
|
+
# Persistence (spec sections 7 and 20).
|
|
561
|
+
#
|
|
562
|
+
# The fitted calibration is machine-owned internal state and lives in neither
|
|
563
|
+
# database. `cache.db` is declared fully re-derivable and `cache-sync
|
|
564
|
+
# --rebuild` clears keys there; a plain `stats.db` table is re-materialized
|
|
565
|
+
# from the journal on rebuild; a journal-backed record with a stats fold is
|
|
566
|
+
# forbidden, because the thirteen-migration stats registry is frozen and a
|
|
567
|
+
# schema change is an epoch bump. Living in neither is what satisfies the
|
|
568
|
+
# durability requirement by construction.
|
|
569
|
+
# ---------------------------------------------------------------------------
|
|
570
|
+
#: Bumped when the shape below changes incompatibly. A file stamped ABOVE this
|
|
571
|
+
#: value is version-ahead and is preserved, never overwritten.
|
|
572
|
+
CALIBRATION_STATE_SCHEMA_VERSION: int = 1
|
|
573
|
+
|
|
574
|
+
CALIBRATION_FILENAME: str = "quota-calibrations.json"
|
|
575
|
+
CALIBRATION_LOCK_FILENAME: str = "quota-calibrations.lock"
|
|
576
|
+
|
|
577
|
+
#: The state key for the merged view — the single population a one-account or
|
|
578
|
+
#: lone-unattributed install analyses. `_lib_accounts.VENDOR_WIDE` already
|
|
579
|
+
#: means "all accounts", so it is reused rather than a fourth sentinel minted.
|
|
580
|
+
MERGED_STATE_KEY: str = "*"
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def calibration_path():
|
|
584
|
+
return _cctally_core.APP_DIR / CALIBRATION_FILENAME
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def calibration_lock_path():
|
|
588
|
+
return _cctally_core.APP_DIR / CALIBRATION_LOCK_FILENAME
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _state_key(account_key) -> str:
|
|
592
|
+
return MERGED_STATE_KEY if account_key is None else str(account_key)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def empty_calibration_state() -> dict:
|
|
596
|
+
return {"schemaVersion": CALIBRATION_STATE_SCHEMA_VERSION, "accounts": {}}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
@dataclasses.dataclass(frozen=True)
|
|
600
|
+
class LoadedCalibrations:
|
|
601
|
+
"""The stored state, plus what had to be done to read it.
|
|
602
|
+
|
|
603
|
+
The plan's interface block names a bare `dict`. A bare dict cannot carry
|
|
604
|
+
the quarantine path, and spec section 20 requires the command to report
|
|
605
|
+
`unavailable` WITH that path — a rename the user is never told about
|
|
606
|
+
would be indistinguishable from the silent overwrite the section forbids.
|
|
607
|
+
"""
|
|
608
|
+
|
|
609
|
+
state: dict
|
|
610
|
+
quarantined: "str | None" = None
|
|
611
|
+
unreadable: bool = False
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@contextlib.contextmanager
|
|
615
|
+
def calibration_lock():
|
|
616
|
+
"""Exclusive `flock` around the calibration file's read-modify-write.
|
|
617
|
+
|
|
618
|
+
LOCK ORDER: this lock is a LEAF. No database connection, no other `flock`
|
|
619
|
+
and no open SQLite transaction may be held while it is acquired, and it
|
|
620
|
+
takes nothing else while held. The command therefore closes both stores
|
|
621
|
+
before it persists anything.
|
|
622
|
+
|
|
623
|
+
Blocking rather than non-blocking, following `config_writer_lock`: the
|
|
624
|
+
write is millisecond-scale, so a brief wait is preferable to silently
|
|
625
|
+
dropping a writer's update.
|
|
626
|
+
"""
|
|
627
|
+
path = calibration_lock_path()
|
|
628
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
629
|
+
fd = os.open(str(path), os.O_RDWR | os.O_CREAT, 0o600)
|
|
630
|
+
try:
|
|
631
|
+
try:
|
|
632
|
+
os.fchmod(fd, 0o600)
|
|
633
|
+
except OSError:
|
|
634
|
+
pass
|
|
635
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
636
|
+
try:
|
|
637
|
+
yield
|
|
638
|
+
finally:
|
|
639
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
640
|
+
finally:
|
|
641
|
+
os.close(fd)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _quarantine(path, now) -> str:
|
|
645
|
+
"""Rename a malformed or version-ahead file aside and return its new path.
|
|
646
|
+
|
|
647
|
+
Silently rewriting it would destroy the only surviving record of budgets
|
|
648
|
+
whose source rows have since been pruned, so the file is preserved under a
|
|
649
|
+
timestamped suffix and a fresh one is started beside it.
|
|
650
|
+
"""
|
|
651
|
+
stamp = now.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
652
|
+
candidate = path.with_name(f"{path.name}.quarantined-{stamp}")
|
|
653
|
+
counter = 1
|
|
654
|
+
while candidate.exists():
|
|
655
|
+
candidate = path.with_name(
|
|
656
|
+
f"{path.name}.quarantined-{stamp}.{counter}")
|
|
657
|
+
counter += 1
|
|
658
|
+
os.replace(str(path), str(candidate))
|
|
659
|
+
return str(candidate)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def load_calibrations(*, now=None) -> LoadedCalibrations:
|
|
663
|
+
"""Read the stored state, quarantining a file this binary cannot own.
|
|
664
|
+
|
|
665
|
+
`now` supplies the quarantine suffix and is required whenever a
|
|
666
|
+
quarantine is possible; it defaults to the wall clock only so a read-only
|
|
667
|
+
caller need not supply one.
|
|
668
|
+
"""
|
|
669
|
+
path = calibration_path()
|
|
670
|
+
if not path.exists():
|
|
671
|
+
return LoadedCalibrations(empty_calibration_state())
|
|
672
|
+
try:
|
|
673
|
+
raw = path.read_text(encoding="utf-8")
|
|
674
|
+
except OSError:
|
|
675
|
+
return LoadedCalibrations(empty_calibration_state(), unreadable=True)
|
|
676
|
+
when = now or dt.datetime.now(UTC)
|
|
677
|
+
try:
|
|
678
|
+
state = json.loads(raw)
|
|
679
|
+
except ValueError:
|
|
680
|
+
return LoadedCalibrations(empty_calibration_state(),
|
|
681
|
+
quarantined=_quarantine(path, when))
|
|
682
|
+
version = state.get("schemaVersion") if isinstance(state, dict) else None
|
|
683
|
+
if (not isinstance(state, dict) or not isinstance(version, int)
|
|
684
|
+
or isinstance(version, bool)
|
|
685
|
+
or version > CALIBRATION_STATE_SCHEMA_VERSION
|
|
686
|
+
or not isinstance(state.get("accounts"), dict)):
|
|
687
|
+
return LoadedCalibrations(empty_calibration_state(),
|
|
688
|
+
quarantined=_quarantine(path, when))
|
|
689
|
+
return LoadedCalibrations(state)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def save_calibrations(state: dict) -> None:
|
|
693
|
+
"""Write the state atomically, more strictly than `save_config` does.
|
|
694
|
+
|
|
695
|
+
`_cctally_config.save_config` is a related precedent rather than the
|
|
696
|
+
specification: it uses a PID-only temporary name at mode 0644 and does NOT
|
|
697
|
+
fsync the parent directory, so a crash after the rename can lose the
|
|
698
|
+
rename itself. This protocol creates the temporary file with `O_EXCL`
|
|
699
|
+
under a name unique per process AND attempt, writes at 0600, fsyncs the
|
|
700
|
+
contents, `os.replace`s into position, and then fsyncs the parent
|
|
701
|
+
directory.
|
|
702
|
+
|
|
703
|
+
The caller holds `calibration_lock()`.
|
|
704
|
+
"""
|
|
705
|
+
path = calibration_path()
|
|
706
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
707
|
+
_sweep_stale_temporaries(path)
|
|
708
|
+
payload = (json.dumps(state, indent=2) + "\n").encode("utf-8")
|
|
709
|
+
attempt = 0
|
|
710
|
+
while True:
|
|
711
|
+
tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}.{attempt}")
|
|
712
|
+
try:
|
|
713
|
+
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
714
|
+
break
|
|
715
|
+
except FileExistsError:
|
|
716
|
+
attempt += 1
|
|
717
|
+
if attempt > 64:
|
|
718
|
+
raise
|
|
719
|
+
try:
|
|
720
|
+
os.write(fd, payload)
|
|
721
|
+
os.fsync(fd)
|
|
722
|
+
finally:
|
|
723
|
+
os.close(fd)
|
|
724
|
+
os.replace(str(tmp), str(path))
|
|
725
|
+
# The rename itself is only durable once the DIRECTORY entry is synced.
|
|
726
|
+
dir_fd = os.open(str(path.parent), os.O_RDONLY)
|
|
727
|
+
try:
|
|
728
|
+
os.fsync(dir_fd)
|
|
729
|
+
finally:
|
|
730
|
+
os.close(dir_fd)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _sweep_stale_temporaries(path) -> None:
|
|
734
|
+
"""Remove temporary files a crashed writer left behind.
|
|
735
|
+
|
|
736
|
+
Safe because the caller holds the exclusive lock, so no other writer owns
|
|
737
|
+
one. Without this a crash at the wrong instant leaks a file per attempt.
|
|
738
|
+
"""
|
|
739
|
+
prefix = f"{path.name}.tmp."
|
|
740
|
+
try:
|
|
741
|
+
names = list(path.parent.iterdir())
|
|
742
|
+
except OSError:
|
|
743
|
+
return
|
|
744
|
+
for candidate in names:
|
|
745
|
+
if candidate.name.startswith(prefix):
|
|
746
|
+
try:
|
|
747
|
+
candidate.unlink()
|
|
748
|
+
except OSError:
|
|
749
|
+
pass
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
@dataclasses.dataclass(frozen=True)
|
|
753
|
+
class PersistMode:
|
|
754
|
+
"""Everything the pure reducer needs that is not the analysis itself.
|
|
755
|
+
|
|
756
|
+
`kind` is `"automatic"` or `"diagnostic"`. A diagnostic run — one passing
|
|
757
|
+
`--watch-from`, or a `--since` narrower than the stored regime's span —
|
|
758
|
+
reports its result and writes nothing, so the durable history stays a
|
|
759
|
+
function of unmodified runs rather than of whatever the operator last
|
|
760
|
+
asked to inspect.
|
|
761
|
+
"""
|
|
762
|
+
|
|
763
|
+
kind: str
|
|
764
|
+
account_key: "str | None"
|
|
765
|
+
now: "dt.datetime"
|
|
766
|
+
fingerprint: str
|
|
767
|
+
regime_start: "dt.datetime"
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _interval_json(interval):
|
|
771
|
+
if interval is None:
|
|
772
|
+
return None
|
|
773
|
+
return {"lo": interval.lo, "hi": interval.hi}
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _regime_from(evidence, *, effective_from, mode, status):
|
|
777
|
+
"""One durable regime record built from an AVAILABLE evidence value."""
|
|
778
|
+
return {
|
|
779
|
+
"effectiveFrom": effective_from.astimezone(UTC).isoformat(),
|
|
780
|
+
"effectiveUntil": None,
|
|
781
|
+
"fingerprint": mode.fingerprint,
|
|
782
|
+
"algorithmRevision": qm.QUOTA_MODEL_ALGORITHM_REVISION,
|
|
783
|
+
"unitsPerPoint": evidence.value,
|
|
784
|
+
"interval": _interval_json(evidence.interval),
|
|
785
|
+
"support": ({"days": evidence.support.days,
|
|
786
|
+
"segments": evidence.support.segments}
|
|
787
|
+
if evidence.support is not None else None),
|
|
788
|
+
"status": status,
|
|
789
|
+
"asOf": mode.now.astimezone(UTC).isoformat(),
|
|
790
|
+
"qualifications": list(evidence.qualifications),
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _decorate(regime, analysis):
|
|
795
|
+
"""Attach the composition the regime was fitted under."""
|
|
796
|
+
regime["familyShares"] = dict(analysis.family_shares)
|
|
797
|
+
regime["classShares"] = dict(analysis.class_shares)
|
|
798
|
+
regime["familyRadius"] = analysis.family_radius
|
|
799
|
+
regime["classRadius"] = analysis.class_radius
|
|
800
|
+
return regime
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _open_regime(regimes):
|
|
804
|
+
for regime in reversed(regimes):
|
|
805
|
+
if regime.get("effectiveUntil") is None:
|
|
806
|
+
return regime
|
|
807
|
+
return None
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def read_stored_state_readonly() -> "dict | None":
|
|
811
|
+
"""The persisted calibration state, read WITHOUT quarantining anything.
|
|
812
|
+
|
|
813
|
+
`load_calibrations` renames a malformed or version-ahead file aside
|
|
814
|
+
through `_quarantine`, so a read-only caller — `doctor` is the documented
|
|
815
|
+
one — must not use it. This returns None rather than an empty state on
|
|
816
|
+
any failure, so a caller can tell "nothing readable" from "nothing
|
|
817
|
+
stored"; the two render differently.
|
|
818
|
+
|
|
819
|
+
No lock is taken. `save_calibrations` writes through `os.replace` in the
|
|
820
|
+
same directory, so a lock-free reader always sees a complete old or new
|
|
821
|
+
inode, and a flock here would let a writer stall a read-only command.
|
|
822
|
+
"""
|
|
823
|
+
try:
|
|
824
|
+
raw = calibration_path().read_text(encoding="utf-8")
|
|
825
|
+
except OSError:
|
|
826
|
+
return None
|
|
827
|
+
try:
|
|
828
|
+
state = json.loads(raw)
|
|
829
|
+
except ValueError:
|
|
830
|
+
return None
|
|
831
|
+
if not isinstance(state, dict) or not isinstance(
|
|
832
|
+
state.get("accounts"), dict):
|
|
833
|
+
return None
|
|
834
|
+
return state
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def stored_regimes(state: dict, account_key) -> list:
|
|
838
|
+
"""The stored regimes for one account, oldest first. Never mutated."""
|
|
839
|
+
accounts = state.get("accounts") or {}
|
|
840
|
+
bucket = accounts.get(_state_key(account_key)) or {}
|
|
841
|
+
regimes = bucket.get("regimes") or []
|
|
842
|
+
return [dict(r) for r in regimes if isinstance(r, dict)]
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def recorded_regime(state: dict, account_key) -> "dict | None":
|
|
846
|
+
"""The open regime a run would present as the durable prior, or None."""
|
|
847
|
+
return _open_regime(stored_regimes(state, account_key))
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def reduce_state(stored: dict, analysis, mode: PersistMode) -> dict:
|
|
851
|
+
"""The durable history as a pure function of `(stored, analysis, mode)`.
|
|
852
|
+
|
|
853
|
+
Pure: no clock, no file access, no mutation of `stored`. That is what
|
|
854
|
+
makes the history a function of its inputs rather than of call order, and
|
|
855
|
+
it is why every transition below is testable without a filesystem.
|
|
856
|
+
|
|
857
|
+
Transitions (spec section 20):
|
|
858
|
+
|
|
859
|
+
* A diagnostic run writes nothing.
|
|
860
|
+
* A confirmed rate change closes the open regime at the split's first
|
|
861
|
+
instant and opens a successor from it. The first run that discovers a
|
|
862
|
+
split persists BOTH sides, so the baseline is created if it is absent.
|
|
863
|
+
* A trustworthy fit with no detected change updates the open regime in
|
|
864
|
+
place, or opens the first one.
|
|
865
|
+
* A stored regime under a different fingerprint is marked `stale` and a
|
|
866
|
+
new regime is APPENDED; its own value is never rewritten, because that
|
|
867
|
+
value was true under its own constants.
|
|
868
|
+
* A non-trustworthy analysis writes nothing.
|
|
869
|
+
"""
|
|
870
|
+
if mode.kind != "automatic":
|
|
871
|
+
return stored
|
|
872
|
+
key = _state_key(mode.account_key)
|
|
873
|
+
regimes = stored_regimes(stored, mode.account_key)
|
|
874
|
+
confirmed = (analysis.verdict is Verdict.RATE_CHANGE_DETECTED
|
|
875
|
+
and analysis.detector.split_date is not None)
|
|
876
|
+
fitted_ok = (analysis.status is CalibrationStatus.OK
|
|
877
|
+
and analysis.fitted.state == "available")
|
|
878
|
+
successor_ok = (confirmed and analysis.watch_fit.state == "available")
|
|
879
|
+
if not fitted_ok and not successor_ok:
|
|
880
|
+
return stored
|
|
881
|
+
|
|
882
|
+
open_regime = _open_regime(regimes)
|
|
883
|
+
# A stored regime fitted under different constants is marked stale and
|
|
884
|
+
# left otherwise untouched. It is marked HERE, together with the append
|
|
885
|
+
# below, because section 20 also says a run that writes nothing leaves the
|
|
886
|
+
# stored state alone — and a run reaching this point is writing.
|
|
887
|
+
if open_regime is not None and open_regime.get("fingerprint") != \
|
|
888
|
+
mode.fingerprint:
|
|
889
|
+
open_regime["status"] = CalibrationStatus.STALE.value
|
|
890
|
+
open_regime["effectiveUntil"] = mode.now.astimezone(UTC).isoformat()
|
|
891
|
+
open_regime = None
|
|
892
|
+
|
|
893
|
+
if confirmed:
|
|
894
|
+
boundary = dt.datetime.combine(
|
|
895
|
+
analysis.detector.split_date, dt.time(0, 0), tzinfo=UTC)
|
|
896
|
+
boundary_iso = boundary.isoformat()
|
|
897
|
+
# An open regime that already STARTS at the boundary is the successor
|
|
898
|
+
# this run is re-discovering, not a predecessor to close. Closing it
|
|
899
|
+
# would set its `effectiveUntil` to its own `effectiveFrom` and the
|
|
900
|
+
# append below would then add a third regime on every subsequent run.
|
|
901
|
+
open_from = (parse_instant(open_regime.get("effectiveFrom"))
|
|
902
|
+
if open_regime is not None else None)
|
|
903
|
+
predecessor = (open_regime if open_from is not None
|
|
904
|
+
and open_from < boundary else None)
|
|
905
|
+
if predecessor is not None:
|
|
906
|
+
predecessor["effectiveUntil"] = boundary_iso
|
|
907
|
+
predecessor["status"] = CalibrationStatus.OK.value
|
|
908
|
+
elif open_regime is None and analysis.baseline_fit.state == "available":
|
|
909
|
+
# The first run that discovers a split persists both sides, so a
|
|
910
|
+
# store with no prior regime still records what the rate WAS.
|
|
911
|
+
baseline = _decorate(_regime_from(
|
|
912
|
+
analysis.baseline_fit, effective_from=mode.regime_start,
|
|
913
|
+
mode=mode, status=CalibrationStatus.OK.value), analysis)
|
|
914
|
+
baseline["effectiveUntil"] = boundary_iso
|
|
915
|
+
regimes.append(baseline)
|
|
916
|
+
if successor_ok:
|
|
917
|
+
successor = _decorate(_regime_from(
|
|
918
|
+
analysis.watch_fit, effective_from=boundary, mode=mode,
|
|
919
|
+
status=analysis.status.value), analysis)
|
|
920
|
+
existing = next((r for r in regimes
|
|
921
|
+
if r.get("effectiveFrom") == boundary_iso
|
|
922
|
+
and r.get("effectiveUntil") is None), None)
|
|
923
|
+
if existing is None:
|
|
924
|
+
regimes.append(successor)
|
|
925
|
+
else:
|
|
926
|
+
existing.update(successor)
|
|
927
|
+
elif fitted_ok:
|
|
928
|
+
fresh = _decorate(_regime_from(
|
|
929
|
+
analysis.fitted,
|
|
930
|
+
effective_from=(
|
|
931
|
+
parse_instant(open_regime["effectiveFrom"])
|
|
932
|
+
if open_regime is not None
|
|
933
|
+
and open_regime.get("effectiveFrom") else mode.regime_start),
|
|
934
|
+
mode=mode, status=CalibrationStatus.OK.value), analysis)
|
|
935
|
+
if open_regime is None:
|
|
936
|
+
regimes.append(fresh)
|
|
937
|
+
else:
|
|
938
|
+
open_regime.update(fresh)
|
|
939
|
+
else:
|
|
940
|
+
return stored
|
|
941
|
+
|
|
942
|
+
accounts = dict(stored.get("accounts") or {})
|
|
943
|
+
accounts[key] = {"regimes": regimes}
|
|
944
|
+
return {"schemaVersion": CALIBRATION_STATE_SCHEMA_VERSION,
|
|
945
|
+
"accounts": accounts}
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def reset_calibration(account_key, *, now=None) -> bool:
|
|
949
|
+
"""Remove one account's regimes. True when something was stored.
|
|
950
|
+
|
|
951
|
+
Idempotent: resetting an absent calibration succeeds and reports that
|
|
952
|
+
nothing was stored, which is what makes it safe to put in a script.
|
|
953
|
+
"""
|
|
954
|
+
with calibration_lock():
|
|
955
|
+
loaded = load_calibrations(now=now)
|
|
956
|
+
accounts = dict(loaded.state.get("accounts") or {})
|
|
957
|
+
key = _state_key(account_key)
|
|
958
|
+
if key not in accounts:
|
|
959
|
+
return False
|
|
960
|
+
accounts.pop(key)
|
|
961
|
+
save_calibrations({
|
|
962
|
+
"schemaVersion": CALIBRATION_STATE_SCHEMA_VERSION,
|
|
963
|
+
"accounts": accounts})
|
|
964
|
+
return True
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _alert_account_key(account_key) -> str:
|
|
968
|
+
"""The account identity §6.3's key carries.
|
|
969
|
+
|
|
970
|
+
A merged read has no account, and `unattributed` is the estate's sentinel
|
|
971
|
+
for exactly that, so it is used rather than an empty string — the column
|
|
972
|
+
is NOT NULL and every other account-scoped alert family already spells the
|
|
973
|
+
absence this way.
|
|
974
|
+
"""
|
|
975
|
+
import _lib_accounts
|
|
976
|
+
if account_key is None:
|
|
977
|
+
return _lib_accounts.UNATTRIBUTED
|
|
978
|
+
return str(account_key)
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def persist_and_detect(analysis, mode: PersistMode) -> tuple:
|
|
982
|
+
"""`(quarantine path or None, transitions)` under one lock.
|
|
983
|
+
|
|
984
|
+
The read, the reduction and the write all happen inside one exclusive
|
|
985
|
+
lock, because the reducer is a read-modify-write and the atomic rename
|
|
986
|
+
alone protects readers rather than writers.
|
|
987
|
+
|
|
988
|
+
#661 S2 §6.5 step 1: the metering-rate transition is decided HERE, from
|
|
989
|
+
the persisted state before and after this write. The trigger is the
|
|
990
|
+
persistence transition itself rather than a second detector run, so two
|
|
991
|
+
consumers cannot disagree about whether a change happened. The descriptor
|
|
992
|
+
is RETURNED rather than acted on, because step 2 requires this leaf lock
|
|
993
|
+
to be released before any stats lock is taken, and this function is the
|
|
994
|
+
lock's whole extent.
|
|
995
|
+
"""
|
|
996
|
+
with calibration_lock():
|
|
997
|
+
loaded = load_calibrations(now=mode.now)
|
|
998
|
+
if loaded.unreadable:
|
|
999
|
+
# An EMPTY TUPLE, never `None`. `cmd_quota` iterates the second
|
|
1000
|
+
# element, so a `None` here raised `TypeError: 'NoneType' object
|
|
1001
|
+
# is not iterable` on a file this binary could not read — a
|
|
1002
|
+
# permissions problem, an I/O error, or a concurrent quarantine
|
|
1003
|
+
# rename removing the primary name between the existence check
|
|
1004
|
+
# and the read. No transition is detectable without a before
|
|
1005
|
+
# state, and "no transitions" is an empty sequence.
|
|
1006
|
+
return None, ()
|
|
1007
|
+
before = stored_regimes(loaded.state, mode.account_key)
|
|
1008
|
+
reduced = reduce_state(loaded.state, analysis, mode)
|
|
1009
|
+
transitions: tuple = ()
|
|
1010
|
+
if reduced is not loaded.state:
|
|
1011
|
+
save_calibrations(reduced)
|
|
1012
|
+
mrc = _cctally()._load_sibling("_lib_meter_rate_change")
|
|
1013
|
+
transitions = mrc.detect_transitions(
|
|
1014
|
+
before, stored_regimes(reduced, mode.account_key),
|
|
1015
|
+
provider="claude",
|
|
1016
|
+
account_key=_alert_account_key(mode.account_key),
|
|
1017
|
+
detected_at=mode.now.astimezone(UTC).isoformat())
|
|
1018
|
+
return loaded.quarantined, transitions
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def rate_change_notifications_enabled() -> bool:
|
|
1022
|
+
"""Whether a recorded transition also PUSHES (spec §6.2).
|
|
1023
|
+
|
|
1024
|
+
Both switches must be on, exactly as the quota threshold axis requires:
|
|
1025
|
+
the global `alerts.enabled` and the family's own
|
|
1026
|
+
`alerts.rate_change_enabled`, each default-off. A config read that raises
|
|
1027
|
+
answers False rather than propagating — a malformed alerts block must not
|
|
1028
|
+
turn a recording path into an error path, because §6.2's whole point is
|
|
1029
|
+
that recording happens with no configuration at all.
|
|
1030
|
+
"""
|
|
1031
|
+
c = _cctally()
|
|
1032
|
+
try:
|
|
1033
|
+
block = _cctally_core._get_alerts_config(c.load_config())
|
|
1034
|
+
except Exception: # noqa: BLE001
|
|
1035
|
+
return False
|
|
1036
|
+
return bool(block.get("enabled")) and bool(block.get("rate_change_enabled"))
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def record_rate_change_transition(transition, *, now) -> bool:
|
|
1040
|
+
"""Steps 3 to 6 of spec §6.5. True when this call created the row.
|
|
1041
|
+
|
|
1042
|
+
`run_stats_ingest` is the sole stats writer and it enforces journal-first,
|
|
1043
|
+
then commit, then notify — so the descriptor is passed THROUGH it rather
|
|
1044
|
+
than written here. `mode="authoritative"` because the caller must observe
|
|
1045
|
+
its own transition rather than leaving it to whichever process next holds
|
|
1046
|
+
the ingest lock: a user who just ran `cctally quota` and saw the change
|
|
1047
|
+
reported would otherwise find no event recorded.
|
|
1048
|
+
|
|
1049
|
+
A stats index mid-epoch-rebuild, a busy lock or a corrupt store must not
|
|
1050
|
+
turn `cctally quota` into an error: the calibration is
|
|
1051
|
+
already persisted, and the transition is re-derivable from it on the next
|
|
1052
|
+
run because `detect_transitions` compares the state before and after each
|
|
1053
|
+
write — a run that wrote nothing produces no descriptor, so a missed
|
|
1054
|
+
recording is recovered by the next run that does write. That is a real
|
|
1055
|
+
gap, and it is the deliberate trade: the durable truth is the calibration
|
|
1056
|
+
file plus the journal, and neither is lost.
|
|
1057
|
+
|
|
1058
|
+
The catch is `Exception` plus the two deferral signals, and NOT
|
|
1059
|
+
`BaseException` (#661 S2 Stage C review). `StatsRebuildDeferred` derives
|
|
1060
|
+
directly from `BaseException` — deliberately, so that no ordinary
|
|
1061
|
+
`except Exception` absorbs it — which made `except BaseException` the
|
|
1062
|
+
obvious way to cover it and swallowed `KeyboardInterrupt` and
|
|
1063
|
+
`SystemExit` with it. A Ctrl-C during the ingest then printed "could not
|
|
1064
|
+
record the metering-rate change" and the command carried on. Those are
|
|
1065
|
+
different kinds of event: one says this store is busy, the other says
|
|
1066
|
+
this process is ending, and only the first is this function's to absorb.
|
|
1067
|
+
Catching the shared parent rather than the epoch subclass keeps the
|
|
1068
|
+
"widened ONCE" property `StatsRebuildDeferred`'s own docstring states.
|
|
1069
|
+
"""
|
|
1070
|
+
import _cctally_journal as jr
|
|
1071
|
+
from _cctally_db import StatsRebuildDeferred
|
|
1072
|
+
try:
|
|
1073
|
+
result = jr.run_stats_ingest(
|
|
1074
|
+
mode="authoritative",
|
|
1075
|
+
meter_rate_change={
|
|
1076
|
+
"transition": transition,
|
|
1077
|
+
"notify": rate_change_notifications_enabled(),
|
|
1078
|
+
"created_at": now.astimezone(UTC).isoformat(),
|
|
1079
|
+
},
|
|
1080
|
+
)
|
|
1081
|
+
except (Exception, StatsRebuildDeferred) as exc: # noqa: BLE001
|
|
1082
|
+
eprint(f"quota: could not record the metering-rate change: {exc}")
|
|
1083
|
+
return False
|
|
1084
|
+
return bool(result.ran)
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
def persist(analysis, mode: PersistMode) -> "str | None":
|
|
1088
|
+
"""`persist_and_detect`'s quarantine half, for callers with no stats leg."""
|
|
1089
|
+
quarantined, _transitions = persist_and_detect(analysis, mode)
|
|
1090
|
+
return quarantined
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
# ---------------------------------------------------------------------------
|
|
1094
|
+
# The analysis (spec sections 21, 22, 23, 35).
|
|
1095
|
+
# ---------------------------------------------------------------------------
|
|
1096
|
+
#: Which outcome an invocation reports when it analysed several accounts.
|
|
1097
|
+
#: A FINDING wins over degradation, which is `pricing-check`'s precedent and
|
|
1098
|
+
#: the reason spec section 8 gives exit 1 to a confirmed change at all: a
|
|
1099
|
+
#: degraded leg never masks a finding. Below that, an unhealthy account
|
|
1100
|
+
#: outranks a merely thin one, because it names something to fix.
|
|
1101
|
+
EXIT_SEVERITY: dict = {0: 0, 4: 1, 3: 2, 1: 3}
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
@dataclasses.dataclass(frozen=True)
|
|
1105
|
+
class CurrentWeek:
|
|
1106
|
+
"""The window the published consumption describes.
|
|
1107
|
+
|
|
1108
|
+
`units_start` is where the unit total begins and is NOT always the week
|
|
1109
|
+
anchor: a credited week's continuing series uses post-credit captures, so
|
|
1110
|
+
counting units from the anchor would divide a whole week's tokens by a
|
|
1111
|
+
meter that was reset partway through. It matches `_floored_week_max`'s
|
|
1112
|
+
choice, made consistently here.
|
|
1113
|
+
"""
|
|
1114
|
+
|
|
1115
|
+
anchor: "dt.datetime"
|
|
1116
|
+
end: "dt.datetime"
|
|
1117
|
+
units_start: "dt.datetime"
|
|
1118
|
+
observed_percent: "int | None"
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def current_week_window(segments, *, now) -> "CurrentWeek | None":
|
|
1122
|
+
"""The subscription week the clock is in, or None when none is retained.
|
|
1123
|
+
|
|
1124
|
+
None rather than a guess: the kernel then withholds the projection with
|
|
1125
|
+
the qualification `week-window-unknown`, which says the caller supplied no
|
|
1126
|
+
window rather than telling the user their history is missing.
|
|
1127
|
+
"""
|
|
1128
|
+
if not segments:
|
|
1129
|
+
return None
|
|
1130
|
+
last = max(segments, key=lambda s: s.rows[-1].at)
|
|
1131
|
+
anchor = last.week_anchor
|
|
1132
|
+
end = anchor + dt.timedelta(days=7)
|
|
1133
|
+
if now >= end:
|
|
1134
|
+
return None
|
|
1135
|
+
in_week = [s for s in segments if s.week_anchor == anchor]
|
|
1136
|
+
# One segment on this anchor means the week ran uninterrupted, so the
|
|
1137
|
+
# units start where the week does. Several means a floor credit opened a
|
|
1138
|
+
# new slice under the same week identity, and the live slice is the last.
|
|
1139
|
+
# A reset credit re-anchors, so its successor is the only segment on its
|
|
1140
|
+
# own anchor and the anchor IS the credit instant.
|
|
1141
|
+
units_start = anchor if len(in_week) == 1 else in_week[-1].rows[0].at
|
|
1142
|
+
rows = sorted(last.rows, key=qm._snapshot_sort_key)
|
|
1143
|
+
observed = qm.integer_percent(rows[-1].percent) if rows else None
|
|
1144
|
+
return CurrentWeek(anchor, end, units_start, observed)
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
@dataclasses.dataclass(frozen=True)
|
|
1148
|
+
class AccountResult:
|
|
1149
|
+
"""Everything one account's renderers need, already computed."""
|
|
1150
|
+
|
|
1151
|
+
account_key: "str | None"
|
|
1152
|
+
label: "str | None"
|
|
1153
|
+
analysis: object
|
|
1154
|
+
load: LoadResult
|
|
1155
|
+
week: "CurrentWeek | None"
|
|
1156
|
+
recorded: "dict | None"
|
|
1157
|
+
stale_prior: bool
|
|
1158
|
+
mode_kind: str
|
|
1159
|
+
#: The analysis run with `fingerprint_matches=True`. The reducer reads
|
|
1160
|
+
#: THIS one, so a stale prior cannot stop a fresh trustworthy fit from
|
|
1161
|
+
#: being accepted; `analysis` is what the user is shown.
|
|
1162
|
+
clean: object = None
|
|
1163
|
+
quarantined: "str | None" = None
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def analyse_account(account_key, *, now, since, watch_from,
|
|
1167
|
+
stored_state) -> AccountResult:
|
|
1168
|
+
"""Read one account's population and run the kernel over it."""
|
|
1169
|
+
load = load_population(account_key, since=since, now=now)
|
|
1170
|
+
extra = [load.status] if load.status is not None else []
|
|
1171
|
+
segments = qm.build_segments(load.snapshots, load.credits)
|
|
1172
|
+
series = qm.build_daily_series(
|
|
1173
|
+
segments, load.entries, now=now, newest_entry_at=load.newest_entry_at)
|
|
1174
|
+
week = current_week_window(segments, now=now)
|
|
1175
|
+
if week is None:
|
|
1176
|
+
forecast_population: tuple = ()
|
|
1177
|
+
week_units = None
|
|
1178
|
+
observed = None
|
|
1179
|
+
week_start = week_end = None
|
|
1180
|
+
else:
|
|
1181
|
+
horizon = min(now, week.end)
|
|
1182
|
+
forecast_population = tuple(
|
|
1183
|
+
e for e in load.entries if week.units_start <= e.at < horizon)
|
|
1184
|
+
week_units = 0.0
|
|
1185
|
+
for entry in forecast_population:
|
|
1186
|
+
units = qm.weighted_units(entry)
|
|
1187
|
+
if units is None:
|
|
1188
|
+
continue
|
|
1189
|
+
if qm.family_participation(
|
|
1190
|
+
qm.normalize_family(entry.model)) != "general":
|
|
1191
|
+
continue
|
|
1192
|
+
week_units += units
|
|
1193
|
+
observed = week.observed_percent
|
|
1194
|
+
week_start, week_end = week.units_start, week.end
|
|
1195
|
+
|
|
1196
|
+
recorded = recorded_regime(stored_state, account_key)
|
|
1197
|
+
stale_prior = bool(
|
|
1198
|
+
recorded is not None
|
|
1199
|
+
and recorded.get("fingerprint")
|
|
1200
|
+
!= qm.QUOTA_MODEL_CONSTANTS_FINGERPRINT)
|
|
1201
|
+
mode_kind = "automatic"
|
|
1202
|
+
if watch_from is not None:
|
|
1203
|
+
mode_kind = "diagnostic"
|
|
1204
|
+
elif since is not None and recorded is not None:
|
|
1205
|
+
recorded_from = parse_instant(recorded.get("effectiveFrom"))
|
|
1206
|
+
if recorded_from is not None and since > recorded_from:
|
|
1207
|
+
mode_kind = "diagnostic"
|
|
1208
|
+
|
|
1209
|
+
common = dict(
|
|
1210
|
+
now=now, newest_at=load.newest_entry_at,
|
|
1211
|
+
forecast_population=forecast_population,
|
|
1212
|
+
observed_percent=observed, current_week_units=week_units,
|
|
1213
|
+
current_week_start=week_start, current_week_end=week_end,
|
|
1214
|
+
override_split=watch_from.date() if watch_from is not None else None,
|
|
1215
|
+
extra_statuses=tuple(extra),
|
|
1216
|
+
unattributed=qm.unattributed_units(segments, load.entries, now=now),
|
|
1217
|
+
in_progress_excluded=qm.in_progress_dates(segments, now=now),
|
|
1218
|
+
diagnostics={"snapshots": len(load.snapshots),
|
|
1219
|
+
"segments": len(segments),
|
|
1220
|
+
"credits": len(load.credits)},
|
|
1221
|
+
)
|
|
1222
|
+
# `fingerprint_matches` describes the calibration the command would ACT
|
|
1223
|
+
# on, which is the fresh fit whenever one is trustworthy. Passing False
|
|
1224
|
+
# unconditionally under a stale prior would deadlock: `stale` outranks
|
|
1225
|
+
# everything below it, so the status could never reach `ok` and no new fit
|
|
1226
|
+
# could ever be accepted under the current constants. The fresh analysis
|
|
1227
|
+
# therefore runs first, and a stale prior only decides the REPORT when
|
|
1228
|
+
# that fresh analysis has no trustworthy fit of its own to publish.
|
|
1229
|
+
clean = qm.analyse(series, fingerprint_matches=True, **common)
|
|
1230
|
+
reported = clean
|
|
1231
|
+
if stale_prior and clean.status is not CalibrationStatus.OK:
|
|
1232
|
+
reported = qm.analyse(series, fingerprint_matches=False, **common)
|
|
1233
|
+
return AccountResult(
|
|
1234
|
+
account_key=account_key,
|
|
1235
|
+
label=account_display_label(account_key),
|
|
1236
|
+
analysis=reported, load=load, week=week, recorded=recorded,
|
|
1237
|
+
stale_prior=stale_prior, mode_kind=mode_kind, clean=clean)
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
# ---------------------------------------------------------------------------
|
|
1241
|
+
# Rendering.
|
|
1242
|
+
# ---------------------------------------------------------------------------
|
|
1243
|
+
def _iso(value):
|
|
1244
|
+
if value is None:
|
|
1245
|
+
return None
|
|
1246
|
+
if isinstance(value, dt.datetime):
|
|
1247
|
+
return value.astimezone(UTC).isoformat()
|
|
1248
|
+
return value.isoformat()
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def _evidence_json(evidence) -> dict:
|
|
1252
|
+
"""One `CalibrationEvidence` on the wire.
|
|
1253
|
+
|
|
1254
|
+
A withheld value carries a typed code and NO value, no interval and no
|
|
1255
|
+
support — it is absent, never zero — and no confidence field exists at
|
|
1256
|
+
all, because the design never defined what a confidence would mean here.
|
|
1257
|
+
"""
|
|
1258
|
+
return {
|
|
1259
|
+
"state": evidence.state,
|
|
1260
|
+
"value": evidence.value,
|
|
1261
|
+
"interval": (None if evidence.interval is None
|
|
1262
|
+
else {"lo": evidence.interval.lo,
|
|
1263
|
+
"hi": evidence.interval.hi}),
|
|
1264
|
+
"support": (None if evidence.support is None
|
|
1265
|
+
else {"days": evidence.support.days,
|
|
1266
|
+
"segments": evidence.support.segments}),
|
|
1267
|
+
"population": dict(evidence.population),
|
|
1268
|
+
"code": evidence.code,
|
|
1269
|
+
"qualifications": list(evidence.qualifications),
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
def _recorded_json(recorded, stale_prior) -> "dict | None":
|
|
1274
|
+
if recorded is None:
|
|
1275
|
+
return None
|
|
1276
|
+
out = dict(recorded)
|
|
1277
|
+
out["fingerprintMatchesCurrent"] = not stale_prior
|
|
1278
|
+
return out
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def account_payload(result: AccountResult) -> dict:
|
|
1282
|
+
"""One account's complete payload, camelCase throughout."""
|
|
1283
|
+
analysis = result.analysis
|
|
1284
|
+
load = result.load
|
|
1285
|
+
diagnostics = dict(analysis.diagnostics)
|
|
1286
|
+
# The detector block is derived from `analysis.detector` rather than read
|
|
1287
|
+
# out of `diagnostics`, which is the same source the text renderer uses.
|
|
1288
|
+
# Reading the dict key made the wire's disclosure depend on a diagnostic
|
|
1289
|
+
# any caller could omit, and section 24 requires it published.
|
|
1290
|
+
diagnostics.pop("detector", None)
|
|
1291
|
+
detector = qm.detector_diagnostics(analysis.detector)
|
|
1292
|
+
return {
|
|
1293
|
+
"status": analysis.status.value,
|
|
1294
|
+
"verdict": analysis.verdict.value,
|
|
1295
|
+
"exitCode": analysis.exit_code,
|
|
1296
|
+
"scope": {
|
|
1297
|
+
"accountKey": result.account_key,
|
|
1298
|
+
"accountLabel": result.label,
|
|
1299
|
+
"merged": result.account_key is None,
|
|
1300
|
+
"analysisStart": _iso(load.analysis_start),
|
|
1301
|
+
"invocation": result.mode_kind,
|
|
1302
|
+
},
|
|
1303
|
+
"calibration": {
|
|
1304
|
+
"recorded": _recorded_json(result.recorded, result.stale_prior),
|
|
1305
|
+
"fitted": _evidence_json(analysis.fitted),
|
|
1306
|
+
},
|
|
1307
|
+
"analysis": {
|
|
1308
|
+
"baseline": {"fit": _evidence_json(analysis.baseline_fit)},
|
|
1309
|
+
"watch": {"fit": _evidence_json(analysis.watch_fit)},
|
|
1310
|
+
},
|
|
1311
|
+
"currentWeek": {
|
|
1312
|
+
"start": _iso(result.week.units_start) if result.week else None,
|
|
1313
|
+
"end": _iso(result.week.end) if result.week else None,
|
|
1314
|
+
"observedPercent": analysis.observed_percent,
|
|
1315
|
+
"observedMinusModelled": diagnostics.get("observedMinusModelled"),
|
|
1316
|
+
"consumption": _evidence_json(analysis.consumption),
|
|
1317
|
+
"projection": _evidence_json(analysis.projection),
|
|
1318
|
+
"headroom": _evidence_json(analysis.headroom),
|
|
1319
|
+
},
|
|
1320
|
+
"composition": {
|
|
1321
|
+
"baseline": {"familyShares": dict(analysis.family_shares),
|
|
1322
|
+
"classShares": dict(analysis.class_shares)},
|
|
1323
|
+
"current": {"familyShares": dict(analysis.current_family_shares),
|
|
1324
|
+
"classShares": dict(analysis.current_class_shares)},
|
|
1325
|
+
"familyRadiusEffective": analysis.family_radius,
|
|
1326
|
+
"classRadiusEffective": analysis.class_radius,
|
|
1327
|
+
"familyRadiusEmpirical": diagnostics.get("familyRadiusEmpirical"),
|
|
1328
|
+
"classRadiusEmpirical": diagnostics.get("classRadiusEmpirical"),
|
|
1329
|
+
"radiusRule": "max(observed spread, materiality floor)",
|
|
1330
|
+
},
|
|
1331
|
+
"health": {
|
|
1332
|
+
"entriesThrough": _iso(load.newest_entry_at),
|
|
1333
|
+
"snapshots": diagnostics.get("snapshots"),
|
|
1334
|
+
"segments": diagnostics.get("segments"),
|
|
1335
|
+
"credits": diagnostics.get("credits"),
|
|
1336
|
+
"eligibleDays": diagnostics.get("eligibleDays"),
|
|
1337
|
+
"withheldDays": diagnostics.get("withheldDays"),
|
|
1338
|
+
"eligibilityFence": diagnostics.get("eligibilityFence"),
|
|
1339
|
+
"unattributedUnits": diagnostics.get("unattributedUnits"),
|
|
1340
|
+
"unattributedEntries": diagnostics.get("unattributedEntries"),
|
|
1341
|
+
"inProgressDayExcluded": diagnostics.get("inProgressDayExcluded"),
|
|
1342
|
+
"forecastPopulationEntries": diagnostics.get(
|
|
1343
|
+
"forecastPopulationEntries"),
|
|
1344
|
+
"rejectedNumericInputs": diagnostics.get("rejectedNumericInputs"),
|
|
1345
|
+
"unrecognisedSnapshotSources": load.diagnostics.get(
|
|
1346
|
+
"unrecognisedSnapshotSources"),
|
|
1347
|
+
"legacyDateOnlyWeekAnchors": load.diagnostics.get(
|
|
1348
|
+
"legacyDateOnlyWeekAnchors"),
|
|
1349
|
+
"storeCause": load.cause,
|
|
1350
|
+
"calibrationFileQuarantinedTo": result.quarantined,
|
|
1351
|
+
},
|
|
1352
|
+
"method": {
|
|
1353
|
+
"algorithmRevision": qm.QUOTA_MODEL_ALGORITHM_REVISION,
|
|
1354
|
+
"constantsFingerprint": qm.QUOTA_MODEL_CONSTANTS_FINGERPRINT,
|
|
1355
|
+
"verifiedAt": qm.QUOTA_MODEL_VERIFIED_AT,
|
|
1356
|
+
"supportedCompositionFrom": SUPPORTED_COMPOSITION_FROM.isoformat(),
|
|
1357
|
+
"coefficientEras": [dict(era) for era in COEFFICIENT_ERAS],
|
|
1358
|
+
"detector": detector,
|
|
1359
|
+
"dedicatedPoolScope": qm.DEDICATED_POOL_SCOPE_NOTE,
|
|
1360
|
+
},
|
|
1361
|
+
"blocking": [reason.value for reason in analysis.blocking],
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def build_payload(results, *, now, decorated) -> dict:
|
|
1366
|
+
"""The whole invocation's JSON, stamped through the shared envelope.
|
|
1367
|
+
|
|
1368
|
+
With one analysed population the account payload is flattened to the top
|
|
1369
|
+
level, which keeps spec section 8's stable key list at the top level and
|
|
1370
|
+
keeps a single-account install's output shaped as that section describes.
|
|
1371
|
+
Above one real account the accounts are published side by side, because
|
|
1372
|
+
section 17 says they are reported separately rather than merged.
|
|
1373
|
+
"""
|
|
1374
|
+
worst = max(results, key=lambda r: EXIT_SEVERITY[r.analysis.exit_code])
|
|
1375
|
+
body = {
|
|
1376
|
+
"generatedAt": _iso(now),
|
|
1377
|
+
"status": worst.analysis.status.value,
|
|
1378
|
+
"verdict": worst.analysis.verdict.value,
|
|
1379
|
+
"exitCode": worst.analysis.exit_code,
|
|
1380
|
+
}
|
|
1381
|
+
if decorated and len(results) > 1:
|
|
1382
|
+
body["accounts"] = [account_payload(r) for r in results]
|
|
1383
|
+
else:
|
|
1384
|
+
body.update(account_payload(results[0]))
|
|
1385
|
+
body["status"] = results[0].analysis.status.value
|
|
1386
|
+
body["verdict"] = results[0].analysis.verdict.value
|
|
1387
|
+
body["exitCode"] = results[0].analysis.exit_code
|
|
1388
|
+
import _lib_json_envelope
|
|
1389
|
+
return _lib_json_envelope.stamp_schema_version(body)
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def _fmt_units(value) -> str:
|
|
1393
|
+
return "-" if value is None else f"{value:,.0f}"
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def _fmt_pct(value) -> str:
|
|
1397
|
+
return "-" if value is None else f"{value:.2f}%"
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def _evidence_line(label, evidence, formatter) -> str:
|
|
1401
|
+
if evidence.state != "available":
|
|
1402
|
+
marks = (f" ({', '.join(evidence.qualifications)})"
|
|
1403
|
+
if evidence.qualifications else "")
|
|
1404
|
+
return f" {label:26} withheld: {evidence.code}{marks}"
|
|
1405
|
+
interval = evidence.interval
|
|
1406
|
+
span = ""
|
|
1407
|
+
if interval is not None:
|
|
1408
|
+
span = (f" [{formatter(interval.lo)} , "
|
|
1409
|
+
f"{formatter(interval.hi) if interval.hi is not None else '-'}]")
|
|
1410
|
+
marks = (f" ({', '.join(evidence.qualifications)})"
|
|
1411
|
+
if evidence.qualifications else "")
|
|
1412
|
+
return f" {label:26} {formatter(evidence.value)}{span}{marks}"
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def render_text(results, *, now, decorated) -> list:
|
|
1416
|
+
"""The human report, as a list of lines."""
|
|
1417
|
+
out: list = []
|
|
1418
|
+
for result in results:
|
|
1419
|
+
analysis = result.analysis
|
|
1420
|
+
payload_detector = qm.detector_diagnostics(analysis.detector)
|
|
1421
|
+
if decorated and len(results) > 1:
|
|
1422
|
+
out.append(f"=== {result.label or 'merged'} ===")
|
|
1423
|
+
out.append("cctally quota — weekly quota consumption and metering rate")
|
|
1424
|
+
out.append("")
|
|
1425
|
+
out.append("DATA")
|
|
1426
|
+
out.append(f" entries through "
|
|
1427
|
+
f"{_iso(result.load.newest_entry_at) or '-'}")
|
|
1428
|
+
out.append(f" analysis start "
|
|
1429
|
+
f"{_iso(result.load.analysis_start)}"
|
|
1430
|
+
f" (supported composition: "
|
|
1431
|
+
f"{SUPPORTED_COMPOSITION_FROM.isoformat()} onward)")
|
|
1432
|
+
out.append(f" eligible days "
|
|
1433
|
+
f"{analysis.diagnostics.get('eligibleDays', 0)}"
|
|
1434
|
+
f" ({analysis.diagnostics.get('withheldDays', 0)} withheld)")
|
|
1435
|
+
if result.load.cause:
|
|
1436
|
+
out.append(f" ! store {result.load.cause}")
|
|
1437
|
+
if result.quarantined:
|
|
1438
|
+
out.append(f" ! stored calibration preserved at "
|
|
1439
|
+
f"{result.quarantined}")
|
|
1440
|
+
out.append("")
|
|
1441
|
+
out.append("CALIBRATION effective blended weighted units per weekly point")
|
|
1442
|
+
out.append(_evidence_line("fitted", analysis.fitted, _fmt_units))
|
|
1443
|
+
if result.recorded is not None:
|
|
1444
|
+
recorded_value = result.recorded.get("unitsPerPoint")
|
|
1445
|
+
stale = " (stale — fitted under different constants)" \
|
|
1446
|
+
if result.stale_prior else ""
|
|
1447
|
+
out.append(f" {'recorded prior':26} "
|
|
1448
|
+
f"{_fmt_units(recorded_value)}{stale}")
|
|
1449
|
+
out.append(" The fitted value is not a provider budget. It is one "
|
|
1450
|
+
"effective blended rate,")
|
|
1451
|
+
out.append(" valid for the observed model and token blend, and it is "
|
|
1452
|
+
"the provider's budget")
|
|
1453
|
+
out.append(" minus an unmeasured, workload-dependent amount.")
|
|
1454
|
+
out.append("")
|
|
1455
|
+
out.append("CURRENT WEEK")
|
|
1456
|
+
if result.week is None:
|
|
1457
|
+
out.append(" no current subscription week is retained")
|
|
1458
|
+
else:
|
|
1459
|
+
out.append(f" window "
|
|
1460
|
+
f"{_iso(result.week.units_start)} .. "
|
|
1461
|
+
f"{_iso(result.week.end)}")
|
|
1462
|
+
out.append(f" observed meter "
|
|
1463
|
+
f"{analysis.observed_percent if analysis.observed_percent is not None else '-'}%")
|
|
1464
|
+
out.append(_evidence_line("modelled consumption", analysis.consumption,
|
|
1465
|
+
_fmt_pct))
|
|
1466
|
+
out.append(_evidence_line("headroom to 100%", analysis.headroom,
|
|
1467
|
+
_fmt_pct))
|
|
1468
|
+
out.append(_evidence_line("projection to week end",
|
|
1469
|
+
analysis.projection, _fmt_pct))
|
|
1470
|
+
difference = analysis.diagnostics.get("observedMinusModelled")
|
|
1471
|
+
if difference is not None:
|
|
1472
|
+
out.append(f" {'observed minus modelled':26} "
|
|
1473
|
+
f"{difference:+.2f} points")
|
|
1474
|
+
out.append("")
|
|
1475
|
+
out.append("COMPOSITION SUPPORT radii are EFFECTIVE: "
|
|
1476
|
+
"max(observed spread, materiality floor)")
|
|
1477
|
+
out.append(f" family radius "
|
|
1478
|
+
f"{_fmt_radius(analysis.family_radius)}"
|
|
1479
|
+
f" (observed "
|
|
1480
|
+
f"{_fmt_radius(analysis.diagnostics.get('familyRadiusEmpirical'))})")
|
|
1481
|
+
out.append(f" token-class radius "
|
|
1482
|
+
f"{_fmt_radius(analysis.class_radius)}"
|
|
1483
|
+
f" (observed "
|
|
1484
|
+
f"{_fmt_radius(analysis.diagnostics.get('classRadiusEmpirical'))})")
|
|
1485
|
+
out.append(f" {qm.DEDICATED_POOL_SCOPE_NOTE}")
|
|
1486
|
+
out.append("")
|
|
1487
|
+
out.append("DETECTOR")
|
|
1488
|
+
out.append(f" split "
|
|
1489
|
+
f"{payload_detector['splitDate'] or 'none'}")
|
|
1490
|
+
out.append(f" eligible days scanned "
|
|
1491
|
+
f"{payload_detector['scannedEligibleDays']}"
|
|
1492
|
+
f" (Holm family {payload_detector['holmFamilySize']})")
|
|
1493
|
+
if payload_detector["historyTruncated"]:
|
|
1494
|
+
out.append(
|
|
1495
|
+
f" only the most recent {payload_detector['maxAutoScanDays']}"
|
|
1496
|
+
f" of {payload_detector['inputEligibleDays']} eligible days "
|
|
1497
|
+
f"were scanned;")
|
|
1498
|
+
out.append(
|
|
1499
|
+
f" the scan starts at "
|
|
1500
|
+
f"{payload_detector['scanStartDate']} and the rank-sum "
|
|
1501
|
+
f"population is that retained set.")
|
|
1502
|
+
if payload_detector["rawP"] is not None:
|
|
1503
|
+
out.append(f" p (raw / Holm) "
|
|
1504
|
+
f"{payload_detector['rawP']:.6f} / "
|
|
1505
|
+
f"{payload_detector['holmP']:.6f}")
|
|
1506
|
+
out.append(_evidence_line("baseline fit", analysis.baseline_fit,
|
|
1507
|
+
_fmt_units))
|
|
1508
|
+
out.append(_evidence_line("successor fit", analysis.watch_fit,
|
|
1509
|
+
_fmt_units))
|
|
1510
|
+
out.append("")
|
|
1511
|
+
out.append(f"VERDICT {analysis.verdict.value}"
|
|
1512
|
+
f" (status {analysis.status.value}, "
|
|
1513
|
+
f"exit {analysis.exit_code})")
|
|
1514
|
+
for reason in analysis.blocking:
|
|
1515
|
+
out.append(f" withheld because: {reason.value}")
|
|
1516
|
+
if analysis.verdict is Verdict.WITHHELD:
|
|
1517
|
+
# Spec section 9: a withheld verdict must name a rate change as
|
|
1518
|
+
# NOT RULED OUT rather than denied. "No rate change" is a finding;
|
|
1519
|
+
# this is the absence of one, and conflating them tells a user
|
|
1520
|
+
# their metering is unchanged when nothing was established.
|
|
1521
|
+
out.append(" This is not a finding of no rate change. The "
|
|
1522
|
+
"evidence needed to decide is")
|
|
1523
|
+
out.append(" missing, so no verdict is reported either way.")
|
|
1524
|
+
if result.mode_kind == "diagnostic":
|
|
1525
|
+
out.append(" diagnostic run — nothing was persisted")
|
|
1526
|
+
out.append("")
|
|
1527
|
+
return out
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def _fmt_radius(value) -> str:
|
|
1531
|
+
return "-" if value is None else f"{value:.4f}"
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
# ---------------------------------------------------------------------------
|
|
1535
|
+
# The command (spec sections 8, 17, 23, 24).
|
|
1536
|
+
# ---------------------------------------------------------------------------
|
|
1537
|
+
def _reset(args, keys, *, now) -> int:
|
|
1538
|
+
"""`--reset-calibration`, which is selected-account-only."""
|
|
1539
|
+
if getattr(args, "account", None) is None and accounts_are_decorated():
|
|
1540
|
+
eprint("quota: --reset-calibration needs --account on an install with "
|
|
1541
|
+
"more than one Claude account; refusing to clear every regime")
|
|
1542
|
+
for key in keys:
|
|
1543
|
+
eprint(f" {key} {account_display_label(key)}")
|
|
1544
|
+
return 2
|
|
1545
|
+
key = keys[0] if keys else None
|
|
1546
|
+
removed = reset_calibration(key, now=now)
|
|
1547
|
+
label = account_display_label(key) or "the merged view"
|
|
1548
|
+
if removed:
|
|
1549
|
+
print(f"quota: cleared the stored calibration for {label}")
|
|
1550
|
+
else:
|
|
1551
|
+
print(f"quota: nothing was stored for {label}")
|
|
1552
|
+
return 0
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
def cmd_quota(args) -> int:
|
|
1556
|
+
"""`cctally quota` — weekly quota consumption and metering-rate change.
|
|
1557
|
+
|
|
1558
|
+
Exit codes come from `QuotaAnalysis.exit_code`, never from `STATUS_EXIT`.
|
|
1559
|
+
Spec section 23 superseded section 19's rule that the status alone decides
|
|
1560
|
+
the verdict and the exit code: the status describes the current predictive
|
|
1561
|
+
calibration and the verdict describes the detector, so a confirmed change
|
|
1562
|
+
whose only shortfall is the successor regime's day count reports
|
|
1563
|
+
`insufficient-history` with `rate-change-detected` at exit 1 — which
|
|
1564
|
+
`STATUS_EXIT` maps to 4. Exit 2 is argument errors only.
|
|
1565
|
+
"""
|
|
1566
|
+
now = _cctally_core._command_as_of()
|
|
1567
|
+
try:
|
|
1568
|
+
since = parse_date_argument(getattr(args, "since", None), "--since")
|
|
1569
|
+
watch_from = parse_date_argument(
|
|
1570
|
+
getattr(args, "watch_from", None), "--watch-from")
|
|
1571
|
+
except ValueError as exc:
|
|
1572
|
+
eprint(f"quota: {exc}")
|
|
1573
|
+
return 2
|
|
1574
|
+
|
|
1575
|
+
keys, code = resolve_accounts(args)
|
|
1576
|
+
if code is not None:
|
|
1577
|
+
return code
|
|
1578
|
+
if getattr(args, "reset_calibration", False):
|
|
1579
|
+
return _reset(args, keys, now=now)
|
|
1580
|
+
|
|
1581
|
+
decorated = accounts_are_decorated()
|
|
1582
|
+
loaded = load_calibrations(now=now)
|
|
1583
|
+
results = []
|
|
1584
|
+
for account_key in keys or [None]:
|
|
1585
|
+
result = analyse_account(
|
|
1586
|
+
account_key, now=now, since=since, watch_from=watch_from,
|
|
1587
|
+
stored_state=loaded.state)
|
|
1588
|
+
# Persistence runs AFTER the stores are closed, because the
|
|
1589
|
+
# calibration lock is a leaf in the lock order and takes nothing else.
|
|
1590
|
+
mode = PersistMode(
|
|
1591
|
+
kind=result.mode_kind, account_key=account_key, now=now,
|
|
1592
|
+
fingerprint=qm.QUOTA_MODEL_CONSTANTS_FINGERPRINT,
|
|
1593
|
+
regime_start=result.load.analysis_start)
|
|
1594
|
+
# §6.5 step 1-2: the descriptor is decided under the calibration
|
|
1595
|
+
# file's leaf lock, and that lock is released before any stats lock is
|
|
1596
|
+
# taken — `persist_and_detect` returns from its `with` block first.
|
|
1597
|
+
quarantined, transitions = persist_and_detect(result.clean, mode)
|
|
1598
|
+
quarantined = quarantined or loaded.quarantined
|
|
1599
|
+
results.append(dataclasses.replace(result, quarantined=quarantined))
|
|
1600
|
+
for transition in transitions:
|
|
1601
|
+
# §6.5 steps 3-6: through the sole stats writer, so the append,
|
|
1602
|
+
# the row and the cursor commit together and the notification
|
|
1603
|
+
# dispatches only after that commit. One write can create more
|
|
1604
|
+
# than one adjacent pair, and each is recorded: the row's UNIQUE
|
|
1605
|
+
# key dedups, so a pair a prior run already recorded folds to a
|
|
1606
|
+
# no-op rather than a second alert.
|
|
1607
|
+
record_rate_change_transition(transition, now=now)
|
|
1608
|
+
|
|
1609
|
+
if getattr(args, "json", False):
|
|
1610
|
+
print(json.dumps(build_payload(results, now=now, decorated=decorated),
|
|
1611
|
+
indent=2))
|
|
1612
|
+
else:
|
|
1613
|
+
for line in render_text(results, now=now, decorated=decorated):
|
|
1614
|
+
print(line)
|
|
1615
|
+
worst = max(results, key=lambda r: EXIT_SEVERITY[r.analysis.exit_code])
|
|
1616
|
+
return worst.analysis.exit_code
|