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,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