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,2333 @@
1
+ """Pure-fn kernel for the Claude weekly quota model (#661 S1).
2
+
3
+ No I/O, no clock, no database, no printing, no environment read. The caller
4
+ supplies an explicit clock instant and already-parsed aware UTC datetimes;
5
+ this module only computes. It is driven by the glue in
6
+ `bin/_cctally_quota_model.py`.
7
+
8
+ The one sibling import is `_lib_pricing._strip_anthropic_model_prefix`, which
9
+ is itself a pure-fn layer with no I/O at import time.
10
+
11
+ Spec: docs/superpowers/specs/2026-08-28-661-s1-quota-model-and-calibration.md
12
+ (each part is normative over the parts before it; Part V is the current one).
13
+
14
+ There is deliberately **no budget constant and no per-family multiplier** in
15
+ this module. The fitted quantity is one effective blended weighted-units per
16
+ general-weekly point, valid only for the observed blend; it cancels out of
17
+ every ratio the detector forms, which is why no scale constant is needed.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import bisect
22
+ import dataclasses
23
+ import datetime as dt
24
+ import enum
25
+ import hashlib
26
+ import json
27
+ import math
28
+ import re
29
+ import statistics
30
+
31
+ from _lib_pricing import _strip_anthropic_model_prefix
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Constants. Every value below is serialized into `constants_payload()` and
35
+ # hashed into QUOTA_MODEL_CONSTANTS_FINGERPRINT; a persisted calibration
36
+ # fitted under a different fingerprint is `stale`.
37
+ # ---------------------------------------------------------------------------
38
+
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
+ }
47
+
48
+ #: Recognized model spellings (case-folded, provider prefix stripped, anchored
49
+ #: terminal -YYYYMMDD removed) resolved to their canonical family. An
50
+ #: unrecognized spelling resolves to None — never to a default weight.
51
+ FAMILY_ALIASES: dict[str, str] = {
52
+ # Opus
53
+ "claude-opus-5": "claude-opus-5",
54
+ "claude-opus-4-8": "claude-opus-4-8",
55
+ "claude-opus-4-7": "claude-opus-4-7",
56
+ "claude-opus-4-6": "claude-opus-4-6",
57
+ "claude-opus-4-5": "claude-opus-4-5",
58
+ "claude-opus-4-1": "claude-opus-4-1",
59
+ "claude-opus-4": "claude-opus-4",
60
+ "claude-4-opus": "claude-opus-4",
61
+ "claude-3-opus": "claude-3-opus",
62
+ "claude-3-opus-latest": "claude-3-opus",
63
+ # Sonnet
64
+ "claude-sonnet-5": "claude-sonnet-5",
65
+ "claude-sonnet-4-6": "claude-sonnet-4-6",
66
+ "claude-sonnet-4-5": "claude-sonnet-4-5",
67
+ "claude-sonnet-4": "claude-sonnet-4",
68
+ "claude-4-sonnet": "claude-sonnet-4",
69
+ "claude-3-7-sonnet": "claude-3-7-sonnet",
70
+ "claude-3-7-sonnet-latest": "claude-3-7-sonnet",
71
+ "claude-3-5-sonnet": "claude-3-5-sonnet",
72
+ "claude-3-5-sonnet-latest": "claude-3-5-sonnet",
73
+ # Haiku
74
+ "claude-haiku-4-5": "claude-haiku-4-5",
75
+ "claude-3-5-haiku": "claude-3-5-haiku",
76
+ "claude-3-5-haiku-latest": "claude-3-5-haiku",
77
+ "claude-3-haiku": "claude-3-haiku",
78
+ # Fable / Mythos
79
+ "claude-fable-5": "claude-fable-5",
80
+ "claude-mythos-5": "claude-mythos-5",
81
+ "claude-mythos-preview": "claude-mythos-5",
82
+ }
83
+
84
+ #: Whether a canonical family drains the general weekly quota, drains a
85
+ #: separate dedicated pool, or has unknown participation. A family absent
86
+ #: here, or classified "unknown", yields `unsupported-model-mix`.
87
+ FAMILY_PARTICIPATION: dict[str, str] = {
88
+ "claude-opus-5": "general",
89
+ "claude-opus-4-8": "general",
90
+ "claude-opus-4-7": "general",
91
+ "claude-opus-4-6": "general",
92
+ "claude-opus-4-5": "general",
93
+ "claude-opus-4-1": "general",
94
+ "claude-opus-4": "general",
95
+ "claude-3-opus": "general",
96
+ "claude-sonnet-5": "general",
97
+ "claude-sonnet-4-6": "general",
98
+ "claude-sonnet-4-5": "general",
99
+ "claude-sonnet-4": "general",
100
+ "claude-3-7-sonnet": "general",
101
+ "claude-3-5-sonnet": "general",
102
+ "claude-haiku-4-5": "general",
103
+ "claude-3-5-haiku": "general",
104
+ "claude-3-haiku": "general",
105
+ "claude-fable-5": "general",
106
+ "claude-mythos-5": "general",
107
+ }
108
+
109
+ #: Where each classification came from, so a later reader can re-test it
110
+ #: rather than inherit it. The Fable 5 and Mythos dual participations are
111
+ #: operator-supplied and are NOT derivable from this repository. No family is
112
+ #: ever classified "unknown" on the strength of unfamiliarity with it: an
113
+ #: unrecognized family is escalated to the operator instead.
114
+ FAMILY_PROVENANCE: dict[str, str] = {
115
+ "claude-fable-5": (
116
+ "operator-supplied 2026-08-28: drains a dedicated pool AND the "
117
+ "general weekly meter; only the general draining is modelled"
118
+ ),
119
+ "claude-mythos-5": (
120
+ "operator-supplied 2026-08-28: drains a dedicated pool AND the "
121
+ "general weekly meter, on the same terms as claude-fable-5; only "
122
+ "the general draining is modelled. Both spellings are real, priced "
123
+ "families in CLAUDE_MODEL_PRICING launched alongside Fable 5"
124
+ ),
125
+ }
126
+
127
+ #: Eligibility fence parameters (section 5).
128
+ ELIGIBILITY: dict[str, float] = {
129
+ "fence_mads": 3.0,
130
+ }
131
+
132
+ #: Change-point detector parameters (sections 5 and 15). Four of the five
133
+ #: entries are ints and their int-ness is load-bearing: `json.dumps(64)` is
134
+ #: "64" and `json.dumps(64.0)` is "64.0", so widening one to a float moves the
135
+ #: fingerprint and marks every persisted calibration stale.
136
+ DETECTOR: dict[str, "int | float"] = {
137
+ "min_watch_days": 3,
138
+ "fence_mads": 3.0,
139
+ "min_daily_gain": 2,
140
+ "alpha": 0.01,
141
+ "max_auto_scan_days": 64,
142
+ }
143
+
144
+ #: Trust gates (section 6). `min_fit_days` / `max_fit_width` gate a fit worth
145
+ #: predicting from; the `detect_*` entries additionally gate a rate-change
146
+ #: verdict; the `incomplete` entries cap incomplete-history contamination.
147
+ TRUST: dict[str, float] = {
148
+ "min_fit_days": 5,
149
+ "max_fit_width": 0.15,
150
+ "min_detect_baseline_days": 14,
151
+ "max_detect_baseline_width": 0.10,
152
+ "max_detect_watch_width": 0.15,
153
+ "max_incomplete_baseline_days": 1,
154
+ "max_incomplete_baseline_fraction": 0.05,
155
+ }
156
+
157
+ #: Composition-support policy (sections 18 and 34). Two radii, computed
158
+ #: identically over the family-share and token-class-share vectors, and both
159
+ #: required. The support vectors are RAW quantity shares — coefficient-weighted
160
+ #: ones cannot carry the materiality floor below — and the radius that decides
161
+ #: is `max(empirical, floor)`, because an empirical radius collapses to exactly
162
+ #: zero whenever the reference population never varied, which is the ordinary
163
+ #: single-model case.
164
+ MIX_SUPPORT: dict[str, object] = {
165
+ "fence_mads": 3.0,
166
+ "distance": "total-variation",
167
+ "centre": "componentwise-median-renormalized",
168
+ "axes": ("family", "token_class"),
169
+ "vector": "raw-quantity-share",
170
+ "effective_radius": "max(empirical, floor)",
171
+ "family_tv_floor": 0.02,
172
+ "token_class_max_relative_effect": 0.05,
173
+ }
174
+
175
+ #: The reading-to-interval rule of section 4. Bumped when that rule changes.
176
+ DISPLAY_RULE_REVISION: int = 1
177
+
178
+
179
+ def max_fit_population_days() -> int:
180
+ """Most recent eligible days a no-change fit may be computed over.
181
+
182
+ Derived rather than stored, so the guard band cannot drift away from the
183
+ scan horizon it is defined against. The band keeps history older than the
184
+ earliest detectable split out of the prediction, which section 24 records
185
+ as a correctness matter and not only a cost one: the cumulative
186
+ all-history fit narrows to a 10% interval while its point estimate climbs
187
+ 2.6x and stays wrong.
188
+ """
189
+ return (int(DETECTOR["max_auto_scan_days"])
190
+ - int(TRUST["min_detect_baseline_days"]))
191
+
192
+ #: The out-of-scope marker section 21 requires. Section 26 extends it from
193
+ #: Fable 5 to Mythos on the same terms: all three families drain a dedicated
194
+ #: pool AS WELL as the general weekly meter, and only the general draining is
195
+ #: modelled here.
196
+ DEDICATED_POOL_SCOPE_NOTE: str = (
197
+ "out-of-scope: claude-fable-5, claude-mythos-5 and claude-mythos-preview "
198
+ "also drain dedicated pools; only general-quota draining is modelled"
199
+ )
200
+
201
+ QUOTA_MODEL_ALGORITHM_REVISION: int = 2
202
+ QUOTA_MODEL_VERIFIED_AT: str = "2026-08-28"
203
+
204
+
205
+ def constants_payload() -> dict:
206
+ """Every constant this kernel's arithmetic depends on, as plain data.
207
+
208
+ Serialized canonically and hashed into the committed fingerprint. It
209
+ publishes no scale constant: there is no quota size, no promotional
210
+ hypothesis and no weekly-to-five-hour ratio to publish.
211
+ """
212
+ return {
213
+ "algorithmRevision": QUOTA_MODEL_ALGORITHM_REVISION,
214
+ "verifiedAt": QUOTA_MODEL_VERIFIED_AT,
215
+ "displayRuleRevision": DISPLAY_RULE_REVISION,
216
+ "tokenClassWeights": dict(TOKEN_CLASS_WEIGHTS),
217
+ "familyAliases": dict(FAMILY_ALIASES),
218
+ "familyParticipation": dict(FAMILY_PARTICIPATION),
219
+ "familyProvenance": dict(FAMILY_PROVENANCE),
220
+ "eligibility": dict(ELIGIBILITY),
221
+ "detector": dict(DETECTOR),
222
+ "trust": dict(TRUST),
223
+ "mixSupport": dict(MIX_SUPPORT),
224
+ }
225
+
226
+
227
+ def constants_fingerprint() -> str:
228
+ """SHA-256 over the canonical JSON of `constants_payload()`."""
229
+ blob = json.dumps(
230
+ constants_payload(), sort_keys=True, separators=(",", ":")
231
+ ).encode()
232
+ return hashlib.sha256(blob).hexdigest()
233
+
234
+
235
+ #: Committed literal. A test recomputes it and fails on drift, so editing any
236
+ #: constant above without re-pinning this line is caught rather than silently
237
+ #: invalidating every persisted calibration.
238
+ QUOTA_MODEL_CONSTANTS_FINGERPRINT: str = (
239
+ "1e2e74ff673bb32dc93d2cf582d5e84eb93fff9cfae39e4272edc25a30da25ee"
240
+ )
241
+
242
+
243
+ # ---------------------------------------------------------------------------
244
+ # Student-t quantiles.
245
+ #
246
+ # The standard library has no inverse-t. A bisection over an incomplete-beta
247
+ # implementation would make every interval depend on a tolerance, so the table
248
+ # is frozen here, generated once at the 0.975 level for one through one
249
+ # hundred degrees of freedom and pinned by a test against published values.
250
+ # ---------------------------------------------------------------------------
251
+ _T_QUANTILE_975: tuple[float, ...] = (
252
+ 12.706205, 4.302653, 3.182446, 2.776445, 2.570582, # df 1-5
253
+ 2.446912, 2.364624, 2.306004, 2.262157, 2.228139, # df 6-10
254
+ 2.200985, 2.178813, 2.160369, 2.144787, 2.131450, # df 11-15
255
+ 2.119905, 2.109816, 2.100922, 2.093024, 2.085963, # df 16-20
256
+ 2.079614, 2.073873, 2.068658, 2.063899, 2.059539, # df 21-25
257
+ 2.055529, 2.051831, 2.048407, 2.045230, 2.042272, # df 26-30
258
+ 2.039513, 2.036933, 2.034515, 2.032245, 2.030108, # df 31-35
259
+ 2.028094, 2.026192, 2.024394, 2.022691, 2.021075, # df 36-40
260
+ 2.019541, 2.018082, 2.016692, 2.015368, 2.014103, # df 41-45
261
+ 2.012896, 2.011741, 2.010635, 2.009575, 2.008559, # df 46-50
262
+ 2.007584, 2.006647, 2.005746, 2.004879, 2.004045, # df 51-55
263
+ 2.003241, 2.002465, 2.001717, 2.000995, 2.000298, # df 56-60
264
+ 1.999624, 1.998972, 1.998341, 1.997730, 1.997138, # df 61-65
265
+ 1.996564, 1.996008, 1.995469, 1.994945, 1.994437, # df 66-70
266
+ 1.993943, 1.993464, 1.992997, 1.992543, 1.992102, # df 71-75
267
+ 1.991673, 1.991254, 1.990847, 1.990450, 1.990063, # df 76-80
268
+ 1.989686, 1.989319, 1.988960, 1.988610, 1.988268, # df 81-85
269
+ 1.987934, 1.987608, 1.987290, 1.986979, 1.986675, # df 86-90
270
+ 1.986377, 1.986086, 1.985802, 1.985523, 1.985251, # df 91-95
271
+ 1.984984, 1.984723, 1.984467, 1.984217, 1.983972, # df 96-100
272
+ )
273
+
274
+
275
+ def t_quantile_975(df: int) -> float:
276
+ """Two-sided 0.975 Student-t quantile at `df` degrees of freedom.
277
+
278
+ Raises ValueError at or below zero, because a caller reaching this with
279
+ df <= 0 has fewer than two observations and must withhold rather than
280
+ quietly receive a normal quantile.
281
+ """
282
+ if df <= 0:
283
+ raise ValueError(f"degrees of freedom must be positive, got {df!r}")
284
+ if df <= len(_T_QUANTILE_975):
285
+ return _T_QUANTILE_975[df - 1]
286
+ return statistics.NormalDist().inv_cdf(0.975)
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # The closed status, cause and verdict contract (section 19).
291
+ #
292
+ # Three separate closed enums, not one overloaded set: a top-level calibration
293
+ # status, a per-observation withholding cause, and the reported verdict.
294
+ # ---------------------------------------------------------------------------
295
+ class CalibrationStatus(str, enum.Enum):
296
+ """Top-level state of one account's calibration."""
297
+
298
+ OK = "ok"
299
+ INSUFFICIENT_HISTORY = "insufficient-history"
300
+ FRAGMENTED_HISTORY = "fragmented-history"
301
+ UNSTABLE_FIT = "unstable-fit"
302
+ LOCAL_HISTORY_INCOMPLETE = "local-history-incomplete"
303
+ UNSUPPORTED_MODEL_MIX = "unsupported-model-mix"
304
+ UNVALIDATED_COEFFICIENT_ERA = "unvalidated-coefficient-era"
305
+ TOKEN_SPLIT_UNKNOWN = "token-split-unknown"
306
+ STALE = "stale"
307
+ FUTURE = "future"
308
+ UNAVAILABLE = "unavailable"
309
+
310
+
311
+ class WithholdingCause(str, enum.Enum):
312
+ """Why one daily observation carries no usable value.
313
+
314
+ `NO_LOCAL_HISTORY` and `SPARSE_LOCAL_HISTORY` are separate absences with
315
+ separate remedies and must never be merged: the first means the ingest
316
+ tail does not cover the day or the interval holds no entries at all, the
317
+ second means the interval holds positive units below the eligibility
318
+ fence.
319
+ """
320
+
321
+ NO_LOCAL_HISTORY = "no-local-history"
322
+ SPARSE_LOCAL_HISTORY = "sparse-local-history"
323
+ TRANSITION_DAY = "transition-day"
324
+ RIGHT_CENSORED = "right-censored"
325
+ TOKEN_SPLIT_UNKNOWN = "token-split-unknown"
326
+ UNSUPPORTED_COMPOSITION = "unsupported-composition"
327
+
328
+
329
+ class Verdict(str, enum.Enum):
330
+ RATE_CHANGE_DETECTED = "rate-change-detected"
331
+ NO_RATE_CHANGE = "no-rate-change"
332
+ WITHHELD = "withheld"
333
+
334
+
335
+ class BlockingReason(str, enum.Enum):
336
+ """Why the verdict is withheld although the status does not withhold it.
337
+
338
+ A THIRD closed vocabulary, disjoint from `CalibrationStatus` and from
339
+ `WithholdingCause` (section 38). Section 23 made status and verdict
340
+ independent axes; a blocking reason is an input to the verdict axis alone,
341
+ so the status keeps describing the published population's own health.
342
+
343
+ `SPARSE_DAY_IN_DECISIVE_RUN` is section 5's rule that a day below the
344
+ eligibility fence inside the decisive run must not relocate the change
345
+ point. Filing it as `local-history-incomplete` told a user whose days are
346
+ fully ingested that their local token history was incomplete, which is the
347
+ merge section 27 forbids; borrowing any other status for it would be the
348
+ same defect.
349
+ """
350
+
351
+ SPARSE_DAY_IN_DECISIVE_RUN = "sparse-day-in-decisive-run"
352
+
353
+
354
+ #: Total order, first match wins. A non-`ok` status always renders the verdict
355
+ #: as withheld, never as "no rate change".
356
+ STATUS_PRECEDENCE: tuple[CalibrationStatus, ...] = (
357
+ CalibrationStatus.UNAVAILABLE,
358
+ CalibrationStatus.FUTURE,
359
+ CalibrationStatus.STALE,
360
+ CalibrationStatus.TOKEN_SPLIT_UNKNOWN,
361
+ CalibrationStatus.LOCAL_HISTORY_INCOMPLETE,
362
+ CalibrationStatus.UNSUPPORTED_MODEL_MIX,
363
+ CalibrationStatus.UNVALIDATED_COEFFICIENT_ERA,
364
+ CalibrationStatus.INSUFFICIENT_HISTORY,
365
+ CalibrationStatus.FRAGMENTED_HISTORY,
366
+ CalibrationStatus.UNSTABLE_FIT,
367
+ CalibrationStatus.OK,
368
+ )
369
+
370
+ #: Exit code per status. `0` trustworthy result, `3` judgment withheld because
371
+ #: the data is unhealthy or a store is unavailable, `4` evidence healthy but
372
+ #: too thin, fragmented or unstable to fit. `1` is the detector's, not a
373
+ #: status's, and `2` is argument errors only — no analysis outcome maps to it.
374
+ STATUS_EXIT: dict[CalibrationStatus, int] = {
375
+ CalibrationStatus.OK: 0,
376
+ CalibrationStatus.INSUFFICIENT_HISTORY: 4,
377
+ CalibrationStatus.FRAGMENTED_HISTORY: 4,
378
+ CalibrationStatus.UNSTABLE_FIT: 4,
379
+ CalibrationStatus.LOCAL_HISTORY_INCOMPLETE: 3,
380
+ CalibrationStatus.UNSUPPORTED_MODEL_MIX: 3,
381
+ CalibrationStatus.UNVALIDATED_COEFFICIENT_ERA: 3,
382
+ CalibrationStatus.TOKEN_SPLIT_UNKNOWN: 3,
383
+ CalibrationStatus.STALE: 3,
384
+ CalibrationStatus.FUTURE: 3,
385
+ CalibrationStatus.UNAVAILABLE: 3,
386
+ }
387
+
388
+
389
+ #: Statuses that withhold the verdict whatever the detector found, and exit
390
+ #: 3. Section 23: status describes the current predictive calibration and the
391
+ #: verdict describes the detector, so the two are independently gated — but a
392
+ #: health failure this severe means the evidence underneath the detector
393
+ #: cannot be trusted either.
394
+ VERDICT_BLOCKING_STATUSES: tuple[CalibrationStatus, ...] = (
395
+ CalibrationStatus.UNAVAILABLE,
396
+ CalibrationStatus.FUTURE,
397
+ CalibrationStatus.STALE,
398
+ CalibrationStatus.TOKEN_SPLIT_UNKNOWN,
399
+ CalibrationStatus.LOCAL_HISTORY_INCOMPLETE,
400
+ CalibrationStatus.UNSUPPORTED_MODEL_MIX,
401
+ CalibrationStatus.UNVALIDATED_COEFFICIENT_ERA,
402
+ )
403
+
404
+
405
+ def resolve_outcome(status: CalibrationStatus, *, change_detected: bool,
406
+ blocking=()) -> tuple["Verdict", int]:
407
+ """The one `(verdict, exit)` answer for a `(status, detector, blocking)`.
408
+
409
+ `blocking` is the `BlockingReason` members that withhold the verdict
410
+ whatever the status and the detector say (section 38). It is accepted HERE
411
+ rather than returned by `classify` because this function is already the
412
+ sole authority that joins the two independent axes of section 23, and a
413
+ blocking reason is an input to the verdict axis alone: `classify` resolves
414
+ one status through `worst_status`, the glue merges its own statuses into
415
+ that same call, and neither has a counterpart for a reason.
416
+
417
+ Section 23 supersedes Part II's claim that the status determines the
418
+ verdict and the exit code exhaustively. A confirmed change whose only
419
+ non-`ok` condition is thin successor evidence still reports
420
+ `rate-change-detected` at exit 1, because detection has independently
421
+ satisfied its own stricter gates — fourteen baseline days at 10% width,
422
+ three watch days at 15%, strict disjointness and a Holm-corrected p —
423
+ and exit 1 is this repository's actionable-finding convention.
424
+
425
+ Section 23 names that case for `insufficient-history` and ONLY for it.
426
+ Section 33 reverts an earlier widening to `fragmented-history` and
427
+ `unstable-fit`: those two say the published successor fit is untrustworthy
428
+ or absent, whereas `insufficient-history` says a computed fit merely
429
+ covers too few days. `classify` guarantees the distinction, because it
430
+ reports `unstable-fit` whenever no fit could be computed at all, so
431
+ `insufficient-history` here means exactly `fit is not None and
432
+ len(fit_days) < TRUST["min_fit_days"]`.
433
+
434
+ `STATUS_EXIT` remains the status-only mapping and is correct exactly when
435
+ no change was detected; this function is the authority.
436
+ """
437
+ if status in VERDICT_BLOCKING_STATUSES:
438
+ return Verdict.WITHHELD, 3
439
+ if tuple(blocking):
440
+ return Verdict.WITHHELD, 3
441
+ if change_detected and status in (CalibrationStatus.OK,
442
+ CalibrationStatus.INSUFFICIENT_HISTORY):
443
+ return Verdict.RATE_CHANGE_DETECTED, 1
444
+ if status is CalibrationStatus.OK:
445
+ return Verdict.NO_RATE_CHANGE, 0
446
+ return Verdict.WITHHELD, 4
447
+
448
+
449
+ def worst_status(statuses) -> CalibrationStatus:
450
+ """The earliest member of STATUS_PRECEDENCE present in `statuses`.
451
+
452
+ An empty argument is `ok`, so a caller with nothing to report does not
453
+ have to special-case the call.
454
+ """
455
+ present = set(statuses)
456
+ for candidate in STATUS_PRECEDENCE:
457
+ if candidate in present:
458
+ return candidate
459
+ return CalibrationStatus.OK
460
+
461
+
462
+ # ---------------------------------------------------------------------------
463
+ # Evidence.
464
+ #
465
+ # CalibrationEvidence is a NEW named type and deliberately not
466
+ # `_lib_diagnosis.EvidenceField`, whose real shape is
467
+ # state / value / population / qualifications / code and which carries no
468
+ # interval and no support. This type follows the same discipline — a withheld
469
+ # value carries a typed code and no value — and adds `interval` and `support`.
470
+ # It carries NO confidence field at all: the design never defined what a
471
+ # confidence would mean here, and an undefined confidence is worse than none.
472
+ # ---------------------------------------------------------------------------
473
+ #: The closed union `CalibrationEvidence.code` draws from (section 22):
474
+ #: every `WithholdingCause` member plus every non-`ok` `CalibrationStatus`
475
+ #: member. An observation-level defect uses a cause; a calibration-level
476
+ #: trust failure uses the corresponding status, so an immature successor
477
+ #: carries `insufficient-history`. No seventh `WithholdingCause` is added.
478
+ EVIDENCE_CODES: frozenset = frozenset(
479
+ [c.value for c in WithholdingCause]
480
+ + [s.value for s in CalibrationStatus if s is not CalibrationStatus.OK]
481
+ )
482
+
483
+
484
+ @dataclasses.dataclass(frozen=True)
485
+ class Interval:
486
+ lo: float
487
+ hi: float | None
488
+
489
+
490
+ @dataclasses.dataclass(frozen=True)
491
+ class Support:
492
+ days: int
493
+ segments: int
494
+
495
+
496
+ @dataclasses.dataclass(frozen=True)
497
+ class CalibrationEvidence:
498
+ state: str # "available" | "withheld"
499
+ value: float | None
500
+ interval: Interval | None
501
+ support: Support | None
502
+ population: dict[str, int]
503
+ #: When withheld, a member of the closed union `EVIDENCE_CODES`: any
504
+ #: `WithholdingCause` value, or any non-`ok` `CalibrationStatus` value.
505
+ #: None when available.
506
+ code: str | None = None
507
+ qualifications: tuple[str, ...] = ()
508
+
509
+
510
+ def evidence_available(value, interval, support, population,
511
+ qualifications=()) -> CalibrationEvidence:
512
+ return CalibrationEvidence(
513
+ state="available", value=value, interval=interval, support=support,
514
+ population=dict(population), qualifications=tuple(qualifications),
515
+ )
516
+
517
+
518
+ def evidence_withheld(code, population,
519
+ qualifications=()) -> CalibrationEvidence:
520
+ """`code` may be a `WithholdingCause`, a `CalibrationStatus`, or the
521
+ plain string value of either.
522
+
523
+ It is stored as a plain `str` either way, so a JSON renderer never has to
524
+ know whether the caller happened to hold the enum member. A value outside
525
+ `EVIDENCE_CODES` raises: the union is closed, and the shipped kernel's
526
+ defect was populating this field from two enums with nothing specifying
527
+ which applied.
528
+ """
529
+ value = str(code.value) if isinstance(code, enum.Enum) else str(code)
530
+ if value not in EVIDENCE_CODES:
531
+ raise ValueError(
532
+ f"withholding code {value!r} is outside the closed union "
533
+ f"EVIDENCE_CODES"
534
+ )
535
+ return CalibrationEvidence(
536
+ state="withheld", value=None, interval=None, support=None,
537
+ population=dict(population), qualifications=tuple(qualifications),
538
+ code=value,
539
+ )
540
+
541
+
542
+ # ---------------------------------------------------------------------------
543
+ # Model identity, participation, token weighting, and the ceiling rule.
544
+ # ---------------------------------------------------------------------------
545
+ @dataclasses.dataclass(frozen=True)
546
+ class EntryRecord:
547
+ """One priced request, as the glue reads it out of `session_entries`.
548
+
549
+ `cache_create_total` is the whole cache-write quantity and `cache_1h` the
550
+ one-hour part of it, which is NULL on a store predating the split. The
551
+ five-minute quantity is the remainder, never a separate column.
552
+ """
553
+
554
+ at: "dt.datetime"
555
+ model: str
556
+ fresh: int
557
+ output: int
558
+ cache_create_total: int
559
+ cache_1h: int | None
560
+ cache_read: int
561
+
562
+
563
+ #: An anchored terminal model date. Anchored and exactly eight digits, so
564
+ #: `claude-opus-5-2026072` is not silently truncated to a known family.
565
+ _DATED_SUFFIX = re.compile(r"-\d{8}$")
566
+
567
+
568
+ def normalize_family(model: str) -> str | None:
569
+ """Resolve a raw model id to its canonical family, or None.
570
+
571
+ Case-folds FIRST and strips the provider prefix second, because
572
+ `_lib_pricing._strip_anthropic_model_prefix` matches only the lowercase
573
+ `anthropic/` and `anthropic.` forms — folding after stripping would leave
574
+ `ANTHROPIC/...` unstripped. Then removes an anchored terminal -YYYYMMDD
575
+ and resolves through the explicit alias catalogue.
576
+
577
+ Never substring matching, and never the display-only `_short_model_name`.
578
+ An unrecognized family is None, not a default weight: the research
579
+ script's `MODEL_WEIGHT_DEFAULT = 0.70` silently priced every unknown model
580
+ and is exactly what this return value replaces.
581
+ """
582
+ if not model:
583
+ return None
584
+ folded = _strip_anthropic_model_prefix(model.casefold())
585
+ candidate = _DATED_SUFFIX.sub("", folded)
586
+ return FAMILY_ALIASES.get(candidate)
587
+
588
+
589
+ def family_participation(family: str | None) -> str:
590
+ """"general" | "dedicated" | "unknown" for a canonical family.
591
+
592
+ No entry in `FAMILY_PARTICIPATION` is classified "dedicated" and a test
593
+ pins that, so no caller branches on the value: a family is either known to
594
+ drain the general weekly meter or its participation is unknown, and an
595
+ unknown one yields `unsupported-composition`. Fable 5 and Mythos DO drain
596
+ dedicated pools as well, but they are classified "general" because they
597
+ drain the general meter too and only that draining is modelled. A branch
598
+ no input can reach is not a safeguard, so the value stays in this
599
+ function's vocabulary and nowhere else until a purely dedicated family is
600
+ actually classified.
601
+ """
602
+ if family is None:
603
+ return "unknown"
604
+ return FAMILY_PARTICIPATION.get(family, "unknown")
605
+
606
+
607
+ def _finite_quantity(value) -> bool:
608
+ """True when a token quantity is a real, finite number.
609
+
610
+ `float('nan')` passes every `<= 0` guard and fails every `>` guard, so
611
+ without this it flows straight through the weighting and reaches the fit,
612
+ where it produces a `status = ok` calibration whose every value is NaN.
613
+ Positive infinity is the mirror case: it PASSES `> 0`.
614
+ """
615
+ try:
616
+ return math.isfinite(value)
617
+ except (TypeError, OverflowError):
618
+ return False
619
+
620
+
621
+ def token_class_units(e: EntryRecord) -> dict[str, float] | None:
622
+ """Raw token quantity per weighted class, or None when the split is unknown.
623
+
624
+ None means `token-split-unknown` and the caller must withhold. It happens
625
+ when the one-hour quantity is NULL AND the total cache-create quantity is
626
+ positive: where the total is zero the one-hour contribution is
627
+ unambiguously zero, so a NULL there is not ambiguous. `coalesce(..., 0)`
628
+ would read "split unknown" as "zero one-hour writes", and the two classes
629
+ carry different weights.
630
+
631
+ It also means None when any quantity is not a finite number, and when the
632
+ one-hour quantity exceeds the whole cache-write total. Neither is a usable
633
+ split, and reporting both through this same return value keeps the day
634
+ withheld with a typed cause without adding a seventh `WithholdingCause`
635
+ member, which section 22 forbids.
636
+ """
637
+ for quantity in (e.fresh, e.output, e.cache_create_total, e.cache_read):
638
+ if not _finite_quantity(quantity):
639
+ return None
640
+ if e.cache_1h is None:
641
+ if e.cache_create_total > 0:
642
+ return None
643
+ one_hour = 0
644
+ else:
645
+ if not _finite_quantity(e.cache_1h):
646
+ return None
647
+ one_hour = e.cache_1h
648
+ five_minute = e.cache_create_total - one_hour
649
+ if five_minute < 0:
650
+ # The five-minute quantity is the REMAINDER, so a one-hour part
651
+ # larger than the whole cache-write total is malformed input
652
+ # (section 39). Left to the arithmetic it becomes a negative fresh
653
+ # quantity, and the raw quantity total can then reach zero while the
654
+ # weighted total stays positive, because the one-hour class carries
655
+ # 1.03 against the fresh remainder's 1.0.
656
+ return None
657
+ return {
658
+ "fresh": float(e.fresh + five_minute),
659
+ "output": float(e.output),
660
+ "cache_1h": float(one_hour),
661
+ "cache_read": float(e.cache_read),
662
+ }
663
+
664
+
665
+ def weighted_units(e: EntryRecord) -> float | None:
666
+ """Weighted quota units for one entry, or None when the split is unknown."""
667
+ classes = token_class_units(e)
668
+ if classes is None:
669
+ return None
670
+ return sum(qty * TOKEN_CLASS_WEIGHTS[name] for name, qty in classes.items())
671
+
672
+
673
+ def integer_percent(value: float) -> int:
674
+ """`math.floor(value + 1e-9)`.
675
+
676
+ `0.57 * 100` is 56.99999999999999, so a bare floor reads it as 56. Bare
677
+ `int()` misread 5 of 22,987 rows on the measured store and produced one
678
+ spurious segment fork.
679
+ """
680
+ return math.floor(value + 1e-9)
681
+
682
+
683
+ def true_percent_point(shown: int) -> float | None:
684
+ """Unbiased point estimate of true consumption behind a displayed reading.
685
+
686
+ A reading is the interval it denotes: 0 is [0, 0.5), 1 is [0.5, 1), k is
687
+ [k-1, k), and 100 is [99, +inf). Midpoints are used only where the
688
+ interval is bounded, so 100 returns None — it is right-censored, and
689
+ returning 99.5 would invent a finite value the observation does not
690
+ supply.
691
+ """
692
+ if shown >= 100:
693
+ return None
694
+ if shown <= 0:
695
+ return 0.25
696
+ if shown == 1:
697
+ return 0.75
698
+ return shown - 0.5
699
+
700
+
701
+ def true_percent_interval(shown: int) -> tuple[float, float | None]:
702
+ """The half-open interval a displayed reading denotes.
703
+
704
+ The upper bound is None at 100, which is the right-censored case.
705
+ """
706
+ if shown >= 100:
707
+ return (99.0, None)
708
+ if shown <= 0:
709
+ return (0.0, 0.5)
710
+ if shown == 1:
711
+ return (0.5, 1.0)
712
+ return (float(shown - 1), float(shown))
713
+
714
+
715
+ # ---------------------------------------------------------------------------
716
+ # Credit-aware segmentation and the daily observation contract (section 14).
717
+ # ---------------------------------------------------------------------------
718
+ @dataclasses.dataclass(frozen=True)
719
+ class SnapshotRecord:
720
+ """One weekly-meter reading.
721
+
722
+ `rowid` carries the store's row identity so the endpoint tie-break of
723
+ section 14 is total; a caller that has none may leave it at zero, and the
724
+ order then falls back to the instant and the percent alone.
725
+ """
726
+
727
+ at: "dt.datetime"
728
+ week_start: "dt.datetime"
729
+ percent: float
730
+ source: str
731
+ rowid: int = 0
732
+
733
+
734
+ @dataclasses.dataclass(frozen=True)
735
+ class CreditRecord:
736
+ """An authoritative credit instant.
737
+
738
+ `kind` is "reset" for a `week_reset_events` row, which re-anchors the
739
+ logical week, or "floor" for a `weekly_credit_floors` row, which does not.
740
+ Never inferred from a meter decrease.
741
+ """
742
+
743
+ at: "dt.datetime"
744
+ kind: str
745
+
746
+
747
+ @dataclasses.dataclass(frozen=True)
748
+ class Segment:
749
+ segment_id: int
750
+ week_anchor: "dt.datetime"
751
+ rows: tuple
752
+ restarted: bool
753
+
754
+
755
+ @dataclasses.dataclass(frozen=True)
756
+ class DailyObservation:
757
+ date: "dt.date"
758
+ segment_id: int
759
+ meter_delta: float
760
+ units: float
761
+ class_shares: dict
762
+ family_shares: dict
763
+ cause: WithholdingCause | None = None
764
+
765
+
766
+ def require_aware(value: "dt.datetime", label: str = "datetime"):
767
+ """Return `value`, or raise `ValueError` when it carries no offset.
768
+
769
+ A naive datetime reaching `astimezone()` is interpreted in the HOST's
770
+ zone, so every UTC date bucket in this module would silently depend on
771
+ the timezone the process happened to run under. The kernel accepts aware
772
+ datetimes only and the glue parses.
773
+
774
+ ANY offset is accepted, not only UTC: the measured store holds 9,749 rows
775
+ at +02:00 and 3,497 at +03:00, and this module compares instants rather
776
+ than wall clocks.
777
+ """
778
+ if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
779
+ raise ValueError(
780
+ f"{label} must be an aware datetime, got naive {value!r}")
781
+ return value
782
+
783
+
784
+ def canonical_week_anchor(value: "dt.datetime") -> "dt.datetime":
785
+ """Round a week boundary to the nearest hour.
786
+
787
+ Matches `_cctally_core._normalize_week_boundary_dt`: minutes 0-29 down,
788
+ 30-59 up. Nearest-hour is correct and hour-flooring is not — the measured
789
+ spread runs 07:59 to 08:28, and flooring would separate 07:59 from its own
790
+ week. Grouping by the raw value gave 125 segments where this gives 61.
791
+ """
792
+ normalized = require_aware(value, "week_start").replace(
793
+ second=0, microsecond=0)
794
+ if normalized.minute >= 30:
795
+ return (normalized + dt.timedelta(hours=1)).replace(minute=0)
796
+ return normalized.replace(minute=0)
797
+
798
+
799
+ def _snapshot_sort_key(row: SnapshotRecord):
800
+ """Total, reproducible order: instant, then higher percent, then rowid.
801
+
802
+ The percent leg uses the RAW stored value, not the floored integer.
803
+ Flooring makes two rows at one instant reading 10.2 and 10.7 tie, so the
804
+ higher-percent rule never applies and the order falls through to rowid.
805
+ The rowid is the final total-order guarantee, not the second key.
806
+ """
807
+ return (row.at, -row.percent, row.rowid)
808
+
809
+
810
+ def build_segments(snapshots, credits) -> list[Segment]:
811
+ """Cut the reading series into segments at authoritative boundaries only.
812
+
813
+ A segment ends at a canonical week-anchor change or at a credit instant,
814
+ and NEVER at a percentage decrease: the measured store holds 30 bare
815
+ decreases against 10 authoritative credit instants, so two thirds of the
816
+ research script's forks were unsupported.
817
+
818
+ A "reset" credit opens a newly anchored logical week, because a >=25pp
819
+ auto-credit re-anchors the week. A "floor" credit opens a new segment
820
+ retaining the existing week identity, because `record-credit` deliberately
821
+ does not re-anchor. When SEVERAL credits fall between two consecutive
822
+ snapshots the last RESET among them wins, not simply the last credit: a
823
+ reset followed by a floor is still a re-anchoring, and filing it as a
824
+ floor would silently merge the two paths section 5 keeps distinct.
825
+
826
+ The segment's published `week_anchor` and the value a week change is
827
+ detected against are tracked separately. A reset anchors the successor
828
+ from the CREDIT instant while the store's own rows carry their own
829
+ spelling of the new boundary, and the two round to different hours
830
+ whenever the credit straddles the half-hour — a credit at 03:29 with
831
+ post-reset rows recording 03:31 gives 03:00 and 04:00. Comparing later
832
+ rows against the credit-derived value forked a spurious third segment
833
+ that also lost `restarted`.
834
+ """
835
+ for row in snapshots:
836
+ require_aware(row.at, "snapshot.at")
837
+ for credit in credits:
838
+ require_aware(credit.at, "credit.at")
839
+ rows = sorted(snapshots, key=_snapshot_sort_key)
840
+ cuts = sorted(credits, key=lambda c: c.at)
841
+ segments: list[Segment] = []
842
+ current: list[SnapshotRecord] = []
843
+ anchor = None
844
+ anchor_ref = None
845
+ restarted = False
846
+ prev_at = None
847
+ for row in rows:
848
+ row_anchor = canonical_week_anchor(row.week_start)
849
+ crossed = None
850
+ if prev_at is not None:
851
+ spanned = [c for c in cuts if prev_at < c.at <= row.at]
852
+ if spanned:
853
+ resets = [c for c in spanned if c.kind == "reset"]
854
+ crossed = resets[-1] if resets else spanned[-1]
855
+ if current and (row_anchor != anchor_ref or crossed is not None):
856
+ segments.append(
857
+ Segment(len(segments), anchor, tuple(current), restarted)
858
+ )
859
+ current = []
860
+ if crossed is not None and crossed.kind == "reset":
861
+ anchor = canonical_week_anchor(crossed.at)
862
+ restarted = True
863
+ else:
864
+ anchor = row_anchor
865
+ restarted = False
866
+ anchor_ref = row_anchor
867
+ elif not current:
868
+ anchor = row_anchor
869
+ anchor_ref = row_anchor
870
+ restarted = False
871
+ current.append(row)
872
+ prev_at = row.at
873
+ if current:
874
+ segments.append(Segment(len(segments), anchor, tuple(current), restarted))
875
+ return segments
876
+
877
+
878
+ def _day_end(date) -> "dt.datetime":
879
+ """The first instant after `date`, in UTC."""
880
+ return dt.datetime.combine(
881
+ date + dt.timedelta(days=1), dt.time(0, 0), tzinfo=dt.timezone.utc
882
+ )
883
+
884
+
885
+ def day_has_ended(date, *, now) -> bool:
886
+ """The clock has passed the day's final instant.
887
+
888
+ Section 29 keeps this apart from `day_ingest_covers`. "The day is not over
889
+ yet" is a statement about time, not about local history: a day failing
890
+ THIS condition is excluded from the series entirely, carries no
891
+ withholding cause, and enters no health budget.
892
+ """
893
+ return require_aware(now, "now") >= _day_end(date)
894
+
895
+
896
+ def day_ingest_covers(date, *, newest_entry_at) -> bool:
897
+ """The ingest tail reaches past the day's final instant.
898
+
899
+ A day whose local token history has not finished ingesting looks exactly
900
+ like a day the meter moved without local work, so it is a real
901
+ local-history defect and keeps `no-local-history`.
902
+ """
903
+ if newest_entry_at is None:
904
+ return False
905
+ return require_aware(
906
+ newest_entry_at, "newest_entry_at") >= _day_end(date)
907
+
908
+
909
+ def day_is_complete(date, *, now, newest_entry_at) -> bool:
910
+ """Both conditions above hold.
911
+
912
+ Kept as one predicate for callers that want the conjunction, but the
913
+ series never uses it: the two conditions have different consequences and
914
+ conflating them made the current in-progress day report
915
+ `no-local-history`, which then withheld every verdict at exit 3.
916
+ """
917
+ return (day_has_ended(date, now=now)
918
+ and day_ingest_covers(date, newest_entry_at=newest_entry_at))
919
+
920
+
921
+ @dataclasses.dataclass(frozen=True)
922
+ class _DaySlice:
923
+ """One (segment, date) pair, classified exactly as the series classifies
924
+ it: "transition", "in-progress", "right-censored", "below-gain" or
925
+ "attributed".
926
+
927
+ The single definition both `build_daily_series` and `unattributed_units`
928
+ read, so the token windows the diagnostic complements cannot drift from
929
+ the ones the series actually summed.
930
+ """
931
+
932
+ segment_id: int
933
+ date: "dt.date"
934
+ first_at: "dt.datetime | None"
935
+ last_at: "dt.datetime | None"
936
+ meter_delta: float
937
+ kind: str
938
+
939
+
940
+ def _day_slices(segments, now):
941
+ """Classify every (segment, date) pair against the caller's clock.
942
+
943
+ `now` is required rather than optional, because both readers of these
944
+ slices — the series and the unattributed-token diagnostic — must agree on
945
+ which days contribute a token window. A day the clock has not passed
946
+ contributes none, so its tokens belong to no observation.
947
+ """
948
+ require_aware(now, "now")
949
+ min_gain = DETECTOR["min_daily_gain"]
950
+ date_segment_count: dict = {}
951
+ for seg in segments:
952
+ for date in {r.at.astimezone(dt.timezone.utc).date()
953
+ for r in seg.rows}:
954
+ date_segment_count[date] = date_segment_count.get(date, 0) + 1
955
+ for seg in segments:
956
+ by_day: dict = {}
957
+ for row in seg.rows:
958
+ by_day.setdefault(
959
+ row.at.astimezone(dt.timezone.utc).date(), []
960
+ ).append(row)
961
+ for date in sorted(by_day):
962
+ if date_segment_count.get(date, 0) > 1:
963
+ yield _DaySlice(seg.segment_id, date, None, None, 0.0,
964
+ "transition")
965
+ continue
966
+ if not day_has_ended(date, now=now):
967
+ yield _DaySlice(seg.segment_id, date, None, None, 0.0,
968
+ "in-progress")
969
+ continue
970
+ rows = sorted(by_day[date], key=_snapshot_sort_key)
971
+ first, last = rows[0], rows[-1]
972
+ start = true_percent_point(integer_percent(first.percent))
973
+ end = true_percent_point(integer_percent(last.percent))
974
+ if start is None or end is None:
975
+ yield _DaySlice(seg.segment_id, date, first.at, last.at, 0.0,
976
+ "right-censored")
977
+ continue
978
+ meter_delta = end - start
979
+ yield _DaySlice(
980
+ seg.segment_id, date, first.at, last.at, meter_delta,
981
+ "attributed" if meter_delta >= min_gain else "below-gain",
982
+ )
983
+
984
+
985
+ def build_daily_series(segments, entries, *, now,
986
+ newest_entry_at) -> list[DailyObservation]:
987
+ """One observation per complete UTC date, in date order.
988
+
989
+ Within one day and one segment the observation runs from the first to the
990
+ last reading by the total order above, meter movement comes from the
991
+ interval midpoints, and units are summed over the HALF-OPEN interval
992
+ `[first_instant, last_instant)` — an entry at exactly the closing instant
993
+ belongs to the next observation.
994
+
995
+ A date appearing in more than one segment is a transition day: it yields
996
+ exactly ONE withheld row and never two. That is the deterministic form of
997
+ "exclude the transition interval", and it also removes the duplicate-date
998
+ defect, so no calendar date ever contributes two rows and the consecutive
999
+ run counter cannot count one day twice.
1000
+
1001
+ A day whose meter moved less than the minimum daily gain is ABSENT from
1002
+ the series rather than withheld, because "consecutive" counts consecutive
1003
+ eligible days in the series and a barely-moved day must not occupy a slot
1004
+ in it. Its tokens are then attributed to no observation, which is what
1005
+ `unattributed_units` reports.
1006
+
1007
+ A day the clock has not passed is absent for the same reason and NOT
1008
+ withheld (section 29). Its meter has moved and its ingest is by definition
1009
+ unfinished, so reporting it as `no-local-history` asserts something false
1010
+ and, because it always falls on or after any split date, withheld every
1011
+ verdict at exit 3. `in_progress_dates` reports it as a diagnostic instead.
1012
+ """
1013
+ require_aware(now, "now")
1014
+ if newest_entry_at is not None:
1015
+ require_aware(newest_entry_at, "newest_entry_at")
1016
+ for entry in entries:
1017
+ require_aware(entry.at, "entry.at")
1018
+ ordered = sorted(entries, key=lambda e: e.at)
1019
+ instants = [e.at for e in ordered]
1020
+
1021
+ out: list[DailyObservation] = []
1022
+ emitted_transition: set = set()
1023
+ for slice_ in _day_slices(segments, now):
1024
+ date = slice_.date
1025
+ segment_id = slice_.segment_id
1026
+ if slice_.kind == "in-progress":
1027
+ continue
1028
+ if slice_.kind == "transition":
1029
+ if date not in emitted_transition:
1030
+ emitted_transition.add(date)
1031
+ out.append(DailyObservation(
1032
+ date, segment_id, 0.0, 0.0, {}, {},
1033
+ WithholdingCause.TRANSITION_DAY,
1034
+ ))
1035
+ continue
1036
+ if slice_.kind == "right-censored":
1037
+ out.append(DailyObservation(
1038
+ date, segment_id, 0.0, 0.0, {}, {},
1039
+ WithholdingCause.RIGHT_CENSORED,
1040
+ ))
1041
+ continue
1042
+ if slice_.kind == "below-gain":
1043
+ continue
1044
+
1045
+ lo = bisect.bisect_left(instants, slice_.first_at)
1046
+ hi = bisect.bisect_left(instants, slice_.last_at)
1047
+ # Two accumulators, because they answer different questions. `units`
1048
+ # is the WEIGHTED total the fit consumes; `quantities` is the RAW
1049
+ # token count per class, which is what the composition support vector
1050
+ # is (section 34) — a coefficient-weighted share cannot carry the
1051
+ # materiality floor derived from those same coefficients.
1052
+ raw = {name: 0.0 for name in TOKEN_CLASS_WEIGHTS}
1053
+ units = 0.0
1054
+ families: dict = {}
1055
+ split_unknown = False
1056
+ unsupported = False
1057
+ for entry in ordered[lo:hi]:
1058
+ family = normalize_family(entry.model)
1059
+ if family_participation(family) != "general":
1060
+ unsupported = True
1061
+ continue
1062
+ quantities = token_class_units(entry)
1063
+ if quantities is None:
1064
+ split_unknown = True
1065
+ continue
1066
+ entry_units = 0.0
1067
+ for name, qty in quantities.items():
1068
+ raw[name] += qty
1069
+ entry_units += qty * TOKEN_CLASS_WEIGHTS[name]
1070
+ units += entry_units
1071
+ families[family] = families.get(family, 0.0) + entry_units
1072
+ raw_total = sum(raw.values())
1073
+
1074
+ # Cause precedence follows STATUS_PRECEDENCE: an unknown split
1075
+ # outranks incomplete history, which outranks an unsupported mix.
1076
+ if split_unknown or not _finite_quantity(units) \
1077
+ or not _finite_quantity(raw_total):
1078
+ cause = WithholdingCause.TOKEN_SPLIT_UNKNOWN
1079
+ elif not day_ingest_covers(date, newest_entry_at=newest_entry_at):
1080
+ cause = WithholdingCause.NO_LOCAL_HISTORY
1081
+ elif unsupported:
1082
+ cause = WithholdingCause.UNSUPPORTED_COMPOSITION
1083
+ elif units <= 0.0 or raw_total <= 0.0:
1084
+ cause = WithholdingCause.NO_LOCAL_HISTORY
1085
+ else:
1086
+ cause = None
1087
+
1088
+ if cause is None:
1089
+ class_shares = {n: v / raw_total for n, v in raw.items()}
1090
+ family_shares = {f: v / units for f, v in families.items()}
1091
+ else:
1092
+ class_shares, family_shares = {}, {}
1093
+ out.append(DailyObservation(
1094
+ date, segment_id, slice_.meter_delta, units,
1095
+ class_shares, family_shares, cause,
1096
+ ))
1097
+ out.sort(key=lambda o: (o.date, o.segment_id))
1098
+ return out
1099
+
1100
+
1101
+ def in_progress_dates(segments, *, now) -> tuple:
1102
+ """UTC dates the series dropped because the clock had not passed them.
1103
+
1104
+ Section 29 requires this reported as the `inProgressDayExcluded`
1105
+ diagnostic. It is a separate entry point because `build_daily_series`
1106
+ returns observations and an excluded day is, by construction, not one.
1107
+
1108
+ The return value is a sorted set of dates, which is the contract callers
1109
+ read: one entry per calendar date, in ascending order, whatever order the
1110
+ segments happened to arrive in.
1111
+ """
1112
+ return tuple(sorted({sl.date for sl in _day_slices(segments, now)
1113
+ if sl.kind == "in-progress"}))
1114
+
1115
+
1116
+ def unattributed_units(segments, entries, *, now) -> dict:
1117
+ """Weighted units and entry count on NO observation's token interval.
1118
+
1119
+ Section 14 requires this diagnostic rather than a silent discard. An
1120
+ entry lands outside every interval when it precedes the day's first
1121
+ reading, follows its last, falls on a transition, in-progress or
1122
+ right-censored day, or falls on a day the meter barely moved — none of
1123
+ which contributes a row the series sums tokens over.
1124
+
1125
+ `now` is required because the in-progress day is one of those cases, and
1126
+ this diagnostic reads the same `_day_slices` the series does so the two
1127
+ cannot drift apart.
1128
+
1129
+ `entries` counts every such entry whatever its family. `units` sums only
1130
+ the general-quota entries whose token split is known, because that is the
1131
+ quantity the fit would have used; a dedicated-pool entry is reported as
1132
+ present and contributes nothing, exactly as it would inside an interval.
1133
+ """
1134
+ windows = sorted((s.first_at, s.last_at)
1135
+ for s in _day_slices(segments, now)
1136
+ if s.kind == "attributed")
1137
+ starts = [w[0] for w in windows]
1138
+ total = 0.0
1139
+ count = 0
1140
+ for entry in entries:
1141
+ require_aware(entry.at, "entry.at")
1142
+ index = bisect.bisect_right(starts, entry.at) - 1
1143
+ if index >= 0 and entry.at < windows[index][1]:
1144
+ continue
1145
+ count += 1
1146
+ if family_participation(normalize_family(entry.model)) != "general":
1147
+ continue
1148
+ units = weighted_units(entry)
1149
+ if units is not None:
1150
+ total += units
1151
+ return {"units": total, "entries": count}
1152
+
1153
+
1154
+ # ---------------------------------------------------------------------------
1155
+ # Robust statistics, the eligibility fence, and composition support (§5, §15).
1156
+ # ---------------------------------------------------------------------------
1157
+ def median(xs) -> float:
1158
+ """Median, NaN over an empty population rather than an exception."""
1159
+ values = list(xs)
1160
+ if not values:
1161
+ return float("nan")
1162
+ return statistics.median(values)
1163
+
1164
+
1165
+ def scaled_mad(xs, med=None) -> float:
1166
+ """`1.4826 * median(|x - median(x)|)`.
1167
+
1168
+ NaN below two observations, because a single point supplies no spread.
1169
+ A ZERO return is a real answer and means the population has no spread;
1170
+ the caller must treat the resulting fence as undefined rather than
1171
+ collapsing it to the median, which would flag every above-median day.
1172
+ """
1173
+ values = list(xs)
1174
+ if len(values) < 2:
1175
+ return float("nan")
1176
+ centre = median(values) if med is None else med
1177
+ return 1.4826 * median([abs(x - centre) for x in values])
1178
+
1179
+
1180
+ def eligibility_fence(days) -> float | None:
1181
+ """Lower unit fence: `expm1(median(x) - 3 * scaledMAD(x))` over
1182
+ `x = log1p(weighted_units)` of the positive-unit candidate days.
1183
+
1184
+ None when the fence is undefined — fewer than two positive days, or a
1185
+ zero scaled MAD. The caller then yields `unstable-fit`.
1186
+
1187
+ This depends on no scale constant, which is the point. The research
1188
+ script's `modelled <= 0.5` gate was a function of its shipped quota size
1189
+ and misdiagnosed 4 of 4 blind days on the measured store, every one of
1190
+ which carried between 0.8 and 1.2 million weighted units against a
1191
+ message claiming no local token history at all.
1192
+ """
1193
+ xs = [math.log1p(u) for u in days if u is not None and u > 0.0]
1194
+ if len(xs) < 2:
1195
+ return None
1196
+ centre = median(xs)
1197
+ spread = scaled_mad(xs, centre)
1198
+ if not math.isfinite(spread) or spread == 0.0:
1199
+ return None
1200
+ return math.expm1(centre - ELIGIBILITY["fence_mads"] * spread)
1201
+
1202
+
1203
+ def composition_centre(vectors) -> dict[str, float] | None:
1204
+ """Componentwise median of the share vectors, RENORMALIZED to sum to one.
1205
+
1206
+ A componentwise median need not be a distribution — the medians of
1207
+ (0.6,0.4,0), (0.4,0,0.6) and (0,0.6,0.4) are (0.4,0.4,0.4) — so the
1208
+ normalization is required, not cosmetic. None when the componentwise
1209
+ median is all zeros; the caller then yields `unsupported-model-mix`.
1210
+ """
1211
+ rows = [dict(v) for v in vectors]
1212
+ if not rows:
1213
+ return None
1214
+ keys = sorted({k for row in rows for k in row})
1215
+ raw = {k: median([row.get(k, 0.0) for row in rows]) for k in keys}
1216
+ total = sum(raw.values())
1217
+ if not math.isfinite(total) or total <= 0.0:
1218
+ return None
1219
+ return {k: v / total for k, v in raw.items()}
1220
+
1221
+
1222
+ def tv_distance(p, centre) -> float:
1223
+ """Total-variation distance, `0.5 * sum(|p_i - c_i|)`."""
1224
+ keys = set(p) | set(centre)
1225
+ return 0.5 * sum(abs(p.get(k, 0.0) - centre.get(k, 0.0)) for k in keys)
1226
+
1227
+
1228
+ def support_radius(vectors, centre) -> float | None:
1229
+ """`median(distance) + 3 * scaledMAD(distance)` from `centre`.
1230
+
1231
+ None when the radius is undefined. A hypothesis test was rejected for
1232
+ this role because failure to reject is not equivalence: at the measured
1233
+ three watch days the p-value was 0.314 on the only family carrying
1234
+ information.
1235
+ """
1236
+ if centre is None:
1237
+ return None
1238
+ distances = [tv_distance(dict(v), centre) for v in vectors]
1239
+ if len(distances) < 2:
1240
+ return None
1241
+ spread = scaled_mad(distances)
1242
+ if not math.isfinite(spread):
1243
+ return None
1244
+ return median(distances) + MIX_SUPPORT["fence_mads"] * spread
1245
+
1246
+
1247
+ def token_class_floor(centre) -> float | None:
1248
+ """Materiality floor for the token-class radius, at `centre`.
1249
+
1250
+ Derived, not asserted. For raw share distributions
1251
+ `|c.p - c.q| <= TV(p, q) * (cmax - cmin)`, so bounding the relative
1252
+ movement of the weighted-unit total at
1253
+ `MIX_SUPPORT["token_class_max_relative_effect"]` bounds the admissible
1254
+ total-variation distance at `effect * mu / spread`, where `mu` is the
1255
+ centre's own weighted value and `spread` is the coefficient range. At the
1256
+ shipped coefficients the spread is 4.73 - 0.0031 = 4.7269, so a fresh-only
1257
+ centre admits about 0.01058 — half the 10% detection-width gate and a
1258
+ third of the 15% prediction-width gate.
1259
+
1260
+ None when the centre is undefined, which is a mix finding in its own right
1261
+ and never reaches here.
1262
+ """
1263
+ if centre is None:
1264
+ return None
1265
+ mu = sum(float(centre.get(name, 0.0)) * weight
1266
+ for name, weight in TOKEN_CLASS_WEIGHTS.items())
1267
+ spread = max(TOKEN_CLASS_WEIGHTS.values()) - min(TOKEN_CLASS_WEIGHTS.values())
1268
+ if spread <= 0.0 or not math.isfinite(mu) or mu <= 0.0:
1269
+ return None
1270
+ return float(MIX_SUPPORT["token_class_max_relative_effect"]) * mu / spread
1271
+
1272
+
1273
+ def effective_radius(empirical, floor) -> float | None:
1274
+ """`max(empirical, floor)` — the radius that actually decides support.
1275
+
1276
+ A ZERO empirical radius is a real answer and means the reference
1277
+ population never varied, which is the ordinary single-model case and the
1278
+ case the standard fixture produces. Treating it as undefined and skipping
1279
+ the test would admit arbitrarily large shifts, including the 0.79
1280
+ token-class attack section 25 requires be caught; treating it as a literal
1281
+ zero locked a user out for adding one percent of a second model. The floor
1282
+ is the materiality bound between the two.
1283
+
1284
+ None only when the empirical radius is undefined, which means fewer than
1285
+ two reference observations.
1286
+ """
1287
+ if empirical is None:
1288
+ return None
1289
+ if floor is None or not math.isfinite(floor):
1290
+ return empirical
1291
+ return max(empirical, floor)
1292
+
1293
+
1294
+ def aggregate_composition(entries) -> tuple | None:
1295
+ """One `(family_shares, class_shares)` pair over a set of entries.
1296
+
1297
+ The forecast population is a set of entries rather than a set of days
1298
+ (section 35), so it needs its own aggregation. Family shares are by
1299
+ WEIGHTED units, matching `build_daily_series`; token-class shares are RAW
1300
+ quantity shares, matching section 34.
1301
+
1302
+ None when no general-quota entry with a known token split contributes,
1303
+ because an empty population supports no comparison.
1304
+ """
1305
+ raw = {name: 0.0 for name in TOKEN_CLASS_WEIGHTS}
1306
+ families: dict = {}
1307
+ for entry in entries:
1308
+ family = normalize_family(entry.model)
1309
+ if family_participation(family) != "general":
1310
+ continue
1311
+ quantities = token_class_units(entry)
1312
+ if quantities is None:
1313
+ continue
1314
+ units = 0.0
1315
+ for name, qty in quantities.items():
1316
+ raw[name] += qty
1317
+ units += qty * TOKEN_CLASS_WEIGHTS[name]
1318
+ families[family] = families.get(family, 0.0) + units
1319
+ raw_total = sum(raw.values())
1320
+ unit_total = sum(families.values())
1321
+ if not _finite_quantity(raw_total) or not _finite_quantity(unit_total):
1322
+ return None
1323
+ if raw_total <= 0.0 or unit_total <= 0.0:
1324
+ return None
1325
+ return ({f: v / unit_total for f, v in families.items()},
1326
+ {n: v / raw_total for n, v in raw.items()})
1327
+
1328
+
1329
+ def is_supported(family_shares, class_shares, family_centre, family_radius,
1330
+ class_centre, class_radius) -> bool:
1331
+ """A day is supported only when it lies inside BOTH radii.
1332
+
1333
+ The family radius alone is not enough: a workload can hold its family
1334
+ shares perfectly constant while shifting its output-to-cache composition
1335
+ enough to move the effective scale, and the token-class coefficients were
1336
+ themselves fitted on Opus-5-dominated traffic. An undefined centre or
1337
+ radius is unsupported, never "everything passes".
1338
+ """
1339
+ for shares, centre, radius in (
1340
+ (family_shares, family_centre, family_radius),
1341
+ (class_shares, class_centre, class_radius),
1342
+ ):
1343
+ if centre is None or radius is None:
1344
+ return False
1345
+ if tv_distance(dict(shares), centre) > radius:
1346
+ return False
1347
+ return True
1348
+
1349
+
1350
+ # ---------------------------------------------------------------------------
1351
+ # The fit (section 6 and 15).
1352
+ # ---------------------------------------------------------------------------
1353
+ def relative_width(interval, point) -> float | None:
1354
+ """`(hi - lo) / point`, or None when either end is undefined."""
1355
+ if interval is None or interval.hi is None or point is None or point <= 0:
1356
+ return None
1357
+ return (interval.hi - interval.lo) / point
1358
+
1359
+
1360
+ def intervals_disjoint(a, b) -> bool:
1361
+ """STRICT disjointness: `a.lo > b.hi` or `b.lo > a.hi`.
1362
+
1363
+ A non-strict comparison calls two touching intervals disjoint, which is
1364
+ one of the three conditions that manufacture a rate-change verdict.
1365
+ """
1366
+ if a is None or b is None or a.hi is None or b.hi is None:
1367
+ return False
1368
+ return a.lo > b.hi or b.lo > a.hi
1369
+
1370
+
1371
+ def jackknife_fit(days) -> tuple[float, Interval] | None:
1372
+ """Effective blended weighted units per general-weekly point.
1373
+
1374
+ The point estimate is total units over total meter movement, which is why
1375
+ any scale constant cancels. The interval is a delete-one day jackknife,
1376
+ `se = sqrt((n-1)/n * sum((v_i - mean)^2))`, using the two-sided Student-t
1377
+ quantile at n-1 degrees of freedom — NOT the research script's hardcoded
1378
+ 1.96, which at the three watch days this model actually fits is 4.303 and
1379
+ so nearly doubles the half-width. The lower bound is floored at zero.
1380
+
1381
+ None below two days, when any day carries a non-finite unit count or
1382
+ meter movement, or when the totals cannot support a ratio.
1383
+ """
1384
+ rows = list(days)
1385
+ n = len(rows)
1386
+ if n < 2:
1387
+ return None
1388
+ if any(not _finite_quantity(d.units) or not _finite_quantity(d.meter_delta)
1389
+ for d in rows):
1390
+ return None
1391
+ total_units = sum(d.units for d in rows)
1392
+ total_meter = sum(d.meter_delta for d in rows)
1393
+ if total_meter <= 0.0 or total_units <= 0.0:
1394
+ return None
1395
+ point = total_units / total_meter
1396
+ replicates = []
1397
+ for row in rows:
1398
+ rest_meter = total_meter - row.meter_delta
1399
+ if rest_meter <= 0.0:
1400
+ return None
1401
+ replicates.append((total_units - row.units) / rest_meter)
1402
+ mean = sum(replicates) / n
1403
+ se = math.sqrt((n - 1) / n * sum((v - mean) ** 2 for v in replicates))
1404
+ half = t_quantile_975(n - 1) * se
1405
+ return point, Interval(max(0.0, point - half), point + half)
1406
+
1407
+
1408
+ # ---------------------------------------------------------------------------
1409
+ # The exact conditional Wilcoxon rank-sum test (section 15).
1410
+ # ---------------------------------------------------------------------------
1411
+ def _mid_ranks(values) -> list[float]:
1412
+ """One-based ranks, tied values sharing their average rank."""
1413
+ order = sorted(range(len(values)), key=lambda i: values[i])
1414
+ ranks = [0.0] * len(values)
1415
+ i = 0
1416
+ while i < len(order):
1417
+ j = i
1418
+ while j + 1 < len(order) and values[order[j + 1]] == values[order[i]]:
1419
+ j += 1
1420
+ average = (i + j + 2) / 2.0
1421
+ for t in range(i, j + 1):
1422
+ ranks[order[t]] = average
1423
+ i = j + 1
1424
+ return ranks
1425
+
1426
+
1427
+ def _subset_sum_counts(values, k) -> tuple[int, list[int]]:
1428
+ """Counts of achievable sums when choosing exactly `k` of `values`.
1429
+
1430
+ Returns `(offset, counts)` where `counts[i]` is the number of k-subsets
1431
+ summing to `offset + i`. Dynamic programming over achievable sums, which
1432
+ is polynomial in the number of days, rather than enumeration of the
1433
+ C(n,k) arrangements, which is not.
1434
+
1435
+ The sum axis is bounded per intermediate size by the k smallest and k
1436
+ largest values, which keeps the table proportional to the achievable
1437
+ range rather than to the grand total. The caller picks the smaller of the
1438
+ two groups, so `k` never exceeds half the population.
1439
+ """
1440
+ ordered = sorted(values)
1441
+ total_count = len(ordered)
1442
+ low = [0] * (k + 1)
1443
+ high = [0] * (k + 1)
1444
+ for j in range(1, k + 1):
1445
+ low[j] = low[j - 1] + ordered[j - 1]
1446
+ high[j] = high[j - 1] + ordered[total_count - j]
1447
+ table = [[0] * (high[j] - low[j] + 1) for j in range(k + 1)]
1448
+ table[0][0] = 1
1449
+ for taken, value in enumerate(values):
1450
+ upper = min(k, taken + 1)
1451
+ for j in range(upper, 0, -1):
1452
+ source = table[j - 1]
1453
+ target = table[j]
1454
+ base = low[j - 1] + value - low[j]
1455
+ for index, count in enumerate(source):
1456
+ if count:
1457
+ target[base + index] += count
1458
+ return low[k], table[k]
1459
+
1460
+
1461
+ def rank_sum_p(baseline, watch) -> float:
1462
+ """Two-sided exact conditional Wilcoxon rank-sum on mid-ranks.
1463
+
1464
+ `p = min(1.0, 2 * min(P(W <= w), P(W >= w)))`. Mid-ranks are doubled so
1465
+ the null distribution is over integers, because a tie makes a rank a half
1466
+ integer.
1467
+
1468
+ The test is symmetric in direction, so a rate change downward is detected
1469
+ exactly as a change upward is.
1470
+ """
1471
+ left = list(baseline)
1472
+ right = list(watch)
1473
+ n, m = len(left), len(right)
1474
+ if n == 0 or m == 0:
1475
+ return 1.0
1476
+ ranks = _mid_ranks(left + right)
1477
+ doubled = [int(round(r * 2)) for r in ranks]
1478
+ # The complement of a k-subset is an (N-k)-subset, and the two tails swap
1479
+ # under that map, so the two-sided p is unchanged by taking the smaller
1480
+ # group. Doing so halves the worst-case table.
1481
+ if m <= n:
1482
+ k = m
1483
+ observed = sum(doubled[n:])
1484
+ else:
1485
+ k = n
1486
+ observed = sum(doubled[:n])
1487
+ offset, counts = _subset_sum_counts(doubled, k)
1488
+ arrangements = sum(counts)
1489
+ if arrangements <= 0:
1490
+ return 1.0
1491
+ index = observed - offset
1492
+ at_or_below = sum(counts[:index + 1]) if index >= 0 else 0
1493
+ at_or_above = sum(counts[index:]) if index < len(counts) else 0
1494
+ tail = min(at_or_below, at_or_above) / arrangements
1495
+ return min(1.0, 2.0 * tail)
1496
+
1497
+
1498
+ def holm(pvalues) -> list[float]:
1499
+ """Holm-Bonferroni adjusted p-values, returned in ascending order.
1500
+
1501
+ Adjusted value `i` is `max` over the first `i` of `(m - j) * p_j`, capped
1502
+ at one, which is what makes the sequence monotone.
1503
+ """
1504
+ ordered = sorted(pvalues)
1505
+ size = len(ordered)
1506
+ running = 0.0
1507
+ out: list[float] = []
1508
+ for i, p in enumerate(ordered):
1509
+ running = max(running, min(1.0, (size - i) * p))
1510
+ out.append(running)
1511
+ return out
1512
+
1513
+
1514
+ # ---------------------------------------------------------------------------
1515
+ # The change-point detector (section 5).
1516
+ # ---------------------------------------------------------------------------
1517
+ @dataclasses.dataclass(frozen=True)
1518
+ class DetectorResult:
1519
+ """What the scan found, and what it was allowed to look at.
1520
+
1521
+ `baseline_days` and `watch_days` describe the winning split and are BOTH
1522
+ zero when no split qualified, so the type never implies a split that does
1523
+ not exist; the size of the population actually scanned is
1524
+ `scanned_eligible_days`.
1525
+
1526
+ The truncation fields are disclosure, not a status: a bounded exact scan
1527
+ is still valid evidence, but concealing the bound would misstate both the
1528
+ days used and the Holm family size.
1529
+ """
1530
+
1531
+ split_date: "dt.date | None"
1532
+ raw_p: float | None
1533
+ holm_p: float | None
1534
+ holm_family_size: int
1535
+ baseline_days: int
1536
+ watch_days: int
1537
+ longest_run: int
1538
+ qualified: bool
1539
+ input_eligible_days: int
1540
+ scanned_eligible_days: int
1541
+ truncated_eligible_days: int
1542
+ scan_start_date: "dt.date | None"
1543
+ max_auto_scan_days: int
1544
+ history_truncated: bool
1545
+
1546
+
1547
+ def detector_diagnostics(detector: DetectorResult) -> dict:
1548
+ """`detector` as JSON-serializable camelCase, for `method.detector`.
1549
+
1550
+ `dataclasses.asdict` cannot be used: it emits snake_case keys into an
1551
+ otherwise camelCase envelope, and it leaves `split_date` as a
1552
+ `datetime.date`, which `json.dumps` refuses.
1553
+ """
1554
+ return {
1555
+ "splitDate": (detector.split_date.isoformat()
1556
+ if detector.split_date is not None else None),
1557
+ "rawP": detector.raw_p,
1558
+ "holmP": detector.holm_p,
1559
+ "holmFamilySize": detector.holm_family_size,
1560
+ "baselineDays": detector.baseline_days,
1561
+ "watchDays": detector.watch_days,
1562
+ "longestRun": detector.longest_run,
1563
+ "qualified": detector.qualified,
1564
+ "inputEligibleDays": detector.input_eligible_days,
1565
+ "scannedEligibleDays": detector.scanned_eligible_days,
1566
+ "truncatedEligibleDays": detector.truncated_eligible_days,
1567
+ "scanStartDate": (detector.scan_start_date.isoformat()
1568
+ if detector.scan_start_date is not None else None),
1569
+ "maxAutoScanDays": detector.max_auto_scan_days,
1570
+ "historyTruncated": detector.history_truncated,
1571
+ }
1572
+
1573
+
1574
+ def _longest_run_beyond(values, low_fence, high_fence) -> int:
1575
+ """Longest run of consecutive values beyond a fence in ONE direction.
1576
+
1577
+ Both directions are measured and the larger returned, so a metering rate
1578
+ that fell is detected exactly as one that rose.
1579
+ """
1580
+ best = 0
1581
+ for fence, sign in ((high_fence, 1), (low_fence, -1)):
1582
+ if fence is None:
1583
+ continue
1584
+ run = 0
1585
+ for v in values:
1586
+ beyond = v > fence if sign > 0 else v < fence
1587
+ run = run + 1 if beyond else 0
1588
+ best = max(best, run)
1589
+ return best
1590
+
1591
+
1592
+ def detect_change(series, *, override_split=None) -> DetectorResult:
1593
+ """Scan every admissible split for a change in the implied scale.
1594
+
1595
+ The per-day statistic is `log(units / meter_delta)`, the log of that day's
1596
+ implied units-per-point, with no constant dividing it.
1597
+
1598
+ The Holm family is EVERY admissible split — at least
1599
+ `TRUST["min_detect_baseline_days"]` baseline and
1600
+ `DETECTOR["min_watch_days"]` watch eligible days. The other qualification
1601
+ gates are applied AFTER Holm and never remove a member, because removing
1602
+ members post hoc would invalidate the correction.
1603
+
1604
+ A split qualifies only when its Holm-adjusted p clears alpha, at least
1605
+ `min_watch_days` consecutive watch days lie beyond the baseline fence in
1606
+ the same direction, both fits meet their width gates, and the two
1607
+ intervals are strictly disjoint. Among qualifying splits the smallest raw
1608
+ p wins, ties breaking to the earliest date.
1609
+
1610
+ `override_split` is the maintainer override. It bypasses the scan and the
1611
+ multiplicity correction ONLY — never the width, run or disjointness gates.
1612
+ It is also exempt from the scan horizon below, because it evaluates one
1613
+ operator-selected split rather than running a corrected scan.
1614
+
1615
+ The automatic scan is bounded to the most recent
1616
+ `DETECTOR["max_auto_scan_days"]` eligible days. That retained set is the
1617
+ COMPLETE rank-sum population — mid-ranks, every admissible split and the
1618
+ Holm correction are all computed within it. Merely restricting the
1619
+ candidate split dates while leaving the population unbounded would not
1620
+ bound the cost, because the dynamic program over the population dominates
1621
+ it: measured at roughly O(N^5.5), an unbounded scan over the 201 eligible
1622
+ days this model was built from blocks for over six minutes. The horizon
1623
+ is fixed before any p-value is observed, so the correction stays valid.
1624
+ """
1625
+ days = [o for o in series
1626
+ if o.cause is None
1627
+ and math.isfinite(o.units) and o.units > 0.0
1628
+ and math.isfinite(o.meter_delta) and o.meter_delta > 0.0]
1629
+ days.sort(key=lambda o: o.date)
1630
+ input_days = len(days)
1631
+ horizon = int(DETECTOR["max_auto_scan_days"])
1632
+ if override_split is None and input_days > horizon:
1633
+ days = days[-horizon:]
1634
+ total = len(days)
1635
+ truncated = input_days - total
1636
+ scan_start = days[0].date if days else None
1637
+ stats = [math.log(o.units / o.meter_delta) for o in days]
1638
+ min_base = int(TRUST["min_detect_baseline_days"])
1639
+ min_watch = int(DETECTOR["min_watch_days"])
1640
+ fence_mads = DETECTOR["fence_mads"]
1641
+ alpha = DETECTOR["alpha"]
1642
+
1643
+ def _result(split_date, raw_p, holm_p, family, baseline, watch, run,
1644
+ qualified):
1645
+ return DetectorResult(
1646
+ split_date=split_date, raw_p=raw_p, holm_p=holm_p,
1647
+ holm_family_size=family, baseline_days=baseline, watch_days=watch,
1648
+ longest_run=run, qualified=qualified,
1649
+ input_eligible_days=input_days, scanned_eligible_days=total,
1650
+ truncated_eligible_days=truncated, scan_start_date=scan_start,
1651
+ max_auto_scan_days=horizon, history_truncated=truncated > 0,
1652
+ )
1653
+
1654
+ if override_split is not None:
1655
+ forced = [k for k in range(total) if days[k].date >= override_split]
1656
+ admissible = forced[:1] if forced else []
1657
+ else:
1658
+ admissible = list(range(min_base, total - min_watch + 1))
1659
+
1660
+ if not admissible:
1661
+ return _result(None, None, None, len(admissible), 0, 0, 0, False)
1662
+
1663
+ raw = [rank_sum_p(stats[:k], stats[k:]) for k in admissible]
1664
+ if override_split is None:
1665
+ order = sorted(range(len(raw)), key=lambda i: raw[i])
1666
+ adjusted_sorted = holm([raw[i] for i in order])
1667
+ adjusted = [0.0] * len(raw)
1668
+ for position, i in enumerate(order):
1669
+ adjusted[i] = adjusted_sorted[position]
1670
+ else:
1671
+ adjusted = list(raw)
1672
+
1673
+ winner = None
1674
+ best_run = 0
1675
+ for i, k in enumerate(admissible):
1676
+ baseline, watch = days[:k], days[k:]
1677
+ if len(baseline) < min_base or len(watch) < min_watch:
1678
+ continue
1679
+ centre = median(stats[:k])
1680
+ spread = scaled_mad(stats[:k], centre)
1681
+ if math.isfinite(spread) and spread > 0.0:
1682
+ run = _longest_run_beyond(
1683
+ stats[k:], centre - fence_mads * spread,
1684
+ centre + fence_mads * spread,
1685
+ )
1686
+ else:
1687
+ run = 0
1688
+ best_run = max(best_run, run)
1689
+ baseline_fit = jackknife_fit(baseline)
1690
+ watch_fit = jackknife_fit(watch)
1691
+ if baseline_fit is None or watch_fit is None:
1692
+ continue
1693
+ baseline_width = relative_width(baseline_fit[1], baseline_fit[0])
1694
+ watch_width = relative_width(watch_fit[1], watch_fit[0])
1695
+ qualifies = (
1696
+ adjusted[i] <= alpha
1697
+ and run >= min_watch
1698
+ and baseline_width is not None
1699
+ and baseline_width <= TRUST["max_detect_baseline_width"]
1700
+ and watch_width is not None
1701
+ and watch_width <= TRUST["max_detect_watch_width"]
1702
+ and intervals_disjoint(baseline_fit[1], watch_fit[1])
1703
+ )
1704
+ if not qualifies:
1705
+ continue
1706
+ candidate = (raw[i], days[k].date, i, k, run)
1707
+ if winner is None or candidate[:2] < winner[:2]:
1708
+ winner = candidate
1709
+ if winner is None:
1710
+ return _result(None, None, None, len(admissible), 0, 0, best_run,
1711
+ False)
1712
+ _, split_date, i, k, run = winner
1713
+ return _result(split_date, raw[i], adjusted[i], len(admissible), k,
1714
+ total - k, run, True)
1715
+
1716
+
1717
+ # ---------------------------------------------------------------------------
1718
+ # The second pass: the adaptive unit floor, then the trust gates.
1719
+ # ---------------------------------------------------------------------------
1720
+ def apply_eligibility_fence(series, fence) -> list[DailyObservation]:
1721
+ """Mark positive-unit days below `fence` as `sparse-local-history`.
1722
+
1723
+ `fence` of None leaves the series untouched, because an undefined fence
1724
+ is not a reason to withhold every day; the caller reports `unstable-fit`
1725
+ separately. Zero-unit days already carry `no-local-history` and are a
1726
+ different absence with a different remedy.
1727
+ """
1728
+ if fence is None:
1729
+ return list(series)
1730
+ out = []
1731
+ for o in series:
1732
+ if o.cause is None and 0.0 < o.units < fence:
1733
+ out.append(dataclasses.replace(
1734
+ o, cause=WithholdingCause.SPARSE_LOCAL_HISTORY,
1735
+ ))
1736
+ else:
1737
+ out.append(o)
1738
+ return out
1739
+
1740
+
1741
+ def classify(series, fit, detector, *, fingerprint_matches, newest_at,
1742
+ now, fence, population=None) -> CalibrationStatus:
1743
+ """The single top-level status, resolved through `worst_status`.
1744
+
1745
+ `series` is the PUBLISHED WINDOW, not all of retained history (section
1746
+ 30). Every withholding-cause count that feeds a status decision is
1747
+ computed over it, because scanning all retained history makes one bad day
1748
+ anywhere permanent. Days outside the window are reported in diagnostics
1749
+ and decide nothing.
1750
+
1751
+ The fit-driven gates are read from `population`, the day list `fit` was
1752
+ actually computed over, defaulting to every eligible day in the window.
1753
+
1754
+ `fence` is the ONE eligibility floor of section 5, computed by the caller
1755
+ over the pre-watch prefix and passed in rather than recomputed here. An
1756
+ undefined fence means the population has no spread, which is
1757
+ `unstable-fit`.
1758
+
1759
+ `unvalidated-coefficient-era` is deliberately NOT produced here: deciding
1760
+ that an era's composition has no coefficient validation needs an era
1761
+ catalogue the glue owns, so the glue combines its own finding with this
1762
+ one through `worst_status`.
1763
+ """
1764
+ require_aware(now, "now")
1765
+ if newest_at is not None:
1766
+ require_aware(newest_at, "newest_at")
1767
+ statuses: list[CalibrationStatus] = []
1768
+ if newest_at is not None and newest_at > now + dt.timedelta(seconds=60):
1769
+ statuses.append(CalibrationStatus.FUTURE)
1770
+ if not fingerprint_matches:
1771
+ statuses.append(CalibrationStatus.STALE)
1772
+
1773
+ causes = {o.cause for o in series if o.cause is not None}
1774
+ if WithholdingCause.TOKEN_SPLIT_UNKNOWN in causes:
1775
+ statuses.append(CalibrationStatus.TOKEN_SPLIT_UNKNOWN)
1776
+
1777
+ eligible = [o for o in series if o.cause is None]
1778
+ # The section 6 budget — one baseline day, at most 5%, zero inside the
1779
+ # decisive run — is the INCOMPLETE-HISTORY budget and counts
1780
+ # `no-local-history` alone. `sparse-local-history` is a different absence
1781
+ # with a different remedy (section 14): merging them told a user whose
1782
+ # days merely fall below the eligibility fence that the ingest tail does
1783
+ # not cover them.
1784
+ absent = [o for o in series
1785
+ if o.cause is WithholdingCause.NO_LOCAL_HISTORY]
1786
+ considered = len(eligible) + len(absent)
1787
+ over_budget = bool(absent) and (
1788
+ len(absent) > TRUST["max_incomplete_baseline_days"]
1789
+ or (considered
1790
+ and len(absent) / considered
1791
+ > TRUST["max_incomplete_baseline_fraction"])
1792
+ )
1793
+ inside_run = False
1794
+ if detector is not None and detector.qualified and detector.split_date:
1795
+ inside_run = any(o.date >= detector.split_date for o in absent)
1796
+ if over_budget or inside_run:
1797
+ statuses.append(CalibrationStatus.LOCAL_HISTORY_INCOMPLETE)
1798
+
1799
+ if WithholdingCause.UNSUPPORTED_COMPOSITION in causes:
1800
+ statuses.append(CalibrationStatus.UNSUPPORTED_MODEL_MIX)
1801
+
1802
+ fit_days = eligible if population is None else list(population)
1803
+ # Section 33: a fit that CANNOT be computed and a fit that exists over too
1804
+ # few days are different conditions. Reporting the former as a day-count
1805
+ # shortfall let a confirmed change reach exit 1 with no successor fit at
1806
+ # all, because section 23's exit-1 path names `insufficient-history`.
1807
+ if fit is None:
1808
+ statuses.append(CalibrationStatus.UNSTABLE_FIT)
1809
+ elif len(fit_days) < TRUST["min_fit_days"]:
1810
+ statuses.append(CalibrationStatus.INSUFFICIENT_HISTORY)
1811
+ else:
1812
+ point, interval = fit
1813
+ segment_ids = sorted({o.segment_id for o in fit_days})
1814
+ if len(segment_ids) > 1:
1815
+ for segment_id in segment_ids:
1816
+ rest = [o for o in fit_days if o.segment_id != segment_id]
1817
+ left_out = jackknife_fit(rest)
1818
+ if left_out is None or not (
1819
+ interval.lo <= left_out[0] <= (interval.hi or math.inf)
1820
+ ):
1821
+ statuses.append(CalibrationStatus.FRAGMENTED_HISTORY)
1822
+ break
1823
+ width = relative_width(interval, point)
1824
+ if width is None or width > TRUST["max_fit_width"] or fence is None:
1825
+ statuses.append(CalibrationStatus.UNSTABLE_FIT)
1826
+ return worst_status(statuses)
1827
+
1828
+
1829
+ def blocking_reasons(series, detector) -> tuple:
1830
+ """The `BlockingReason` members the published window carries.
1831
+
1832
+ Section 31's rule, and section 38's home for it. Section 5 requires that a
1833
+ day below the eligibility fence inside the decisive run withhold the
1834
+ verdict rather than relocate the change point. That is a statement about
1835
+ the verdict, not about the health of the population the command publishes,
1836
+ so it produces a reason here and never a status in `classify`. The two
1837
+ rules therefore live in different functions and neither can be implemented
1838
+ as a side effect of the other.
1839
+
1840
+ There is no decisive run without a confirmed split, so an unsplit series
1841
+ blocks nothing however sparse its days are.
1842
+ """
1843
+ if (detector is None or not detector.qualified
1844
+ or detector.split_date is None):
1845
+ return ()
1846
+ if any(o.cause is WithholdingCause.SPARSE_LOCAL_HISTORY
1847
+ and o.date >= detector.split_date for o in series):
1848
+ return (BlockingReason.SPARSE_DAY_IN_DECISIVE_RUN,)
1849
+ return ()
1850
+
1851
+
1852
+ # ---------------------------------------------------------------------------
1853
+ # Composition.
1854
+ # ---------------------------------------------------------------------------
1855
+ @dataclasses.dataclass(frozen=True)
1856
+ class QuotaAnalysis:
1857
+ """One account's complete analysis.
1858
+
1859
+ `fitted`, `consumption`, `projection` and `headroom` describe the
1860
+ SUCCESSOR regime alone after a confirmed change, never a value blended
1861
+ across the split, because a blended fit describes neither of the two
1862
+ regimes the persistence reducer creates. `baseline_fit` and `watch_fit`
1863
+ publish the two regimes as evidence in their own right.
1864
+
1865
+ `status` and `verdict` are independent axes: the status describes the
1866
+ current predictive calibration, the verdict describes the detector, and a
1867
+ `BlockingReason` is a third input that withholds the verdict alone
1868
+ (section 38). `exit_code` is their joint resolution and is the value the
1869
+ command returns; reading an exit code from `STATUS_EXIT` alone is wrong
1870
+ whenever the detector confirmed a change or a reason blocked the verdict.
1871
+ Each blocking reason is published as a qualification on `fitted`,
1872
+ `consumption`, `projection` and `headroom`, so a renderer can state what
1873
+ happened rather than infer a remedy from a status that does not apply.
1874
+ """
1875
+
1876
+ status: CalibrationStatus
1877
+ verdict: Verdict
1878
+ exit_code: int
1879
+ fitted: CalibrationEvidence
1880
+ consumption: CalibrationEvidence
1881
+ projection: CalibrationEvidence
1882
+ headroom: CalibrationEvidence
1883
+ baseline_fit: CalibrationEvidence
1884
+ watch_fit: CalibrationEvidence
1885
+ observed_percent: int | None
1886
+ #: The BASELINE composition centres, which the support radii are measured
1887
+ #: from, and the CURRENT ones over the published population. Sections 8
1888
+ #: and 21 require both pairs; publishing only the baseline's leaves a
1889
+ #: reader unable to see what actually moved.
1890
+ family_shares: dict
1891
+ class_shares: dict
1892
+ current_family_shares: dict
1893
+ current_class_shares: dict
1894
+ #: The EFFECTIVE radii of section 34, which are
1895
+ #: `max(empirical, floor)` -- NOT the measured spread. A single-model
1896
+ #: user's measured 0.0 appears here as the 0.02 family floor, so a
1897
+ #: renderer must not label these as observed quantities. The empirical
1898
+ #: values are published separately as `familyRadiusEmpirical` and
1899
+ #: `classRadiusEmpirical` in `diagnostics`.
1900
+ family_radius: float | None
1901
+ class_radius: float | None
1902
+ detector: DetectorResult
1903
+ #: Why the verdict was withheld while the status stayed healthy, per
1904
+ #: section 38. A closed vocabulary, so a renderer matches enum members
1905
+ #: rather than searching `qualifications`, which carries seven unrelated
1906
+ #: string vocabularies and would silently stop matching when a second
1907
+ #: reason is added.
1908
+ blocking: tuple
1909
+ diagnostics: dict
1910
+
1911
+
1912
+ def _composition_unsupported(*, confirmed, reference_days, decisive_days,
1913
+ forecast_probed=False,
1914
+ forecast_shares, family_centre, family_radius,
1915
+ class_centre, class_radius) -> bool:
1916
+ """Apply section 18's two-radius test, which section 25 requires.
1917
+
1918
+ Two probes, because section 18 names two populations:
1919
+
1920
+ * every DECISIVE WATCH DAY, individually, after a confirmed change;
1921
+ * the FORECAST POPULATION, as one aggregate.
1922
+
1923
+ The forecast population is the current-regime entries whose units become
1924
+ `consumption`, `projection` and `headroom` (section 35), supplied
1925
+ separately by the caller. Comparing the published population's own centre
1926
+ against the reference centre instead was dead in both branches: with no
1927
+ confirmed change the two are the same days, so the distance is identically
1928
+ zero, and after a change the per-day probe already covers those days more
1929
+ strictly.
1930
+
1931
+ The forecast probe is an AGGREGATE rather than per-day. A radius of median
1932
+ plus three scaled MADs leaves an ordinary tail outside it by construction,
1933
+ so a per-day rule here would flip most healthy workloads to exit 3.
1934
+
1935
+ An UNDEFINED CENTRE is unsupported (section 25). An undefined RADIUS is
1936
+ not: it means the reference population held fewer than two observations,
1937
+ which is thin evidence rather than a mix finding, and reporting it as one
1938
+ would move a thin population from exit 4 to exit 3.
1939
+ """
1940
+ if not reference_days:
1941
+ return False
1942
+ if family_centre is None or class_centre is None:
1943
+ return True
1944
+ if family_radius is None or class_radius is None:
1945
+ return False
1946
+ # A population that WAS probed but could not be aggregated is unsupported,
1947
+ # not exempt. Every entry from an unrecognised family contributes no
1948
+ # units, so `aggregate_composition` returns None and the probe list would
1949
+ # otherwise stay empty -- disabling the section 18 test exactly as an
1950
+ # omitted `forecast_population` would, which is what section 40 made that
1951
+ # parameter required to prevent.
1952
+ if forecast_probed and forecast_shares is None:
1953
+ return True
1954
+ probes = [(o.family_shares, o.class_shares)
1955
+ for o in (decisive_days if confirmed else ())]
1956
+ if forecast_shares is not None:
1957
+ probes.append(forecast_shares)
1958
+ return any(
1959
+ not is_supported(family_shares, class_shares, family_centre,
1960
+ family_radius, class_centre, class_radius)
1961
+ for family_shares, class_shares in probes
1962
+ )
1963
+
1964
+
1965
+ def analyse(series, *, now, newest_at, forecast_population,
1966
+ fingerprint_matches=True, observed_percent=None,
1967
+ current_week_units=None, current_week_start=None,
1968
+ current_week_end=None, override_split=None, extra_statuses=(),
1969
+ unattributed=None, in_progress_excluded=None,
1970
+ diagnostics=None) -> QuotaAnalysis:
1971
+ """Run both passes and return one account's complete analysis.
1972
+
1973
+ Pass one chooses the split from budget-independent structural candidates.
1974
+ Pass two computes the adaptive unit floor from the selected pre-watch
1975
+ prefix — or from the whole regime when no split qualified — and applies
1976
+ the trust gates. The floor never feeds back into split selection, so a
1977
+ sparse day inside the decisive run withholds the verdict rather than
1978
+ relocating the change point.
1979
+
1980
+ `extra_statuses` lets the glue contribute the statuses this kernel cannot
1981
+ see, such as `unavailable` from a store failure or
1982
+ `unvalidated-coefficient-era` from its era catalogue; they are merged
1983
+ through `worst_status`, so the precedence order stays in one place.
1984
+
1985
+ `forecast_population` is the set of `EntryRecord`s whose units become
1986
+ `consumption`, `projection` and `headroom` — the current regime, not the
1987
+ historical fit population (section 35). It is the population section 18
1988
+ requires be supported, and it is supplied separately because the kernel
1989
+ cannot derive it from the daily series. It is REQUIRED and carries no
1990
+ default: an omitted population disables section 35's probe entirely and
1991
+ returns `ok` at exit 0, so a caller that never wired it must fail loudly
1992
+ rather than lose a spec-mandated check. Pass an explicit empty tuple to
1993
+ state that the population really is empty.
1994
+
1995
+ `unattributed` is the dict `unattributed_units()` returns and
1996
+ `in_progress_excluded` the tuple `in_progress_dates()` returns. Both are
1997
+ always published, as null when the caller supplied none, so a glue that
1998
+ never wired a diagnostic is visible on the wire rather than silently
1999
+ absent.
2000
+
2001
+ Every numeric parameter is checked for finiteness and sign (section 32). A
2002
+ non-finite or negative value withholds the evidence that depends on it
2003
+ with a typed code, because a NaN propagates through the headroom
2004
+ arithmetic into a published interval instead of raising.
2005
+ """
2006
+ require_aware(now, "now")
2007
+ for label, value in (("newest_at", newest_at),
2008
+ ("current_week_start", current_week_start),
2009
+ ("current_week_end", current_week_end)):
2010
+ if value is not None:
2011
+ require_aware(value, label)
2012
+ forecast_entries = list(forecast_population)
2013
+ for entry in forecast_entries:
2014
+ require_aware(entry.at, "forecast_entry.at")
2015
+
2016
+ rejected: list[str] = []
2017
+ if current_week_units is not None and not _usable_quantity(
2018
+ current_week_units):
2019
+ rejected.append("current_week_units")
2020
+ current_week_units = None
2021
+ week_units_invalid = True
2022
+ else:
2023
+ week_units_invalid = False
2024
+ if observed_percent is not None and not _usable_quantity(observed_percent):
2025
+ rejected.append("observed_percent")
2026
+ observed_percent = None
2027
+
2028
+ # `analyse` is a public entry point and takes "most recent" positionally
2029
+ # through `eligible[-n:]`, so it sorts rather than assuming its caller did.
2030
+ series = sorted(series, key=lambda o: (o.date, o.segment_id))
2031
+ detector = detect_change(series, override_split=override_split)
2032
+ # The fence shares the detector's horizon so that the scan, the floor and
2033
+ # the fit all judge the same span of history. A fence computed over every
2034
+ # retained day would be a floor on absolute daily volume taken across
2035
+ # eras whose units-per-point differ several-fold.
2036
+ structural = [o for o in series if o.cause is None
2037
+ and (detector.scan_start_date is None
2038
+ or o.date >= detector.scan_start_date)]
2039
+ if detector.split_date is not None:
2040
+ prefix = [o for o in structural if o.date < detector.split_date]
2041
+ else:
2042
+ prefix = structural
2043
+ fence = eligibility_fence([o.units for o in prefix])
2044
+ fenced = apply_eligibility_fence(series, fence)
2045
+
2046
+ eligible = [o for o in fenced if o.cause is None]
2047
+ # Successor-only publication is spec section 22.
2048
+ confirmed = detector.qualified and detector.split_date is not None
2049
+ if confirmed:
2050
+ baseline_days = [o for o in eligible if o.date < detector.split_date]
2051
+ published_days = [o for o in eligible
2052
+ if o.date >= detector.split_date]
2053
+ else:
2054
+ baseline_days = []
2055
+ published_days = eligible[-max_fit_population_days():]
2056
+ baseline_raw = jackknife_fit(baseline_days)
2057
+ fit = jackknife_fit(published_days)
2058
+ # The reference centres and radii come from the regime that ESTABLISHED
2059
+ # the composition, so a successor is measured against what preceded it.
2060
+ # With no confirmed change the bounded fit population is the reference,
2061
+ # and what is measured against it is the separately supplied forecast
2062
+ # population rather than those same days.
2063
+ reference_days = baseline_days if confirmed else published_days
2064
+ family_centre = composition_centre(
2065
+ [o.family_shares for o in reference_days])
2066
+ class_centre = composition_centre(
2067
+ [o.class_shares for o in reference_days])
2068
+ family_radius = support_radius(
2069
+ [o.family_shares for o in reference_days], family_centre)
2070
+ class_radius = support_radius(
2071
+ [o.class_shares for o in reference_days], class_centre)
2072
+ current_family_centre = composition_centre(
2073
+ [o.family_shares for o in published_days])
2074
+ current_class_centre = composition_centre(
2075
+ [o.class_shares for o in published_days])
2076
+ # The radius that DECIDES is the effective one (section 34). The empirical
2077
+ # values are published as diagnostics so a reader can see when the floor
2078
+ # bound rather than the observed spread.
2079
+ family_radius_empirical, class_radius_empirical = (family_radius,
2080
+ class_radius)
2081
+ family_radius = effective_radius(
2082
+ family_radius, float(MIX_SUPPORT["family_tv_floor"]))
2083
+ class_radius = effective_radius(
2084
+ class_radius, token_class_floor(class_centre))
2085
+
2086
+ # Section 35: after a confirmed change only the entries at or after the
2087
+ # successor boundary belong to the current regime, and they are measured
2088
+ # against the BASELINE references, which `reference_days` already is.
2089
+ if confirmed:
2090
+ probed_entries = [
2091
+ e for e in forecast_entries
2092
+ if e.at.astimezone(dt.timezone.utc).date() >= detector.split_date]
2093
+ else:
2094
+ probed_entries = forecast_entries
2095
+ forecast_shares = (aggregate_composition(probed_entries)
2096
+ if probed_entries else None)
2097
+
2098
+ contributed = list(extra_statuses)
2099
+ if _composition_unsupported(
2100
+ confirmed=confirmed, reference_days=reference_days,
2101
+ decisive_days=published_days, forecast_shares=forecast_shares,
2102
+ forecast_probed=bool(probed_entries),
2103
+ family_centre=family_centre, family_radius=family_radius,
2104
+ class_centre=class_centre, class_radius=class_radius):
2105
+ contributed.append(CalibrationStatus.UNSUPPORTED_MODEL_MIX)
2106
+ if confirmed:
2107
+ window_start = detector.split_date
2108
+ elif published_days:
2109
+ window_start = published_days[0].date
2110
+ else:
2111
+ window_start = None
2112
+ window = [o for o in fenced
2113
+ if window_start is None or o.date >= window_start]
2114
+
2115
+ # The status is classified over the population actually PUBLISHED — the
2116
+ # successor regime after a confirmed change, the bounded fit population
2117
+ # otherwise — and its health is scanned over that same window (section
2118
+ # 30), so a bad day in history the command neither fits nor scans cannot
2119
+ # withhold a verdict forever.
2120
+ status = worst_status(
2121
+ [classify(window, fit, detector, newest_at=newest_at, now=now,
2122
+ fingerprint_matches=fingerprint_matches, fence=fence,
2123
+ population=published_days)]
2124
+ + contributed
2125
+ )
2126
+ blocking = blocking_reasons(window, detector)
2127
+ verdict, exit_code = resolve_outcome(
2128
+ status, change_detected=detector.qualified, blocking=blocking)
2129
+
2130
+ population = {
2131
+ "days": len(published_days),
2132
+ "segments": len({o.segment_id for o in published_days}),
2133
+ "withheld": sum(1 for o in window if o.cause is not None),
2134
+ "considered": len(window),
2135
+ }
2136
+ support = Support(population["days"], population["segments"])
2137
+
2138
+ baseline_population = {
2139
+ "days": len(baseline_days),
2140
+ "segments": len({o.segment_id for o in baseline_days}),
2141
+ "withheld": sum(1 for o in fenced
2142
+ if o.cause is not None
2143
+ and (window_start is None or o.date < window_start)),
2144
+ "considered": len(fenced) - len(window),
2145
+ }
2146
+ if not confirmed:
2147
+ no_split = ("no-confirmed-split",)
2148
+ baseline_fit = evidence_withheld(
2149
+ CalibrationStatus.INSUFFICIENT_HISTORY, baseline_population,
2150
+ no_split)
2151
+ watch_fit = evidence_withheld(
2152
+ CalibrationStatus.INSUFFICIENT_HISTORY, population, no_split)
2153
+ else:
2154
+ superseded = ("superseded",
2155
+ f"split-at:{detector.split_date.isoformat()}")
2156
+ if baseline_raw is None:
2157
+ baseline_fit = evidence_withheld(
2158
+ CalibrationStatus.INSUFFICIENT_HISTORY, baseline_population,
2159
+ superseded)
2160
+ else:
2161
+ baseline_fit = evidence_available(
2162
+ baseline_raw[0], baseline_raw[1],
2163
+ Support(baseline_population["days"],
2164
+ baseline_population["segments"]),
2165
+ baseline_population, superseded)
2166
+ prediction_ready = status is CalibrationStatus.OK and fit is not None
2167
+ if fit is None:
2168
+ watch_fit = evidence_withheld(
2169
+ CalibrationStatus.INSUFFICIENT_HISTORY, population,
2170
+ ("successor-regime",))
2171
+ else:
2172
+ watch_fit = evidence_available(
2173
+ fit[0], fit[1], support, population,
2174
+ ("successor-regime",) if prediction_ready
2175
+ else ("successor-regime", "detection-only"))
2176
+
2177
+ published_qualifications = (("successor-regime",) if confirmed else ()) \
2178
+ + tuple(reason.value for reason in blocking)
2179
+ if status is not CalibrationStatus.OK or fit is None:
2180
+ withheld_code = status.value if status is not CalibrationStatus.OK \
2181
+ else CalibrationStatus.INSUFFICIENT_HISTORY.value
2182
+ fitted = evidence_withheld(withheld_code, population,
2183
+ published_qualifications)
2184
+ consumption = evidence_withheld(withheld_code, population,
2185
+ published_qualifications)
2186
+ headroom = evidence_withheld(withheld_code, population,
2187
+ published_qualifications)
2188
+ projection = evidence_withheld(withheld_code, population,
2189
+ published_qualifications)
2190
+ else:
2191
+ point, interval = fit
2192
+ fitted = evidence_available(point, interval, support, population,
2193
+ published_qualifications)
2194
+ if current_week_units is None:
2195
+ # Section 27's principle, which section 40 extends from the week
2196
+ # WINDOW to the week FIGURE: a caller supplying nothing is not the
2197
+ # user's history being missing. Both absences are `unavailable`
2198
+ # and the qualification says which one happened.
2199
+ code = CalibrationStatus.UNAVAILABLE.value
2200
+ marks = published_qualifications + (
2201
+ ("current-week-units-invalid",) if week_units_invalid
2202
+ else ("current-week-units-unknown",))
2203
+ consumption = evidence_withheld(code, population, marks)
2204
+ headroom = evidence_withheld(code, population, marks)
2205
+ projection = evidence_withheld(code, population, marks)
2206
+ else:
2207
+ used = current_week_units / point
2208
+ # A larger units-per-point means a smaller consumption, so the
2209
+ # bounds invert. A floored-at-zero lower bound leaves the upper
2210
+ # consumption bound unbounded rather than infinite.
2211
+ used_lo = current_week_units / interval.hi if interval.hi else None
2212
+ used_hi = (current_week_units / interval.lo
2213
+ if interval.lo > 0.0 else None)
2214
+ consumption = evidence_available(
2215
+ used, Interval(used_lo if used_lo is not None else 0.0,
2216
+ used_hi), support, population,
2217
+ published_qualifications)
2218
+ # Headroom is quota remaining, so it lives in [0, 100]. The
2219
+ # point and BOTH bounds go through one clamp, which is monotone,
2220
+ # so `lo <= point <= hi` survives it. Clamping the lower bound
2221
+ # alone inverts the interval for a user above 100% of quota.
2222
+ headroom = evidence_available(
2223
+ _clamp_percent(100.0 - used),
2224
+ Interval(
2225
+ _clamp_percent(100.0 - used_hi)
2226
+ if used_hi is not None else 0.0,
2227
+ _clamp_percent(100.0 - used_lo)
2228
+ if used_lo is not None else 100.0,
2229
+ ),
2230
+ support, population, published_qualifications)
2231
+ projection = _project(used, consumption.interval, support,
2232
+ population, now=now,
2233
+ week_start=current_week_start,
2234
+ week_end=current_week_end,
2235
+ qualifications=published_qualifications)
2236
+
2237
+ merged = dict(diagnostics or {})
2238
+ merged.update({
2239
+ "detector": detector_diagnostics(detector),
2240
+ "eligibilityFence": fence,
2241
+ "eligibleDays": len(eligible),
2242
+ "baselineDays": len(baseline_days),
2243
+ "currentRegimeDays": len(published_days),
2244
+ "withheldDays": len(fenced) - len(eligible),
2245
+ "dedicatedPoolScope": DEDICATED_POOL_SCOPE_NOTE,
2246
+ "familyRadiusEmpirical": family_radius_empirical,
2247
+ "classRadiusEmpirical": class_radius_empirical,
2248
+ "forecastPopulationEntries": len(probed_entries),
2249
+ "rejectedNumericInputs": sorted(rejected),
2250
+ "unattributedUnits": (None if unattributed is None
2251
+ else float(unattributed["units"])),
2252
+ "unattributedEntries": (None if unattributed is None
2253
+ else int(unattributed["entries"])),
2254
+ "inProgressDayExcluded": (
2255
+ None if in_progress_excluded is None
2256
+ else [d.isoformat() for d in in_progress_excluded]),
2257
+ })
2258
+ if consumption.value is not None and observed_percent is not None:
2259
+ merged["observedMinusModelled"] = observed_percent - consumption.value
2260
+ # `json.dumps` emits a bare `NaN` for a non-finite float, which is not
2261
+ # valid JSON, and a caller-supplied diagnostic is as capable of carrying
2262
+ # one as this kernel is.
2263
+ merged = _finite_json(merged)
2264
+ return QuotaAnalysis(
2265
+ status=status, verdict=verdict, exit_code=exit_code, fitted=fitted,
2266
+ consumption=consumption, projection=projection, headroom=headroom,
2267
+ baseline_fit=baseline_fit, watch_fit=watch_fit,
2268
+ observed_percent=observed_percent,
2269
+ family_shares=family_centre or {}, class_shares=class_centre or {},
2270
+ current_family_shares=current_family_centre or {},
2271
+ current_class_shares=current_class_centre or {},
2272
+ family_radius=family_radius, class_radius=class_radius,
2273
+ detector=detector, blocking=tuple(blocking), diagnostics=merged,
2274
+ )
2275
+
2276
+
2277
+ def _usable_quantity(value) -> bool:
2278
+ """A finite, non-negative number the arithmetic below can consume."""
2279
+ return _finite_quantity(value) and value >= 0.0
2280
+
2281
+
2282
+ def _finite_json(value):
2283
+ """`value` with every non-finite float replaced by None, recursively.
2284
+
2285
+ KEYS as well as values. `json.dumps({float("nan"): 1})` emits
2286
+ `{"NaN": 1}`, which is not valid JSON, and a dict key was the one
2287
+ remaining path by which a non-finite float reached the serializer.
2288
+ """
2289
+ if isinstance(value, float) and not math.isfinite(value):
2290
+ return None
2291
+ if isinstance(value, dict):
2292
+ return {_finite_json(k): _finite_json(v) for k, v in value.items()}
2293
+ if isinstance(value, (list, tuple)):
2294
+ return [_finite_json(v) for v in value]
2295
+ return value
2296
+
2297
+
2298
+ def _clamp_percent(value: float) -> float:
2299
+ """Confine a meter-point quantity to the [0, 100] the meter can express.
2300
+
2301
+ Headroom is quota remaining, so both limbs belong to its definition
2302
+ whether or not an input reaches them. The function must stay monotone,
2303
+ because the caller applies it to the point and to both interval bounds
2304
+ and relies on `lo <= point <= hi` surviving.
2305
+ """
2306
+ return max(0.0, min(100.0, value))
2307
+
2308
+
2309
+ def _project(used, interval, support, population, *, now, week_start,
2310
+ week_end, qualifications=()) -> CalibrationEvidence:
2311
+ """Linear pace projection of consumption to the week's end.
2312
+
2313
+ A missing week window is `unavailable`, not `no-local-history`: the
2314
+ latter tells the user their token history is missing when the actual
2315
+ cause is that the caller supplied no window.
2316
+ """
2317
+ if week_start is None or week_end is None:
2318
+ return evidence_withheld(
2319
+ CalibrationStatus.UNAVAILABLE, population,
2320
+ tuple(qualifications) + ("week-window-unknown",))
2321
+ elapsed = (min(now, week_end) - week_start).total_seconds()
2322
+ span = (week_end - week_start).total_seconds()
2323
+ if elapsed <= 0 or span <= 0:
2324
+ return evidence_withheld(
2325
+ CalibrationStatus.UNAVAILABLE, population,
2326
+ tuple(qualifications) + ("week-window-invalid",))
2327
+ factor = span / elapsed
2328
+ scaled = Interval(
2329
+ interval.lo * factor,
2330
+ interval.hi * factor if interval.hi is not None else None,
2331
+ ) if interval is not None else None
2332
+ return evidence_available(used * factor, scaled, support, population,
2333
+ qualifications)