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
@@ -27,6 +27,7 @@ import json
27
27
  import math
28
28
  import re
29
29
  import statistics
30
+ import typing
30
31
 
31
32
  from _lib_pricing import _strip_anthropic_model_prefix
32
33
 
@@ -36,14 +37,9 @@ from _lib_pricing import _strip_anthropic_model_prefix
36
37
  # fitted under a different fingerprint is `stale`.
37
38
  # ---------------------------------------------------------------------------
38
39
 
39
- #: Weights in "fresh input token" equivalents. Calibrated on Opus-5-dominated
40
- #: traffic; the five-minute cache-write quantity is weighted as fresh input.
41
- TOKEN_CLASS_WEIGHTS: dict[str, float] = {
42
- "fresh": 1.0,
43
- "output": 4.73,
44
- "cache_1h": 1.03,
45
- "cache_read": 0.0031,
46
- }
40
+ #: The four weighted token classes, in the order every era states its
41
+ #: weights. The five-minute cache-write quantity is weighted as fresh input.
42
+ TOKEN_CLASSES: tuple[str, ...] = ("fresh", "output", "cache_1h", "cache_read")
47
43
 
48
44
  #: Recognized model spellings (case-folded, provider prefix stripped, anchored
49
45
  #: terminal -YYYYMMDD removed) resolved to their canonical family. An
@@ -87,6 +83,12 @@ FAMILY_ALIASES: dict[str, str] = {
87
83
  "claude-mythos-5": "claude-mythos-5",
88
84
  "claude-mythos-5-1": "claude-mythos-5-1",
89
85
  "claude-mythos-preview": "claude-mythos-5",
86
+ # The 5.5 generation (#978). Each is its OWN canonical family, on the
87
+ # #704 precedent: pooling one into the family it follows would assert
88
+ # equal per-unit consumption, which nothing here establishes.
89
+ "claude-opus-5-5": "claude-opus-5-5",
90
+ "claude-sonnet-5-5": "claude-sonnet-5-5",
91
+ "claude-haiku-5-5": "claude-haiku-5-5",
90
92
  }
91
93
 
92
94
  #: Whether a canonical family drains the general weekly quota, drains a
@@ -114,6 +116,9 @@ FAMILY_PARTICIPATION: dict[str, str] = {
114
116
  "claude-fable-5-1": "general",
115
117
  "claude-mythos-5": "general",
116
118
  "claude-mythos-5-1": "general",
119
+ "claude-opus-5-5": "general",
120
+ "claude-sonnet-5-5": "general",
121
+ "claude-haiku-5-5": "general",
117
122
  }
118
123
 
119
124
  #: Where each classification came from, so a later reader can re-test it
@@ -121,6 +126,12 @@ FAMILY_PARTICIPATION: dict[str, str] = {
121
126
  #: operator-supplied and are NOT derivable from this repository. No family is
122
127
  #: ever classified "unknown" on the strength of unfamiliarity with it: an
123
128
  #: unrecognized family is escalated to the operator instead.
129
+ _PROVENANCE_55: str = (
130
+ "operator-supplied in #978 (2026-10-18), confirmed by the 2026-10-20 "
131
+ "handoff: participates in the general weekly meter; modelled with the "
132
+ "selected coefficient era's shared token-class coefficients"
133
+ )
134
+
124
135
  FAMILY_PROVENANCE: dict[str, str] = {
125
136
  "claude-fable-5": (
126
137
  "operator-supplied 2026-08-28: drains a dedicated pool AND the "
@@ -143,6 +154,10 @@ FAMILY_PROVENANCE: dict[str, str] = {
143
154
  "general weekly meter on the same terms as claude-mythos-5; only "
144
155
  "the general draining is modelled"
145
156
  ),
157
+ # Participation is the operator's statement, never the API price card.
158
+ "claude-opus-5-5": _PROVENANCE_55,
159
+ "claude-sonnet-5-5": _PROVENANCE_55,
160
+ "claude-haiku-5-5": _PROVENANCE_55,
146
161
  }
147
162
 
148
163
  #: Eligibility fence parameters (section 5).
@@ -193,14 +208,70 @@ MIX_SUPPORT: dict[str, object] = {
193
208
  "token_class_max_relative_effect": 0.05,
194
209
  }
195
210
 
196
- #: Producer policy that changes which retained requests the fitted model
197
- #: represents. It is fingerprinted with the arithmetic because moving the era
198
- #: floor or admitting another billing class changes the fitted answer just as
199
- #: surely as editing a coefficient does.
200
- COEFFICIENT_SUPPORT: dict[str, str] = {
201
- "supportedCompositionFrom": "2026-07-25",
202
- "fastModeParticipation": "usage-credits-only-excluded",
203
- }
211
+ #: The fast-mode policy every validated era applies: fast-mode requests are
212
+ #: billed to usage credits only and never reach the weekly meter.
213
+ FAST_MODE_PARTICIPATION: str = "usage-credits-only-excluded"
214
+
215
+
216
+ @dataclasses.dataclass(frozen=True)
217
+ class CoefficientEra:
218
+ """One validated coefficient era: a half-open UTC date interval, the four
219
+ token-class weights fitted for it, its support policy and provenance.
220
+
221
+ `start` is inclusive and `end` exclusive, both at UTC midnight; an open
222
+ era has `end=None`. `weights` is a tuple of `(class, weight)` pairs in
223
+ `TOKEN_CLASSES` order, so the record stays immutable and hashable.
224
+ """
225
+
226
+ id: str
227
+ start: "dt.date"
228
+ end: "dt.date | None"
229
+ weights: tuple
230
+ fast_mode_participation: str
231
+ provenance: str
232
+
233
+ def weight_map(self) -> dict[str, float]:
234
+ """The weights as a fresh `{class: weight}` dict the caller may own."""
235
+ return {name: float(weight) for name, weight in self.weights}
236
+
237
+
238
+ #: The ordered, immutable table of validated coefficient eras (spec 4.2).
239
+ #: Exactly one era covers any instant from 2026-07-25 on; earlier history is
240
+ #: covered by none and stays unvalidated. There is deliberately no mutable
241
+ #: "current weights" global: every weighting path asks this table.
242
+ COEFFICIENT_ERAS: tuple = (
243
+ CoefficientEra(
244
+ id="opus-5",
245
+ start=dt.date(2026, 7, 25),
246
+ end=dt.date(2026, 9, 22),
247
+ weights=(("fresh", 1.0), ("output", 4.73), ("cache_1h", 1.03),
248
+ ("cache_read", 0.0031)),
249
+ fast_mode_participation=FAST_MODE_PARTICIPATION,
250
+ provenance='The Opus 5 calibration, docs/quota-model.md "The finding"',
251
+ ),
252
+ CoefficientEra(
253
+ id="opus-5-5",
254
+ start=dt.date(2026, 9, 22),
255
+ end=None,
256
+ weights=(("fresh", 1.0), ("output", 0.64), ("cache_1h", 1.0),
257
+ ("cache_read", 0.0148)),
258
+ fast_mode_participation=FAST_MODE_PARTICIPATION,
259
+ provenance=("The 2026-10-18 Opus 5.5 refit; output weakly identified "
260
+ "(95% CI 0.23\u20131.13), cache read sharp "
261
+ "(0.0128\u20130.0168)"),
262
+ ),
263
+ )
264
+
265
+
266
+ def _era_payload(era: CoefficientEra) -> dict:
267
+ return {
268
+ "id": era.id,
269
+ "from": era.start.isoformat(),
270
+ "until": None if era.end is None else era.end.isoformat(),
271
+ "tokenClassWeights": era.weight_map(),
272
+ "fastModeParticipation": era.fast_mode_participation,
273
+ "provenance": era.provenance,
274
+ }
204
275
 
205
276
  #: The reading-to-interval rule of section 4. Bumped when that rule changes.
206
277
  DISPLAY_RULE_REVISION: int = 1
@@ -229,8 +300,8 @@ DEDICATED_POOL_SCOPE_NOTE: str = (
229
300
  "only general-quota draining is modelled"
230
301
  )
231
302
 
232
- QUOTA_MODEL_ALGORITHM_REVISION: int = 3
233
- QUOTA_MODEL_VERIFIED_AT: str = "2026-08-29"
303
+ QUOTA_MODEL_ALGORITHM_REVISION: int = 4
304
+ QUOTA_MODEL_VERIFIED_AT: str = "2026-10-20"
234
305
 
235
306
 
236
307
  def constants_payload() -> dict:
@@ -244,7 +315,6 @@ def constants_payload() -> dict:
244
315
  "algorithmRevision": QUOTA_MODEL_ALGORITHM_REVISION,
245
316
  "verifiedAt": QUOTA_MODEL_VERIFIED_AT,
246
317
  "displayRuleRevision": DISPLAY_RULE_REVISION,
247
- "tokenClassWeights": dict(TOKEN_CLASS_WEIGHTS),
248
318
  "familyAliases": dict(FAMILY_ALIASES),
249
319
  "familyParticipation": dict(FAMILY_PARTICIPATION),
250
320
  "familyProvenance": dict(FAMILY_PROVENANCE),
@@ -252,7 +322,7 @@ def constants_payload() -> dict:
252
322
  "detector": dict(DETECTOR),
253
323
  "trust": dict(TRUST),
254
324
  "mixSupport": dict(MIX_SUPPORT),
255
- "coefficientSupport": dict(COEFFICIENT_SUPPORT),
325
+ "coefficientEras": [_era_payload(era) for era in COEFFICIENT_ERAS],
256
326
  }
257
327
 
258
328
 
@@ -268,7 +338,7 @@ def constants_fingerprint() -> str:
268
338
  #: constant above without re-pinning this line is caught rather than silently
269
339
  #: invalidating every persisted calibration.
270
340
  QUOTA_MODEL_CONSTANTS_FINGERPRINT: str = (
271
- "0cd932a7f8caa5d9bec7010ed5d800935e5c78332ae060c1142650943512a24a"
341
+ "5835b430978b2fc556c577d44c9e62d5d7807e62533b32819ed63d37e3af671a"
272
342
  )
273
343
 
274
344
 
@@ -735,12 +805,136 @@ def token_class_units(e: EntryRecord) -> dict[str, float] | None:
735
805
  }
736
806
 
737
807
 
738
- def weighted_units(e: EntryRecord) -> float | None:
739
- """Weighted quota units for one entry, or None when the split is unknown."""
808
+ # ---------------------------------------------------------------------------
809
+ # Coefficient eras (#978, spec section 4.2).
810
+ #
811
+ # One covering era per instant, selected by the instant alone and never by
812
+ # the model name. The raw decomposition above is era-independent; everything
813
+ # that turns raw class quantities into units goes through `class_units`.
814
+ # ---------------------------------------------------------------------------
815
+ #: `weigh_entry`'s cause when no validated era covers the entry, or the
816
+ #: explicitly requested era does not. A member of `EVIDENCE_CODES`.
817
+ ERA_UNAVAILABLE: str = "unvalidated-coefficient-era"
818
+
819
+ #: `weigh_entry`'s cause when `token_class_units` cannot split the entry.
820
+ SPLIT_UNKNOWN: str = "token-split-unknown"
821
+
822
+
823
+ class CoefficientEraMismatch(ValueError):
824
+ """A population-level operation was handed an instant outside its era.
825
+
826
+ Units from two eras are never added together or divided by one budget
827
+ (spec section 4.2, invariant 5), so the refusal is loud rather than a
828
+ silent reweighting.
829
+ """
830
+
831
+
832
+ def _utc_midnight(date) -> "dt.datetime":
833
+ return dt.datetime.combine(date, dt.time(0, 0), tzinfo=dt.timezone.utc)
834
+
835
+
836
+ def era_bounds(era: CoefficientEra) -> tuple:
837
+ """`(start, end)` as aware UTC-midnight instants; `end` None when open."""
838
+ return (_utc_midnight(era.start),
839
+ None if era.end is None else _utc_midnight(era.end))
840
+
841
+
842
+ def era_contains(era: CoefficientEra, at: "dt.datetime") -> bool:
843
+ """`start <= at < end`, the half-open interval, for an aware instant."""
844
+ require_aware(at, "at")
845
+ start, end = era_bounds(era)
846
+ return at >= start and (end is None or at < end)
847
+
848
+
849
+ def era_for_instant(at: "dt.datetime") -> "CoefficientEra | None":
850
+ """The one validated era covering `at`, or None before 2026-07-25.
851
+
852
+ Raises on a naive datetime: a host-local reading would move the boundary
853
+ by the process's UTC offset.
854
+ """
855
+ require_aware(at, "at")
856
+ for era in COEFFICIENT_ERAS:
857
+ if era_contains(era, at):
858
+ return era
859
+ return None
860
+
861
+
862
+ def era_by_id(era_id: object) -> "CoefficientEra | None":
863
+ """The era whose identifier is exactly `era_id`, or None.
864
+
865
+ Anything that is not a known identifier string — None, a number, an
866
+ unknown name — is None, which a stored-regime reader treats as
867
+ malformed.
868
+ """
869
+ if not isinstance(era_id, str):
870
+ return None
871
+ for era in COEFFICIENT_ERAS:
872
+ if era.id == era_id:
873
+ return era
874
+ return None
875
+
876
+
877
+ def class_units(classes, era: CoefficientEra) -> float:
878
+ """Raw class quantities weighted by `era`'s coefficients.
879
+
880
+ The ONLY place in this module a quantity is multiplied by a weight, so
881
+ every aggregation path weights with the era it was given and none can keep
882
+ a multiplication of its own.
883
+ """
884
+ weights = era.weight_map()
885
+ return sum(float(classes.get(name, 0.0)) * weights[name]
886
+ for name in TOKEN_CLASSES)
887
+
888
+
889
+ class EntryWeighting(typing.NamedTuple):
890
+ """One entry's weighted units, or None with the typed reason why."""
891
+
892
+ units: "float | None"
893
+ cause: "str | None"
894
+
895
+
896
+ def weigh_entry(e: EntryRecord,
897
+ era: "CoefficientEra | None" = None) -> EntryWeighting:
898
+ """Weighted units for one entry under its covering era.
899
+
900
+ With `era=None` the era is the one covering the entry's instant. An
901
+ instant no validated era covers, or an explicit `era` that does not
902
+ contain it, is `ERA_UNAVAILABLE` — checked FIRST, so an uncovered entry is
903
+ never reported as a split problem. An unknown split is `SPLIT_UNKNOWN`.
904
+ """
905
+ covering = era_for_instant(e.at) if era is None else (
906
+ era if era_contains(era, e.at) else None)
907
+ if covering is None:
908
+ return EntryWeighting(None, ERA_UNAVAILABLE)
740
909
  classes = token_class_units(e)
741
910
  if classes is None:
742
- return None
743
- return sum(qty * TOKEN_CLASS_WEIGHTS[name] for name, qty in classes.items())
911
+ return EntryWeighting(None, SPLIT_UNKNOWN)
912
+ return EntryWeighting(class_units(classes, covering), None)
913
+
914
+
915
+ def weighted_units(e: EntryRecord,
916
+ era: "CoefficientEra | None" = None) -> float | None:
917
+ """Weighted quota units for one entry, or None when it cannot be weighted.
918
+
919
+ None covers both an unknown split and an instant outside every validated
920
+ era (or outside `era`); `weigh_entry` says which.
921
+ """
922
+ return weigh_entry(e, era).units
923
+
924
+
925
+ def _require_era(era) -> CoefficientEra:
926
+ if not isinstance(era, CoefficientEra):
927
+ raise TypeError(f"era must be a CoefficientEra, got {era!r}")
928
+ return era
929
+
930
+
931
+ def _require_in_era(era: CoefficientEra, instants, label: str) -> None:
932
+ """Raise `CoefficientEraMismatch` on the first instant outside `era`."""
933
+ for at in instants:
934
+ if not era_contains(era, at):
935
+ raise CoefficientEraMismatch(
936
+ f"{label} {at.isoformat()} lies outside coefficient era "
937
+ f"{era.id!r}")
744
938
 
745
939
 
746
940
  def integer_percent(value: float) -> int:
@@ -1055,8 +1249,8 @@ def _day_slices(segments, now):
1055
1249
  )
1056
1250
 
1057
1251
 
1058
- def build_daily_series(segments, entries, *, now,
1059
- newest_entry_at) -> list[DailyObservation]:
1252
+ def build_daily_series(segments, entries, *, now, newest_entry_at,
1253
+ era: CoefficientEra) -> list[DailyObservation]:
1060
1254
  """One observation per complete UTC date, in date order.
1061
1255
 
1062
1256
  Within one day and one segment the observation runs from the first to the
@@ -1082,12 +1276,21 @@ def build_daily_series(segments, entries, *, now,
1082
1276
  unfinished, so reporting it as `no-local-history` asserts something false
1083
1277
  and, because it always falls on or after any split date, withheld every
1084
1278
  verdict at exit 3. `in_progress_dates` reports it as a diagnostic instead.
1279
+
1280
+ `era` is the ONE coefficient era the whole series is weighted with
1281
+ (#978). Every entry and every segment reading must lie inside it, and a
1282
+ population that does not raises `CoefficientEraMismatch` rather than
1283
+ summing units from two eras.
1085
1284
  """
1086
1285
  require_aware(now, "now")
1087
1286
  if newest_entry_at is not None:
1088
1287
  require_aware(newest_entry_at, "newest_entry_at")
1089
1288
  for entry in entries:
1090
1289
  require_aware(entry.at, "entry.at")
1290
+ _require_era(era)
1291
+ _require_in_era(era, (e.at for e in entries), "entry.at")
1292
+ _require_in_era(era, (r.at for seg in segments for r in seg.rows),
1293
+ "snapshot.at")
1091
1294
  ordered = sorted(entries, key=lambda e: e.at)
1092
1295
  instants = [e.at for e in ordered]
1093
1296
 
@@ -1122,7 +1325,7 @@ def build_daily_series(segments, entries, *, now,
1122
1325
  # token count per class, which is what the composition support vector
1123
1326
  # is (section 34) — a coefficient-weighted share cannot carry the
1124
1327
  # materiality floor derived from those same coefficients.
1125
- raw = {name: 0.0 for name in TOKEN_CLASS_WEIGHTS}
1328
+ raw = {name: 0.0 for name in TOKEN_CLASSES}
1126
1329
  units = 0.0
1127
1330
  families: dict = {}
1128
1331
  split_unknown = False
@@ -1136,10 +1339,9 @@ def build_daily_series(segments, entries, *, now,
1136
1339
  if quantities is None:
1137
1340
  split_unknown = True
1138
1341
  continue
1139
- entry_units = 0.0
1140
1342
  for name, qty in quantities.items():
1141
1343
  raw[name] += qty
1142
- entry_units += qty * TOKEN_CLASS_WEIGHTS[name]
1344
+ entry_units = class_units(quantities, era)
1143
1345
  units += entry_units
1144
1346
  families[family] = families.get(family, 0.0) + entry_units
1145
1347
  raw_total = sum(raw.values())
@@ -1186,7 +1388,8 @@ def in_progress_dates(segments, *, now) -> tuple:
1186
1388
  if sl.kind == "in-progress"}))
1187
1389
 
1188
1390
 
1189
- def unattributed_units(segments, entries, *, now) -> dict:
1391
+ def unattributed_units(segments, entries, *, now,
1392
+ era: CoefficientEra) -> dict:
1190
1393
  """Weighted units and entry count on NO observation's token interval.
1191
1394
 
1192
1395
  Section 14 requires this diagnostic rather than a silent discard. An
@@ -1203,7 +1406,17 @@ def unattributed_units(segments, entries, *, now) -> dict:
1203
1406
  the general-quota entries whose token split is known, because that is the
1204
1407
  quantity the fit would have used; a dedicated-pool entry is reported as
1205
1408
  present and contributes nothing, exactly as it would inside an interval.
1409
+
1410
+ `era` is the one coefficient era the series is weighted with, and the
1411
+ same out-of-era refusal applies as in `build_daily_series`.
1206
1412
  """
1413
+ _require_era(era)
1414
+ entries = list(entries)
1415
+ for entry in entries:
1416
+ require_aware(entry.at, "entry.at")
1417
+ _require_in_era(era, (e.at for e in entries), "entry.at")
1418
+ _require_in_era(era, (r.at for seg in segments for r in seg.rows),
1419
+ "snapshot.at")
1207
1420
  windows = sorted((s.first_at, s.last_at)
1208
1421
  for s in _day_slices(segments, now)
1209
1422
  if s.kind == "attributed")
@@ -1218,7 +1431,7 @@ def unattributed_units(segments, entries, *, now) -> dict:
1218
1431
  count += 1
1219
1432
  if family_participation(normalize_family(entry.model)) != "general":
1220
1433
  continue
1221
- units = weighted_units(entry)
1434
+ units = weighted_units(entry, era)
1222
1435
  if units is not None:
1223
1436
  total += units
1224
1437
  return {"units": total, "entries": count}
@@ -1317,7 +1530,7 @@ def support_radius(vectors, centre) -> float | None:
1317
1530
  return median(distances) + MIX_SUPPORT["fence_mads"] * spread
1318
1531
 
1319
1532
 
1320
- def token_class_floor(centre) -> float | None:
1533
+ def token_class_floor(centre, *, era: CoefficientEra) -> float | None:
1321
1534
  """Materiality floor for the token-class radius, at `centre`.
1322
1535
 
1323
1536
  Derived, not asserted. For raw share distributions
@@ -1325,19 +1538,22 @@ def token_class_floor(centre) -> float | None:
1325
1538
  movement of the weighted-unit total at
1326
1539
  `MIX_SUPPORT["token_class_max_relative_effect"]` bounds the admissible
1327
1540
  total-variation distance at `effect * mu / spread`, where `mu` is the
1328
- centre's own weighted value and `spread` is the coefficient range. At the
1329
- shipped coefficients the spread is 4.73 - 0.0031 = 4.7269, so a fresh-only
1330
- centre admits about 0.01058 — half the 10% detection-width gate and a
1331
- third of the 15% prediction-width gate.
1541
+ centre's own weighted value and `spread` is the coefficient range of the
1542
+ selected `era` (#978). Under `opus-5` the spread is 4.73 - 0.0031 =
1543
+ 4.7269, so a fresh-only centre admits about 0.01058 — half the 10%
1544
+ detection-width gate and a third of the 15% prediction-width gate. Under
1545
+ `opus-5-5` it is 1.0 - 0.0148 = 0.9852, and a fresh-only centre admits
1546
+ about 0.0507511.
1332
1547
 
1333
1548
  None when the centre is undefined, which is a mix finding in its own right
1334
1549
  and never reaches here.
1335
1550
  """
1551
+ _require_era(era)
1336
1552
  if centre is None:
1337
1553
  return None
1338
- mu = sum(float(centre.get(name, 0.0)) * weight
1339
- for name, weight in TOKEN_CLASS_WEIGHTS.items())
1340
- spread = max(TOKEN_CLASS_WEIGHTS.values()) - min(TOKEN_CLASS_WEIGHTS.values())
1554
+ weights = era.weight_map()
1555
+ mu = class_units(centre, era)
1556
+ spread = max(weights.values()) - min(weights.values())
1341
1557
  if spread <= 0.0 or not math.isfinite(mu) or mu <= 0.0:
1342
1558
  return None
1343
1559
  return float(MIX_SUPPORT["token_class_max_relative_effect"]) * mu / spread
@@ -1364,7 +1580,7 @@ def effective_radius(empirical, floor) -> float | None:
1364
1580
  return max(empirical, floor)
1365
1581
 
1366
1582
 
1367
- def aggregate_composition(entries) -> tuple | None:
1583
+ def aggregate_composition(entries, *, era: CoefficientEra) -> tuple | None:
1368
1584
  """One `(family_shares, class_shares)` pair over a set of entries.
1369
1585
 
1370
1586
  The forecast population is a set of entries rather than a set of days
@@ -1374,8 +1590,16 @@ def aggregate_composition(entries) -> tuple | None:
1374
1590
 
1375
1591
  None when no general-quota entry with a known token split contributes,
1376
1592
  because an empty population supports no comparison.
1593
+
1594
+ `era` is the one coefficient era the population is weighted with; an
1595
+ entry outside it raises `CoefficientEraMismatch` (#978).
1377
1596
  """
1378
- raw = {name: 0.0 for name in TOKEN_CLASS_WEIGHTS}
1597
+ _require_era(era)
1598
+ entries = list(entries)
1599
+ for entry in entries:
1600
+ require_aware(entry.at, "entry.at")
1601
+ _require_in_era(era, (e.at for e in entries), "entry.at")
1602
+ raw = {name: 0.0 for name in TOKEN_CLASSES}
1379
1603
  families: dict = {}
1380
1604
  for entry in entries:
1381
1605
  family = normalize_family(entry.model)
@@ -1384,10 +1608,9 @@ def aggregate_composition(entries) -> tuple | None:
1384
1608
  quantities = token_class_units(entry)
1385
1609
  if quantities is None:
1386
1610
  continue
1387
- units = 0.0
1388
1611
  for name, qty in quantities.items():
1389
1612
  raw[name] += qty
1390
- units += qty * TOKEN_CLASS_WEIGHTS[name]
1613
+ units = class_units(quantities, era)
1391
1614
  families[family] = families.get(family, 0.0) + units
1392
1615
  raw_total = sum(raw.values())
1393
1616
  unit_total = sum(families.values())
@@ -2160,6 +2383,7 @@ def _composition_provenance(*, confirmed, reference_days, decisive_days,
2160
2383
 
2161
2384
 
2162
2385
  def analyse(series, *, now, newest_at, forecast_population,
2386
+ era: CoefficientEra, current_week_withheld: "str | None" = None,
2163
2387
  fingerprint_matches=True, observed_percent=None,
2164
2388
  current_week_units=None, current_week_start=None,
2165
2389
  current_week_end=None, override_split=None, extra_statuses=(),
@@ -2199,7 +2423,16 @@ def analyse(series, *, now, newest_at, forecast_population,
2199
2423
  non-finite or negative value withholds the evidence that depends on it
2200
2424
  with a typed code, because a NaN propagates through the headroom
2201
2425
  arithmetic into a published interval instead of raising.
2426
+
2427
+ `era` is the ONE coefficient era the analysis is fitted in (#978): the
2428
+ support floor uses its weights and the forecast probe aggregates under
2429
+ it, and a forecast entry outside it raises `CoefficientEraMismatch`.
2430
+ `current_week_withheld`, when set, is an `EVIDENCE_CODES` member that
2431
+ withholds the current week's consumption, headroom and projection while
2432
+ leaving `fitted` untouched — the glue's rule for a subscription week that
2433
+ spans the era boundary, whose units must never be summed across eras.
2202
2434
  """
2435
+ _require_era(era)
2203
2436
  require_aware(now, "now")
2204
2437
  for label, value in (("newest_at", newest_at),
2205
2438
  ("current_week_start", current_week_start),
@@ -2209,6 +2442,8 @@ def analyse(series, *, now, newest_at, forecast_population,
2209
2442
  forecast_entries = list(forecast_population)
2210
2443
  for entry in forecast_entries:
2211
2444
  require_aware(entry.at, "forecast_entry.at")
2445
+ _require_in_era(era, (e.at for e in forecast_entries),
2446
+ "forecast_entry.at")
2212
2447
 
2213
2448
  rejected: list[str] = []
2214
2449
  if current_week_units is not None and not _usable_quantity(
@@ -2278,7 +2513,7 @@ def analyse(series, *, now, newest_at, forecast_population,
2278
2513
  family_radius = effective_radius(
2279
2514
  family_radius, float(MIX_SUPPORT["family_tv_floor"]))
2280
2515
  class_radius = effective_radius(
2281
- class_radius, token_class_floor(class_centre))
2516
+ class_radius, token_class_floor(class_centre, era=era))
2282
2517
 
2283
2518
  # Section 35: after a confirmed change only the entries at or after the
2284
2519
  # successor boundary belong to the current regime, and they are measured
@@ -2289,7 +2524,7 @@ def analyse(series, *, now, newest_at, forecast_population,
2289
2524
  if e.at.astimezone(dt.timezone.utc).date() >= detector.split_date]
2290
2525
  else:
2291
2526
  probed_entries = forecast_entries
2292
- forecast_shares = (aggregate_composition(probed_entries)
2527
+ forecast_shares = (aggregate_composition(probed_entries, era=era)
2293
2528
  if probed_entries else None)
2294
2529
 
2295
2530
  contributed = list(extra_statuses)
@@ -2431,6 +2666,13 @@ def analyse(series, *, now, newest_at, forecast_population,
2431
2666
  week_start=current_week_start,
2432
2667
  week_end=current_week_end,
2433
2668
  qualifications=published_qualifications)
2669
+ if current_week_withheld is not None:
2670
+ consumption = evidence_withheld(current_week_withheld, population,
2671
+ published_qualifications)
2672
+ headroom = evidence_withheld(current_week_withheld, population,
2673
+ published_qualifications)
2674
+ projection = evidence_withheld(current_week_withheld, population,
2675
+ published_qualifications)
2434
2676
 
2435
2677
  merged = dict(diagnostics or {})
2436
2678
  merged.update({