cctally 1.103.0 → 1.104.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/bin/_cctally_alerts.py +65 -4
- package/bin/_cctally_config.py +62 -2
- package/bin/_cctally_core.py +62 -1
- package/bin/_cctally_dashboard.py +11 -0
- package/bin/_cctally_dashboard_envelope.py +319 -14
- package/bin/_cctally_dashboard_share.py +56 -25
- package/bin/_cctally_doctor.py +63 -0
- package/bin/_cctally_forecast.py +917 -44
- package/bin/_cctally_journal.py +153 -5
- package/bin/_cctally_parser.py +66 -0
- package/bin/_cctally_project.py +535 -10
- package/bin/_cctally_quota.py +13 -0
- package/bin/_cctally_quota_calibration.py +146 -0
- package/bin/_cctally_quota_model.py +1616 -0
- package/bin/_cctally_record.py +114 -6
- package/bin/_cctally_share.py +16 -8
- package/bin/_cctally_statusline.py +34 -0
- package/bin/_cctally_tui.py +214 -45
- package/bin/_lib_dashboard_settings_contract.py +2 -0
- package/bin/_lib_doctor.py +159 -1
- package/bin/_lib_forecast.py +337 -43
- package/bin/_lib_meter_rate_change.py +294 -0
- package/bin/_lib_quota_calibration.py +311 -0
- package/bin/_lib_quota_copy.py +131 -0
- package/bin/_lib_quota_model.py +2333 -0
- package/bin/_lib_rederive.py +10 -0
- package/bin/_lib_render.py +6 -0
- package/bin/_lib_share_templates.py +37 -5
- package/bin/_lib_statusline.py +226 -2
- package/bin/_lib_view_models.py +30 -12
- package/bin/cctally +32 -0
- package/dashboard/static/assets/index-D19TO7Mg.js +97 -0
- package/dashboard/static/assets/index-klO46NcU.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +7 -1
- package/dashboard/static/assets/index-Di2hljvB.css +0 -1
- package/dashboard/static/assets/index-XYCIWjVG.js +0 -97
|
@@ -0,0 +1,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)
|