cctally 1.113.0 → 1.115.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 (97) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +32 -8
  4. package/bin/_cctally_cache.py +6035 -722
  5. package/bin/_cctally_completion.py +1287 -0
  6. package/bin/_cctally_config.py +37 -0
  7. package/bin/_cctally_core.py +400 -2
  8. package/bin/_cctally_dashboard.py +98 -20
  9. package/bin/_cctally_dashboard_conversation.py +292 -23
  10. package/bin/_cctally_dashboard_envelope.py +16 -3
  11. package/bin/_cctally_dashboard_perf.py +128 -9
  12. package/bin/_cctally_dashboard_sources.py +285 -213
  13. package/bin/_cctally_db.py +1177 -23
  14. package/bin/_cctally_diagnosis_sources.py +22 -4
  15. package/bin/_cctally_doctor.py +572 -45
  16. package/bin/_cctally_five_hour.py +84 -33
  17. package/bin/_cctally_forecast.py +108 -15
  18. package/bin/_cctally_journal.py +3950 -116
  19. package/bin/_cctally_journal_repair.py +101 -12
  20. package/bin/_cctally_milestone_history.py +82 -9
  21. package/bin/_cctally_parser.py +42 -0
  22. package/bin/_cctally_percent_breakdown.py +3 -2
  23. package/bin/_cctally_project.py +8 -2
  24. package/bin/_cctally_quarantine.py +652 -0
  25. package/bin/_cctally_quota.py +227 -37
  26. package/bin/_cctally_quota_model.py +360 -83
  27. package/bin/_cctally_record.py +691 -149
  28. package/bin/_cctally_rederive.py +320 -41
  29. package/bin/_cctally_refresh.py +84 -13
  30. package/bin/_cctally_source_analytics.py +83 -17
  31. package/bin/_cctally_statusline.py +6154 -517
  32. package/bin/_cctally_store.py +367 -53
  33. package/bin/_cctally_transcript.py +8 -7
  34. package/bin/_cctally_tui.py +61 -56
  35. package/bin/_cctally_weekrefs.py +128 -21
  36. package/bin/_lib_account_binding.py +152 -0
  37. package/bin/_lib_account_protocol.py +386 -0
  38. package/bin/_lib_aggregators.py +4 -0
  39. package/bin/_lib_cache_coverage.py +13 -1
  40. package/bin/_lib_cache_report.py +35 -6
  41. package/bin/_lib_cache_writer_lock.py +19 -3
  42. package/bin/_lib_clone_copy.py +142 -0
  43. package/bin/_lib_codex_conversation.py +32 -0
  44. package/bin/_lib_codex_conversation_query.py +297 -49
  45. package/bin/_lib_codex_conversation_watch.py +59 -14
  46. package/bin/_lib_codex_drain_admission.py +214 -0
  47. package/bin/_lib_codex_metadata.py +18 -1
  48. package/bin/_lib_codex_publication_barrier.py +277 -0
  49. package/bin/_lib_codex_rollout_container.py +502 -0
  50. package/bin/_lib_conversation_dispatch.py +3 -0
  51. package/bin/_lib_conversation_query.py +106 -59
  52. package/bin/_lib_conversation_retention.py +1605 -640
  53. package/bin/_lib_cost_provenance.py +53 -2
  54. package/bin/_lib_detector_access_registry.py +3817 -0
  55. package/bin/_lib_doctor.py +714 -69
  56. package/bin/_lib_five_hour.py +126 -0
  57. package/bin/_lib_fmt.py +11 -0
  58. package/bin/_lib_ingest_frontier.py +414 -20
  59. package/bin/_lib_journal.py +141 -10
  60. package/bin/_lib_jsonl.py +5 -4
  61. package/bin/_lib_legacy_spool.py +126 -0
  62. package/bin/_lib_legacy_writer_evidence.py +136 -0
  63. package/bin/_lib_merge_gate.py +231 -86
  64. package/bin/_lib_meter_rate_change.py +10 -0
  65. package/bin/_lib_outcome_quarantine.py +690 -0
  66. package/bin/_lib_perf.py +27 -1
  67. package/bin/_lib_pricing.py +199 -9
  68. package/bin/_lib_pricing_check.py +20 -4
  69. package/bin/_lib_quarantine.py +154 -0
  70. package/bin/_lib_quota_calibration.py +53 -8
  71. package/bin/_lib_quota_copy.py +2 -1
  72. package/bin/_lib_quota_model.py +288 -46
  73. package/bin/_lib_reclaim_planner.py +792 -0
  74. package/bin/_lib_rederive.py +319 -82
  75. package/bin/_lib_segment_summary.py +35 -2
  76. package/bin/_lib_snapshot_cache.py +21 -0
  77. package/bin/_lib_sqlite_close.py +81 -0
  78. package/bin/_lib_statusline_candidates.py +292 -23
  79. package/bin/_lib_subscription_weeks.py +100 -17
  80. package/bin/_lib_tick_stats.py +217 -17
  81. package/bin/_lib_wal_checkpoint.py +1077 -0
  82. package/bin/_lib_write_budget.py +483 -0
  83. package/bin/_lib_write_io.py +703 -0
  84. package/bin/cctally +31 -1
  85. package/dashboard/static/assets/{ConversationsView-CEZ-BUyi.js → ConversationsView-Bc8LCOAZ.js} +1 -1
  86. package/dashboard/static/assets/{DoctorModal-CE1oH_Zy.js → DoctorModal-D--qVBwe.js} +1 -1
  87. package/dashboard/static/assets/ModalRoot-DLPgyAM_.js +1 -0
  88. package/dashboard/static/assets/{ProjectsDrillPanel-B3DZoCeD.js → ProjectsDrillPanel-z53a4v4M.js} +1 -1
  89. package/dashboard/static/assets/{SourceDetailModal-CHp9EgLP.js → SourceDetailModal-Bw0T8W-W.js} +1 -1
  90. package/dashboard/static/assets/{UpdateModal-5iKST6jo.js → UpdateModal-D5AKx4Yw.js} +1 -1
  91. package/dashboard/static/assets/index-BKITk1CT.css +1 -0
  92. package/dashboard/static/assets/{index-Cm0VTxJC.js → index-BXDOzSng.js} +3 -3
  93. package/dashboard/static/assets/{outlineNavigation-DkXGL8J6.js → outlineNavigation-JObY7cUY.js} +1 -1
  94. package/dashboard/static/dashboard.html +2 -2
  95. package/package.json +19 -1
  96. package/dashboard/static/assets/ModalRoot-D5Y8IFUG.js +0 -1
  97. package/dashboard/static/assets/index-Dt4tqf43.css +0 -1
@@ -18,12 +18,14 @@ import contextlib
18
18
  import dataclasses
19
19
  import datetime as dt
20
20
  import fcntl
21
+ import hashlib
21
22
  import json
22
23
  import os
23
24
  import sqlite3
24
25
  import sys
25
26
 
26
27
  import _cctally_core
28
+ import _cctally_store
27
29
  import _lib_quota_model as qm
28
30
  from _lib_quota_model import ( # re-exported for callers and tests
29
31
  BlockingReason, CalibrationEvidence, CalibrationStatus, Verdict,
@@ -56,36 +58,59 @@ def eprint(*args, **kwargs):
56
58
  # Part III section 28 require: the kernel has no producer for that status and
57
59
  # receives it through `analyse(extra_statuses=...)`.
58
60
  # ---------------------------------------------------------------------------
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.fromisoformat(
62
- qm.COEFFICIENT_SUPPORT["supportedCompositionFrom"])
63
-
64
- #: Provenance, so a later reader can re-test the boundary rather than inherit
65
- #: it. Each entry states the era, its state and where the state came from.
66
- COEFFICIENT_ERAS: tuple = (
67
- {
68
- "from": None,
69
- "until": SUPPORTED_COMPOSITION_FROM.isoformat(),
70
- "state": "unvalidated",
71
- "provenance": (
72
- "docs/quota-model.md: the Opus 4.8 era fits at about 1,989,101 "
73
- "units per point against 2.4M for Opus 5, a budget difference the "
74
- "shared token-class coefficients do not absorb. The shipped "
75
- "estimator gives 1,265,184 to 1,479,027 over the same weeks, and "
76
- "spec section 6 declines to adjudicate between the two."
77
- ),
78
- },
79
- {
80
- "from": SUPPORTED_COMPOSITION_FROM.isoformat(),
81
- "until": None,
82
- "state": "validated",
83
- "provenance": (
84
- "docs/quota-model.md: 2026-07-25 is the Opus 5 changeover, the "
85
- "epoch the token-class weights were fitted on."
86
- ),
87
- },
88
- )
61
+ #: First UTC date of the earliest validated coefficient era. Every
62
+ #: observation before it is covered by no era and is never fitted; the
63
+ #: analysis itself starts at the start of the era the clock selects (#978).
64
+ SUPPORTED_COMPOSITION_FROM: dt.date = qm.COEFFICIENT_ERAS[0].start
65
+
66
+ #: The history before every validated era, published first in
67
+ #: `method.coefficientEras` so a later reader can re-test the boundary rather
68
+ #: than inherit it. It carries `id: None` and no weights, because no
69
+ #: coefficient set is validated for it.
70
+ UNVALIDATED_ERA_RECORD: dict = {
71
+ "id": None,
72
+ "from": None,
73
+ "until": SUPPORTED_COMPOSITION_FROM.isoformat(),
74
+ "state": "unvalidated",
75
+ "provenance": (
76
+ "docs/quota-model.md: the Opus 4.8 era fits at about 1,989,101 "
77
+ "units per point against 2.4M for Opus 5, a budget difference the "
78
+ "shared token-class coefficients do not absorb. The shipped "
79
+ "estimator gives 1,265,184 to 1,479,027 over the same weeks, and "
80
+ "spec section 6 declines to adjudicate between the two."
81
+ ),
82
+ }
83
+
84
+
85
+ def published_coefficient_eras() -> list:
86
+ """The complete era catalogue in order, as `method.coefficientEras`.
87
+
88
+ The unvalidated record first, then each kernel era with its interval,
89
+ provenance and token-class weights. Built from `qm.COEFFICIENT_ERAS` on
90
+ every call, so the published table can never drift from the one the
91
+ kernel weights with.
92
+ """
93
+ out = [dict(UNVALIDATED_ERA_RECORD)]
94
+ for era in qm.COEFFICIENT_ERAS:
95
+ out.append({
96
+ "id": era.id,
97
+ "from": era.start.isoformat(),
98
+ "until": None if era.end is None else era.end.isoformat(),
99
+ "state": "validated",
100
+ "provenance": era.provenance,
101
+ "tokenClassWeights": era.weight_map(),
102
+ })
103
+ return out
104
+
105
+
106
+ def select_era(now: "dt.datetime") -> "qm.CoefficientEra | None":
107
+ """The one era an analysis at `now` is fitted in (#978 spec 4.3).
108
+
109
+ The clock decides, never the data: the real clock, or `CCTALLY_AS_OF` in
110
+ harnesses. None before 2026-07-25, where no era is validated.
111
+ """
112
+ return qm.era_for_instant(now)
113
+
89
114
 
90
115
  #: Snapshot sources that are synthetic by construction and are never read as
91
116
  #: observations (spec section 4). A closed committed set: `record-credit`
@@ -229,6 +254,23 @@ def _probe_bundle(stats_conn, cache_conn) -> tuple:
229
254
  )
230
255
 
231
256
 
257
+ #: How far below the analysis start the SQL text prefilter reaches. Stored
258
+ #: timestamps may carry a non-UTC offset, and text comparison then orders
259
+ #: them by local wall time: `2026-09-22T03:00:00+05:00` sorts at or after
260
+ #: `2026-09-22T00:00:00+00:00` while naming an instant before it, and
261
+ #: `2026-09-21T22:30:00-05:00` sorts before it while naming an instant after
262
+ #: it. Any real offset is under a day, so a one-day margin keeps every
263
+ #: in-range row, and the exact bound is then applied to the parsed instant.
264
+ #: The kernel refuses an out-of-era instant outright (#978 spec 4.2,
265
+ #: invariant 5), so the bound has to be exact rather than textual.
266
+ _TEXT_PREFILTER_MARGIN = dt.timedelta(days=1)
267
+
268
+
269
+ def _text_prefilter_floor(start) -> str:
270
+ """The coarse SQL lower bound for an exact instant bound `start`."""
271
+ return (start - _TEXT_PREFILTER_MARGIN).isoformat()
272
+
273
+
232
274
  def _read_stats_component(conn, account_key, start):
233
275
  """Meter readings and authoritative credit instants for one account.
234
276
 
@@ -250,7 +292,7 @@ def _read_stats_component(conn, account_key, start):
250
292
  params: list = list(SYNTHETIC_SNAPSHOT_SOURCES)
251
293
  if start is not None:
252
294
  sql += " AND captured_at_utc >= ?"
253
- params.append(start.isoformat())
295
+ params.append(_text_prefilter_floor(start))
254
296
  clause, extra = _account_clause("account_key", account_key)
255
297
  sql += clause
256
298
  params.extend(extra)
@@ -263,6 +305,8 @@ def _read_stats_component(conn, account_key, start):
263
305
  at = parse_instant(captured, "captured_at_utc")
264
306
  if at is None:
265
307
  continue
308
+ if start is not None and at < start:
309
+ continue
266
310
  anchor = parse_instant(week_at, "week_start_at")
267
311
  if anchor is None:
268
312
  # A pre-`week_start_at` row. Its date-only boundary is read as
@@ -301,9 +345,13 @@ def _read_stats_component(conn, account_key, start):
301
345
  return snapshots, credits, diagnostics
302
346
 
303
347
 
304
- def _read_cache_component(conn, account_key, start):
348
+ def _read_cache_component(conn, account_key, start, end=None):
305
349
  """Priced requests for one account, plus the store-wide ingest tail.
306
350
 
351
+ Rows are bounded to `[start, end)` on the parsed instant before the
352
+ fast-mode count, so `fastModeEntriesExcluded` describes the analysed
353
+ window; `end` is a closed era's exclusive end, or None for an open one.
354
+
307
355
  `newest_entry_at` is deliberately NOT account-scoped. It answers "has the
308
356
  ingest finished covering this day", which is a property of the store; an
309
357
  account-scoped maximum would report every day of a dormant account as
@@ -318,7 +366,7 @@ def _read_cache_component(conn, account_key, start):
318
366
  params: list = []
319
367
  if start is not None:
320
368
  sql += " AND timestamp_utc >= ?"
321
- params.append(start.isoformat())
369
+ params.append(_text_prefilter_floor(start))
322
370
  clause, extra = _account_clause("account_key", account_key)
323
371
  sql += clause
324
372
  params.extend(extra)
@@ -330,6 +378,10 @@ def _read_cache_component(conn, account_key, start):
330
378
  at = parse_instant(row[0], "timestamp_utc")
331
379
  if at is None:
332
380
  continue
381
+ if start is not None and at < start:
382
+ continue
383
+ if end is not None and at >= end:
384
+ continue
333
385
  if row[7] == "fast":
334
386
  fast_excluded += 1
335
387
  continue
@@ -369,26 +421,74 @@ def _retained_snapshot_span(conn, account_key):
369
421
  return parse_instant(row[0]), parse_instant(row[1])
370
422
 
371
423
 
372
- def era_floor_instant() -> "dt.datetime":
373
- """The first instant the shipped coefficients are validated for."""
374
- return dt.datetime.combine(
375
- SUPPORTED_COMPOSITION_FROM, dt.time(0, 0), tzinfo=UTC)
424
+ def era_floor_instant(era) -> "dt.datetime":
425
+ """The first instant `era`'s coefficients are validated for."""
426
+ return qm.era_bounds(era)[0]
376
427
 
377
428
 
378
- def resolve_analysis_start(since):
379
- """`max(--since, the era floor)`.
429
+ def resolve_analysis_start(since, era):
430
+ """`max(--since, the selected era's start)`.
380
431
 
381
- A `--since` earlier than the floor does not widen the window: section 6
432
+ A `--since` earlier than the era does not widen the window: section 6
382
433
  says an era whose composition the coefficients are not validated for is
383
- not fitted, and a flag cannot licence fitting it.
434
+ not fitted, and #978 spec 4.3 says neither `--since` nor `--watch-from`
435
+ reopens a preceding era, so a flag cannot licence fitting one.
384
436
  """
385
- floor = era_floor_instant()
437
+ floor = era_floor_instant(era)
386
438
  if since is None:
387
439
  return floor
388
440
  return max(since, floor)
389
441
 
390
442
 
391
- def load_population(account_key, *, since, now) -> LoadResult:
443
+ def week_spans_era(week: "CurrentWeek", era) -> bool:
444
+ """Is the subscription week not wholly inside `era`?
445
+
446
+ Either the week starts before the era, or, for a closed era a historical
447
+ clock selected, it ends after the era's exclusive end. Such a week's
448
+ units would come from two eras, so its modelled figures are withheld
449
+ (#978 spec 4.3).
450
+ """
451
+ start, end = qm.era_bounds(era)
452
+ return week.anchor < start or (end is not None and week.end > end)
453
+
454
+
455
+ def current_week_units(population, era) -> float:
456
+ """Summed weighted units of the current week's general-meter entries.
457
+
458
+ A dedicated-pool family contributes nothing, and an entry whose
459
+ cache-write split is unknown is skipped, as `population_units` does. An
460
+ entry `era` does not cover raises `CoefficientEraMismatch` instead of
461
+ being skipped (#978 spec 4.2, invariant 5): the week's units are never
462
+ silently clipped to the part one era covers.
463
+ """
464
+ total = 0.0
465
+ for entry in population:
466
+ if qm.family_participation(
467
+ qm.normalize_family(entry.model)) != "general":
468
+ continue
469
+ weighed = qm.weigh_entry(entry, era)
470
+ if weighed.cause == qm.ERA_UNAVAILABLE:
471
+ raise qm.CoefficientEraMismatch(
472
+ f"entry.at {entry.at.isoformat()} lies outside coefficient "
473
+ f"era {era.id!r}")
474
+ if weighed.units is None:
475
+ continue
476
+ total += weighed.units
477
+ return total
478
+
479
+
480
+ def _confine(rows, end):
481
+ """The rows strictly before a closed era's exclusive `end`.
482
+
483
+ The lower bound is already applied at the query; an open era (`end`
484
+ None) is bounded by the clock exactly as before #978.
485
+ """
486
+ if end is None:
487
+ return list(rows)
488
+ return [row for row in rows if row.at < end]
489
+
490
+
491
+ def load_population(account_key, *, since, now, era) -> LoadResult:
392
492
  """Read both stores coherently and return one account's population.
393
493
 
394
494
  The stores are opened through the guarded helpers, so `CCTALLY_DATA_DIR`,
@@ -396,9 +496,21 @@ def load_population(account_key, *, since, now) -> LoadResult:
396
496
  all apply. Every failure is caught and reported as a typed `unavailable`
397
497
  result; an uncaught `OperationalError` from a store read is never allowed
398
498
  to reach the user.
499
+
500
+ `era` is the era `select_era(now)` chose. Snapshots, credits and entries
501
+ are confined to `[analysis start, era end)` before anything downstream
502
+ segments, fences or fits them (#978 spec 4.3). A None era — a clock
503
+ before every validated era — reads nothing and returns the
504
+ `unvalidated-coefficient-era` status, because no coefficient set could
505
+ weigh what it would read.
399
506
  """
400
507
  qm.require_aware(now, "now")
401
- start = resolve_analysis_start(since)
508
+ if era is None:
509
+ return LoadResult(
510
+ status=CalibrationStatus.UNVALIDATED_COEFFICIENT_ERA,
511
+ cause="history-predates-supported-composition")
512
+ start = resolve_analysis_start(since, era)
513
+ era_end = qm.era_bounds(era)[1]
402
514
  stats_conn = None
403
515
  cache_conn = None
404
516
  try:
@@ -419,7 +531,7 @@ def load_population(account_key, *, since, now) -> LoadResult:
419
531
  snapshots, credits, diagnostics = _read_stats_component(
420
532
  stats_conn, account_key, start)
421
533
  entries, newest, fast_excluded = _read_cache_component(
422
- cache_conn, account_key, start)
534
+ cache_conn, account_key, start, era_end)
423
535
  except sqlite3.Error:
424
536
  return LoadResult(analysis_start=start,
425
537
  status=CalibrationStatus.UNAVAILABLE,
@@ -430,6 +542,18 @@ def load_population(account_key, *, since, now) -> LoadResult:
430
542
  cause="malformed-retained-instant")
431
543
  after = _probe_bundle(stats_conn, cache_conn)
432
544
  if before == after:
545
+ # #825/#832 spec 6: the stats half of a population this
546
+ # external plan carries into ingest must come from the current
547
+ # interpretation. The coherence bracket proves the reads saw
548
+ # one unchanged store (a republication moves the probe), so the
549
+ # identity of that store is checked at the bracket's edge.
550
+ try:
551
+ with _cctally_store.current_stats_snapshot(stats_conn):
552
+ pass
553
+ except _cctally_store.StatsIndexNotCurrent:
554
+ return LoadResult(analysis_start=start,
555
+ status=CalibrationStatus.UNAVAILABLE,
556
+ cause="index-not-current")
433
557
  payload = (earliest, snapshots, credits, diagnostics,
434
558
  entries, newest, fast_excluded)
435
559
  break
@@ -451,6 +575,9 @@ def load_population(account_key, *, since, now) -> LoadResult:
451
575
 
452
576
  earliest, snapshots, credits, diagnostics, entries, newest, \
453
577
  fast_excluded = payload
578
+ snapshots = _confine(snapshots, era_end)
579
+ credits = _confine(credits, era_end)
580
+ entries = _confine(entries, era_end)
454
581
  diagnostics = dict(diagnostics)
455
582
  diagnostics["analysisStart"] = start.isoformat()
456
583
  diagnostics["fastModeEntriesExcluded"] = fast_excluded
@@ -993,6 +1120,10 @@ class PersistMode:
993
1120
  now: "dt.datetime"
994
1121
  fingerprint: str
995
1122
  regime_start: "dt.datetime"
1123
+ #: The id of the coefficient era the clock selected (#978), written on
1124
+ #: every regime. None when the clock precedes every validated era, which
1125
+ #: makes the run persist nothing.
1126
+ coefficient_era: "str | None"
996
1127
 
997
1128
 
998
1129
  def _interval_json(interval):
@@ -1008,6 +1139,7 @@ def _regime_from(evidence, *, effective_from, mode, status):
1008
1139
  "effectiveUntil": None,
1009
1140
  "fingerprint": mode.fingerprint,
1010
1141
  "algorithmRevision": qm.QUOTA_MODEL_ALGORITHM_REVISION,
1142
+ "coefficientEra": mode.coefficient_era,
1011
1143
  "unitsPerPoint": evidence.value,
1012
1144
  "interval": _interval_json(evidence.interval),
1013
1145
  "support": ({"days": evidence.support.days,
@@ -1070,6 +1202,87 @@ def stored_regimes(state: dict, account_key) -> list:
1070
1202
  return [dict(r) for r in regimes if isinstance(r, dict)]
1071
1203
 
1072
1204
 
1205
+ # #825/#832 spec 5.4 "Calibration sidecar". Recovery (#689) journals a
1206
+ # transition an earlier run persisted and failed to record. A transition a
1207
+ # previous-release process persisted must never be republished as a
1208
+ # current-stamped `meter_rate_change`, so every regime THIS binary writes
1209
+ # carries the writer generation plus a digest over its own content: a legacy
1210
+ # process's in-place `update()` of a stamped regime keeps the stamp keys but
1211
+ # breaks the digest, so it reads as legacy too.
1212
+ _REGIME_WRITER_KEY = "wpg"
1213
+ _REGIME_WRITER_DIGEST_KEY = "wpgDigest"
1214
+
1215
+
1216
+ def _regime_content_digest(regime: dict) -> str:
1217
+ core = {k: v for k, v in regime.items()
1218
+ if k not in (_REGIME_WRITER_KEY, _REGIME_WRITER_DIGEST_KEY)}
1219
+ data = json.dumps(core, sort_keys=True, separators=(",", ":"),
1220
+ ensure_ascii=False, default=str)
1221
+ return "sha256:" + hashlib.sha256(data.encode("utf-8")).hexdigest()
1222
+
1223
+
1224
+ def stamp_regime(regime: dict) -> dict:
1225
+ """``regime`` stamped as written by the current protocol generation."""
1226
+ import _lib_account_protocol
1227
+ out = {k: v for k, v in regime.items()
1228
+ if k not in (_REGIME_WRITER_KEY, _REGIME_WRITER_DIGEST_KEY)}
1229
+ out[_REGIME_WRITER_KEY] = _lib_account_protocol.PROTOCOL_GENERATION
1230
+ out[_REGIME_WRITER_DIGEST_KEY] = _regime_content_digest(out)
1231
+ return out
1232
+
1233
+
1234
+ def regime_writer_is_current(regime) -> bool:
1235
+ import _lib_account_protocol
1236
+ return (isinstance(regime, dict)
1237
+ and regime.get(_REGIME_WRITER_KEY)
1238
+ == _lib_account_protocol.PROTOCOL_GENERATION
1239
+ and regime.get(_REGIME_WRITER_DIGEST_KEY)
1240
+ == _regime_content_digest(regime))
1241
+
1242
+
1243
+ def stamp_current_writes(stored: dict, reduced: dict, account_key) -> dict:
1244
+ """``reduced`` with every regime this run created or changed stamped.
1245
+
1246
+ A regime identical to the stored one at its position is left exactly as
1247
+ it was — a legacy regime this run did not touch is never laundered into a
1248
+ current one."""
1249
+ before = stored_regimes(stored, account_key)
1250
+ after = stored_regimes(reduced, account_key)
1251
+ regimes = []
1252
+ for index, regime in enumerate(after):
1253
+ if index < len(before) and regime == before[index]:
1254
+ regimes.append(regime)
1255
+ else:
1256
+ regimes.append(stamp_regime(regime))
1257
+ out = dict(reduced)
1258
+ accounts = dict(out.get("accounts") or {})
1259
+ accounts[_state_key(account_key)] = {
1260
+ **(accounts.get(_state_key(account_key)) or {}), "regimes": regimes}
1261
+ out["accounts"] = accounts
1262
+ return out
1263
+
1264
+
1265
+ def recovery_candidates(state: dict, account_key, *, detected_at) -> tuple:
1266
+ """The persisted transitions #689 recovery may journal: every qualified
1267
+ pair whose SUCCESSOR — the regime that constitutes the transition — carries
1268
+ a verifying current-writer stamp. A transition a previous-release process
1269
+ persisted (or edited in place) is withheld. The predecessor is ordinary
1270
+ history: every regime from before the upgrade is legacy-written, and the
1271
+ first current change after it must still be recoverable."""
1272
+ mrc = _cctally()._load_sibling("_lib_meter_rate_change")
1273
+ regimes = stored_regimes(state, account_key)
1274
+ current_starts = set()
1275
+ for start, (_predecessor, successor) in mrc._adjacent_pairs(regimes).items():
1276
+ if regime_writer_is_current(successor):
1277
+ current_starts.add(start)
1278
+ return tuple(
1279
+ t for t in mrc.enumerate_transitions(
1280
+ regimes, provider="claude",
1281
+ account_key=_alert_account_key(account_key),
1282
+ detected_at=detected_at)
1283
+ if mrc._instant(t.effective_from) in current_starts)
1284
+
1285
+
1073
1286
  def recorded_regime(state: dict, account_key) -> "dict | None":
1074
1287
  """The open regime a run would present as the durable prior, or None."""
1075
1288
  return _open_regime(stored_regimes(state, account_key))
@@ -1183,9 +1396,11 @@ def reduce_state(stored: dict, analysis, mode: PersistMode) -> dict:
1183
1396
  split persists BOTH sides, so the baseline is created if it is absent.
1184
1397
  * A trustworthy fit with no detected change updates the open regime in
1185
1398
  place, or opens the first one.
1186
- * A stored regime under a different fingerprint is marked `stale` and a
1187
- new regime is APPENDED; its own value is never rewritten, because that
1188
- value was true under its own constants.
1399
+ * A stored regime under a different fingerprint, or a different
1400
+ coefficient era (#978), is marked `stale` and a new regime is APPENDED;
1401
+ its own value is never rewritten, because that value was true under its
1402
+ own constants. A run whose clock selects an earlier era than the open
1403
+ regime's writes nothing.
1189
1404
  * A withheld analysis that `qm.transition_persistence_permitted` admits
1190
1405
  persists both sides of the split exactly as a confirmed change does,
1191
1406
  with the successor stamped under the withholding status (#688). This
@@ -1211,14 +1426,29 @@ def reduce_state(stored: dict, analysis, mode: PersistMode) -> dict:
1211
1426
  successor_ok = successor_ok or permitted
1212
1427
  if not fitted_ok and not successor_ok:
1213
1428
  return stored
1429
+ mode_era = qm.era_by_id(mode.coefficient_era)
1430
+ if mode_era is None:
1431
+ # No validated era: nothing this run fitted could name one.
1432
+ return stored
1214
1433
 
1215
1434
  open_regime = _open_regime(regimes)
1216
- # A stored regime fitted under different constants is marked stale and
1217
- # left otherwise untouched. It is marked HERE, together with the append
1218
- # below, because section 20 also says a run that writes nothing leaves the
1219
- # stored state alone — and a run reaching this point is writing.
1220
- if open_regime is not None and open_regime.get("fingerprint") != \
1221
- mode.fingerprint:
1435
+ # #978 spec 4.5: a run whose clock selects an EARLIER era than the open
1436
+ # regime's — a `CCTALLY_AS_OF` inside the Opus 5 era against a stored
1437
+ # Opus 5.5 calibration — never replaces the present with the past.
1438
+ open_era = (qm.era_by_id(open_regime.get("coefficientEra"))
1439
+ if open_regime is not None else None)
1440
+ if open_era is not None and open_era.start > mode_era.start:
1441
+ return stored
1442
+ # A stored regime fitted under different constants, or in a different
1443
+ # coefficient era (#978), is marked stale and left otherwise untouched.
1444
+ # It is marked HERE, together with the append below, because section 20
1445
+ # also says a run that writes nothing leaves the stored state alone — and
1446
+ # a run reaching this point is writing. The era comparison uses `.get()`,
1447
+ # so an old-build regime with no era rolls over here too (its fingerprint
1448
+ # already differs).
1449
+ if open_regime is not None and (
1450
+ open_regime.get("fingerprint") != mode.fingerprint
1451
+ or open_regime.get("coefficientEra") != mode.coefficient_era):
1222
1452
  open_regime["status"] = CalibrationStatus.STALE.value
1223
1453
  open_regime["effectiveUntil"] = mode.now.astimezone(UTC).isoformat()
1224
1454
  open_regime = None
@@ -1360,6 +1590,8 @@ def persist_and_detect(analysis, mode: PersistMode) -> tuple:
1360
1590
  detected_at = mode.now.astimezone(UTC).isoformat()
1361
1591
  transitions: tuple = ()
1362
1592
  if reduced is not loaded.state:
1593
+ reduced = stamp_current_writes(
1594
+ loaded.state, reduced, mode.account_key)
1363
1595
  save_calibrations(reduced)
1364
1596
  # #688: `detect_transitions` compares two stored states and does
1365
1597
  # not know WHY this run was admitted, so the disclosure is
@@ -1393,10 +1625,8 @@ def persist_and_detect(analysis, mode: PersistMode) -> tuple:
1393
1625
  # later run finds the state unchanged and takes neither branch above.
1394
1626
  # Recovery therefore cannot be derived from freshness; it comes from
1395
1627
  # what is persisted right now.
1396
- candidates = mrc.enumerate_transitions(
1397
- stored_regimes(reduced, mode.account_key),
1398
- provider="claude", account_key=account_key,
1399
- detected_at=detected_at)
1628
+ candidates = recovery_candidates(
1629
+ reduced, mode.account_key, detected_at=detected_at)
1400
1630
  return loaded.quarantined, transitions, candidates
1401
1631
 
1402
1632
 
@@ -1760,17 +1990,45 @@ class AccountResult:
1760
1990
  #: being accepted; `analysis` is what the user is shown.
1761
1991
  clean: object = None
1762
1992
  quarantined: "str | None" = None
1993
+ #: The coefficient era the clock selected, or None before every
1994
+ #: validated era (#978). The method metadata names it.
1995
+ era: "qm.CoefficientEra | None" = None
1996
+
1997
+
1998
+ def recorded_prior_is_stale(recorded, era) -> bool:
1999
+ """Is the recorded calibration from other constants or another era?
2000
+
2001
+ Spec 4.3's status table: either one is the `stale` overlay, as a
2002
+ fingerprint mismatch already was. A run with no selected era compares
2003
+ against no era id, so any recorded regime is stale to it.
2004
+ """
2005
+ if recorded is None:
2006
+ return False
2007
+ if recorded.get("fingerprint") != qm.QUOTA_MODEL_CONSTANTS_FINGERPRINT:
2008
+ return True
2009
+ return recorded.get("coefficientEra") != (
2010
+ None if era is None else era.id)
1763
2011
 
1764
2012
 
1765
2013
  def analyse_account(account_key, *, now, since, watch_from,
1766
2014
  stored_state) -> AccountResult:
1767
2015
  """Read one account's population and run the kernel over it."""
1768
- load = load_population(account_key, since=since, now=now)
2016
+ # #978 spec 4.3: ONE era per analysis, chosen once from the clock. The
2017
+ # population is confined to it, so every kernel call below sees only
2018
+ # in-era instants and weighs them with this era's coefficients.
2019
+ era = select_era(now)
2020
+ load = load_population(account_key, since=since, now=now, era=era)
1769
2021
  extra = [load.status] if load.status is not None else []
2022
+ # A clock before every validated era read nothing, so the population is
2023
+ # empty and no weight is ever applied. The kernel still requires an era
2024
+ # to type its arguments; the earliest one is an inert placeholder here.
2025
+ kernel_era = era if era is not None else qm.COEFFICIENT_ERAS[0]
1770
2026
  segments = qm.build_segments(load.snapshots, load.credits)
1771
2027
  series = qm.build_daily_series(
1772
- segments, load.entries, now=now, newest_entry_at=load.newest_entry_at)
2028
+ segments, load.entries, now=now, newest_entry_at=load.newest_entry_at,
2029
+ era=kernel_era)
1773
2030
  week = current_week_window(segments, now=now)
2031
+ week_withheld = None
1774
2032
  if week is None:
1775
2033
  forecast_population: tuple = ()
1776
2034
  week_units = None
@@ -1779,24 +2037,22 @@ def analyse_account(account_key, *, now, since, watch_from,
1779
2037
  else:
1780
2038
  horizon = min(now, week.end)
1781
2039
  forecast_population = tuple(
1782
- e for e in load.entries if week.units_start <= e.at < horizon)
1783
- week_units = 0.0
1784
- for entry in forecast_population:
1785
- units = qm.weighted_units(entry)
1786
- if units is None:
1787
- continue
1788
- if qm.family_participation(
1789
- qm.normalize_family(entry.model)) != "general":
1790
- continue
1791
- week_units += units
2040
+ e for e in load.entries if week.units_start <= e.at < horizon
2041
+ and qm.era_contains(kernel_era, e.at))
1792
2042
  observed = week.observed_percent
1793
2043
  week_start, week_end = week.units_start, week.end
2044
+ if week_spans_era(week, kernel_era):
2045
+ # The week's units would come from two eras, and the in-era part
2046
+ # is not the whole week, so consumption, the residual, headroom
2047
+ # and the projection are withheld. The observed reading and an
2048
+ # independently valid fit are still published.
2049
+ week_withheld = "unvalidated-coefficient-era"
2050
+ week_units = None
2051
+ else:
2052
+ week_units = current_week_units(forecast_population, kernel_era)
1794
2053
 
1795
2054
  recorded = recorded_regime(stored_state, account_key)
1796
- stale_prior = bool(
1797
- recorded is not None
1798
- and recorded.get("fingerprint")
1799
- != qm.QUOTA_MODEL_CONSTANTS_FINGERPRINT)
2055
+ stale_prior = recorded_prior_is_stale(recorded, era)
1800
2056
  mode_kind = "automatic"
1801
2057
  if watch_from is not None:
1802
2058
  mode_kind = "diagnostic"
@@ -1806,13 +2062,15 @@ def analyse_account(account_key, *, now, since, watch_from,
1806
2062
  mode_kind = "diagnostic"
1807
2063
 
1808
2064
  common = dict(
1809
- now=now, newest_at=load.newest_entry_at,
2065
+ now=now, newest_at=load.newest_entry_at, era=kernel_era,
2066
+ current_week_withheld=week_withheld,
1810
2067
  forecast_population=forecast_population,
1811
2068
  observed_percent=observed, current_week_units=week_units,
1812
2069
  current_week_start=week_start, current_week_end=week_end,
1813
2070
  override_split=watch_from.date() if watch_from is not None else None,
1814
2071
  extra_statuses=tuple(extra),
1815
- unattributed=qm.unattributed_units(segments, load.entries, now=now),
2072
+ unattributed=qm.unattributed_units(segments, load.entries, now=now,
2073
+ era=kernel_era),
1816
2074
  in_progress_excluded=qm.in_progress_dates(segments, now=now),
1817
2075
  diagnostics={"snapshots": len(load.snapshots),
1818
2076
  "segments": len(segments),
@@ -1833,7 +2091,7 @@ def analyse_account(account_key, *, now, since, watch_from,
1833
2091
  account_key=account_key,
1834
2092
  label=account_display_label(account_key),
1835
2093
  analysis=reported, load=load, week=week, recorded=recorded,
1836
- stale_prior=stale_prior, mode_kind=mode_kind, clean=clean)
2094
+ stale_prior=stale_prior, mode_kind=mode_kind, clean=clean, era=era)
1837
2095
 
1838
2096
 
1839
2097
  # ---------------------------------------------------------------------------
@@ -1892,6 +2150,10 @@ def account_payload(result: AccountResult) -> dict:
1892
2150
  # any caller could omit, and section 24 requires it published.
1893
2151
  diagnostics.pop("detector", None)
1894
2152
  detector = qm.detector_diagnostics(analysis.detector)
2153
+ era = result.era
2154
+ supported_from = None if era is None else era.start.isoformat()
2155
+ supported_until = (None if era is None or era.end is None
2156
+ else era.end.isoformat())
1895
2157
  return {
1896
2158
  "status": analysis.status.value,
1897
2159
  "verdict": analysis.verdict.value,
@@ -1958,9 +2220,14 @@ def account_payload(result: AccountResult) -> dict:
1958
2220
  "algorithmRevision": qm.QUOTA_MODEL_ALGORITHM_REVISION,
1959
2221
  "constantsFingerprint": qm.QUOTA_MODEL_CONSTANTS_FINGERPRINT,
1960
2222
  "verifiedAt": qm.QUOTA_MODEL_VERIFIED_AT,
1961
- "supportedCompositionFrom": SUPPORTED_COMPOSITION_FROM.isoformat(),
1962
- "coefficientSupport": dict(qm.COEFFICIENT_SUPPORT),
1963
- "coefficientEras": [dict(era) for era in COEFFICIENT_ERAS],
2223
+ "supportedCompositionFrom": supported_from,
2224
+ "coefficientSupport": {
2225
+ "supportedCompositionFrom": supported_from,
2226
+ "supportedCompositionUntil": supported_until,
2227
+ "coefficientEra": None if era is None else era.id,
2228
+ "fastModeParticipation": qm.FAST_MODE_PARTICIPATION,
2229
+ },
2230
+ "coefficientEras": published_coefficient_eras(),
1964
2231
  "detector": detector,
1965
2232
  "dedicatedPoolScope": qm.DEDICATED_POOL_SCOPE_NOTE,
1966
2233
  },
@@ -2018,6 +2285,15 @@ def _evidence_line(label, evidence, formatter) -> str:
2018
2285
  return f" {label:26} {formatter(evidence.value)}{span}{marks}"
2019
2286
 
2020
2287
 
2288
+ def _supported_composition_text(era) -> str:
2289
+ """The selected era's interval, as the DATA line names it (#978)."""
2290
+ if era is None:
2291
+ return f"none validated before {SUPPORTED_COMPOSITION_FROM.isoformat()}"
2292
+ if era.end is None:
2293
+ return f"{era.start.isoformat()} onward"
2294
+ return f"{era.start.isoformat()} to {era.end.isoformat()}"
2295
+
2296
+
2021
2297
  def render_text(results, *, now, decorated) -> list:
2022
2298
  """The human report, as a list of lines."""
2023
2299
  out: list = []
@@ -2032,9 +2308,9 @@ def render_text(results, *, now, decorated) -> list:
2032
2308
  out.append(f" entries through "
2033
2309
  f"{_iso(result.load.newest_entry_at) or '-'}")
2034
2310
  out.append(f" analysis start "
2035
- f"{_iso(result.load.analysis_start)}"
2311
+ f"{_iso(result.load.analysis_start) or '-'}"
2036
2312
  f" (supported composition: "
2037
- f"{SUPPORTED_COMPOSITION_FROM.isoformat()} onward)")
2313
+ f"{_supported_composition_text(result.era)})")
2038
2314
  out.append(f" eligible days "
2039
2315
  f"{analysis.diagnostics.get('eligibleDays', 0)}"
2040
2316
  f" ({analysis.diagnostics.get('withheldDays', 0)} withheld)")
@@ -2215,7 +2491,8 @@ def cmd_quota(args) -> int:
2215
2491
  mode = PersistMode(
2216
2492
  kind=result.mode_kind, account_key=account_key, now=now,
2217
2493
  fingerprint=qm.QUOTA_MODEL_CONSTANTS_FINGERPRINT,
2218
- regime_start=result.load.analysis_start)
2494
+ regime_start=result.load.analysis_start,
2495
+ coefficient_era=None if result.era is None else result.era.id)
2219
2496
  # §6.5 step 1-2: the descriptor is decided under the calibration
2220
2497
  # file's leaf lock, and that lock is released before any stats lock is
2221
2498
  # taken — `persist_and_detect` returns from its `with` block first.