cctally 1.116.1 → 1.117.2
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 +47 -0
- package/README.md +4 -6
- package/bin/_cctally_account.py +57 -3
- package/bin/_cctally_cache.py +5720 -885
- package/bin/_cctally_claude_attribution.py +997 -0
- package/bin/_cctally_completion.py +101 -28
- package/bin/_cctally_core.py +235 -12
- package/bin/_cctally_dashboard.py +263 -17
- package/bin/_cctally_dashboard_conversation.py +107 -21
- package/bin/_cctally_dashboard_envelope.py +44 -0
- package/bin/_cctally_db.py +928 -80
- package/bin/_cctally_diagnosis_sources.py +78 -13
- package/bin/_cctally_doctor.py +211 -86
- package/bin/_cctally_five_hour.py +446 -22
- package/bin/_cctally_forecast.py +302 -6
- package/bin/_cctally_journal.py +2418 -158
- package/bin/_cctally_milestone_history.py +379 -91
- package/bin/_cctally_oauth_samples.py +185 -7
- package/bin/_cctally_parser.py +38 -0
- package/bin/_cctally_project.py +34 -8
- package/bin/_cctally_quarantine.py +821 -343
- package/bin/_cctally_quota.py +69 -4
- package/bin/_cctally_quota_calibration.py +677 -3
- package/bin/_cctally_quota_model.py +927 -127
- package/bin/_cctally_record.py +922 -445
- package/bin/_cctally_rederive.py +119 -15
- package/bin/_cctally_statusline.py +1048 -176
- package/bin/_cctally_store.py +544 -110
- package/bin/_cctally_tui.py +455 -42
- package/bin/_cctally_update.py +77 -14
- package/bin/_lib_account_protocol.py +227 -19
- package/bin/_lib_claude_attribution.py +421 -0
- package/bin/_lib_clone_copy.py +30 -0
- package/bin/_lib_codex_conversation_query.py +45 -9
- package/bin/_lib_codex_conversation_watch.py +72 -13
- package/bin/_lib_codex_rollout_container.py +234 -19
- package/bin/_lib_codex_worker_lifecycle.py +374 -0
- package/bin/_lib_credit.py +22 -13
- package/bin/_lib_dashboard_sources.py +257 -18
- package/bin/_lib_detector_access_registry.py +1335 -166
- package/bin/_lib_doctor.py +276 -92
- package/bin/_lib_ingest_frontier.py +269 -36
- package/bin/_lib_journal.py +273 -22
- package/bin/_lib_jsonl.py +12 -0
- package/bin/_lib_meter_rate_change.py +72 -0
- package/bin/_lib_outcome_quarantine.py +917 -554
- package/bin/_lib_provider_activity.py +24 -6
- package/bin/_lib_quota_calibration.py +57 -3
- package/bin/_lib_quota_copy.py +7 -2
- package/bin/_lib_quota_lineage.py +613 -0
- package/bin/_lib_quota_model.py +464 -33
- package/bin/_lib_rate_change_delivery.py +16 -0
- package/bin/_lib_reclaim_planner.py +6 -4
- package/bin/_lib_rederive.py +2046 -21
- package/bin/_lib_selector_state.py +131 -3
- package/bin/_lib_snapshot_cache.py +8 -0
- package/bin/_lib_stale_proof.py +113 -4
- package/bin/_lib_statusline.py +72 -3
- package/bin/_lib_statusline_candidates.py +246 -42
- package/bin/_lib_tick_stats.py +10 -5
- package/bin/_lib_write_io.py +419 -6
- package/bin/cctally +28 -11
- package/dashboard/static/assets/{ConversationsView-Dflb_myA.js → ConversationsView-oqNsWzWV.js} +1 -1
- package/dashboard/static/assets/{DoctorModal-DFX5CfIs.js → DoctorModal-B0DnU9d9.js} +1 -1
- package/dashboard/static/assets/ModalRoot-CYua2wP_.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-C3PJy1xx.js → ProjectsDrillPanel-DSxXQSa-.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-BczsWKhD.js → SourceDetailModal-BleDpQqG.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-Os-Lo5cs.js → UpdateModal-CMqaAkSK.js} +1 -1
- package/dashboard/static/assets/{index-CYZ3NLqF.js → index-BjQV-0-D.js} +3 -3
- package/dashboard/static/assets/index-D34eYhAc.css +1 -0
- package/dashboard/static/assets/{outlineNavigation-k-zIif6W.js → outlineNavigation-CO-VWBm7.js} +1 -1
- package/dashboard/static/assets/{useKeymap-CW7Up0Ay.js → useKeymap-CUgEnnua.js} +1 -1
- package/dashboard/static/dashboard.html +3 -3
- package/package.json +5 -1
- package/dashboard/static/assets/ModalRoot-HlxoagIa.js +0 -1
- package/dashboard/static/assets/index-BKITk1CT.css +0 -1
package/bin/_lib_quota_model.py
CHANGED
|
@@ -207,13 +207,41 @@ MIX_SUPPORT: dict[str, object] = {
|
|
|
207
207
|
"effective_radius": "max(empirical, floor)",
|
|
208
208
|
"family_tv_floor": 0.03,
|
|
209
209
|
"token_class_max_relative_effect": 0.05,
|
|
210
|
+
# #991: a forecast family shift outside the family radius is still
|
|
211
|
+
# admitted when the era's meter-evidence rate bounds keep its worst-case
|
|
212
|
+
# effect on the forecast within this fraction (equality admits). A
|
|
213
|
+
# separate key from the token-class threshold above, so moving one never
|
|
214
|
+
# silently moves the other.
|
|
215
|
+
"family_max_relative_effect": 0.05,
|
|
210
216
|
}
|
|
211
217
|
|
|
218
|
+
#: The composition-support rule's revision. 1 is the two-radius
|
|
219
|
+
#: total-variation rule alone; 2 adds the evidence-bounded family test for
|
|
220
|
+
#: forecasts (#991). Fingerprinted, so a calibration fitted under another
|
|
221
|
+
#: rule is `stale`.
|
|
222
|
+
MIX_SUPPORT_POLICY_REVISION: int = 2
|
|
223
|
+
|
|
212
224
|
#: The fast-mode policy every validated era applies: fast-mode requests are
|
|
213
225
|
#: billed to usage credits only and never reach the weekly meter.
|
|
214
226
|
FAST_MODE_PARTICIPATION: str = "usage-credits-only-excluded"
|
|
215
227
|
|
|
216
228
|
|
|
229
|
+
@dataclasses.dataclass(frozen=True)
|
|
230
|
+
class FamilyRateBound:
|
|
231
|
+
"""Meter evidence for one family's general-weekly rate per kernel unit.
|
|
232
|
+
|
|
233
|
+
`low` and `high` bound the family's metering rate relative to its era's
|
|
234
|
+
reference family (#991 spec 4.1). They decide composition SUPPORT only:
|
|
235
|
+
no weighting path reads them and no multiplier built from them is ever
|
|
236
|
+
applied to units, so the public blended-weekly contract is unchanged.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
family: str
|
|
240
|
+
low: float
|
|
241
|
+
high: float
|
|
242
|
+
provenance: str
|
|
243
|
+
|
|
244
|
+
|
|
217
245
|
@dataclasses.dataclass(frozen=True)
|
|
218
246
|
class CoefficientEra:
|
|
219
247
|
"""One validated coefficient era: a half-open UTC date interval, the four
|
|
@@ -230,11 +258,24 @@ class CoefficientEra:
|
|
|
230
258
|
weights: tuple
|
|
231
259
|
fast_mode_participation: str
|
|
232
260
|
provenance: str
|
|
261
|
+
#: The canonical family every rate bound is relative to (#991). Its own
|
|
262
|
+
#: rate is 1 by definition, so it never carries a bound.
|
|
263
|
+
reference_family: str
|
|
264
|
+
#: Meter-evidence rate bounds, in table order, for the families whose
|
|
265
|
+
#: rate the evidence identifies. A family without one is unbounded.
|
|
266
|
+
family_rate_bounds: tuple
|
|
233
267
|
|
|
234
268
|
def weight_map(self) -> dict[str, float]:
|
|
235
269
|
"""The weights as a fresh `{class: weight}` dict the caller may own."""
|
|
236
270
|
return {name: float(weight) for name, weight in self.weights}
|
|
237
271
|
|
|
272
|
+
def rate_bound(self, family: str) -> "FamilyRateBound | None":
|
|
273
|
+
"""The era's bound for `family`, or None when it carries none."""
|
|
274
|
+
for bound in self.family_rate_bounds:
|
|
275
|
+
if bound.family == family:
|
|
276
|
+
return bound
|
|
277
|
+
return None
|
|
278
|
+
|
|
238
279
|
|
|
239
280
|
#: The ordered, immutable table of validated coefficient eras (spec 4.2).
|
|
240
281
|
#: Exactly one era covers any instant from 2026-07-25 on; earlier history is
|
|
@@ -244,6 +285,8 @@ COEFFICIENT_ERAS: tuple = (
|
|
|
244
285
|
CoefficientEra(
|
|
245
286
|
id="opus-5",
|
|
246
287
|
start=dt.date(2026, 7, 25),
|
|
288
|
+
reference_family="claude-opus-5",
|
|
289
|
+
family_rate_bounds=(),
|
|
247
290
|
end=dt.date(2026, 9, 22),
|
|
248
291
|
weights=(("fresh", 1.0), ("output", 4.73), ("cache_1h", 1.03),
|
|
249
292
|
("cache_read", 0.0031)),
|
|
@@ -253,6 +296,15 @@ COEFFICIENT_ERAS: tuple = (
|
|
|
253
296
|
CoefficientEra(
|
|
254
297
|
id="opus-5-5",
|
|
255
298
|
start=dt.date(2026, 9, 22),
|
|
299
|
+
reference_family="claude-opus-5-5",
|
|
300
|
+
# #991 spec 2.2 and 4.10: the k = 2 (nominal simultaneous 95%) exact
|
|
301
|
+
# segment-bootstrap envelope over both frozen captures and the three
|
|
302
|
+
# variants, rounded outward to hundredths. Haiku 5.5 and the legacy
|
|
303
|
+
# families stay unbounded: their rates are not identified.
|
|
304
|
+
family_rate_bounds=(
|
|
305
|
+
FamilyRateBound("claude-sonnet-5-5", 0.09, 0.61,
|
|
306
|
+
"991-meter-evidence-2026-10-23"),
|
|
307
|
+
),
|
|
256
308
|
end=None,
|
|
257
309
|
weights=(("fresh", 1.0), ("output", 0.64), ("cache_1h", 1.0),
|
|
258
310
|
("cache_read", 0.0148)),
|
|
@@ -264,6 +316,58 @@ COEFFICIENT_ERAS: tuple = (
|
|
|
264
316
|
)
|
|
265
317
|
|
|
266
318
|
|
|
319
|
+
def validate_coefficient_eras(eras) -> None:
|
|
320
|
+
"""Raise `ValueError` naming the first violated era-table invariant.
|
|
321
|
+
|
|
322
|
+
Called at import over `COEFFICIENT_ERAS` (#991 spec 4.1), so a malformed
|
|
323
|
+
table fails loudly rather than letting the materiality test admit on
|
|
324
|
+
evidence that does not exist.
|
|
325
|
+
"""
|
|
326
|
+
for era in eras:
|
|
327
|
+
reference = era.reference_family
|
|
328
|
+
if FAMILY_PARTICIPATION.get(reference) != "general":
|
|
329
|
+
raise ValueError(
|
|
330
|
+
f"era {era.id!r}: reference family {reference!r} is not a "
|
|
331
|
+
f"canonical general family")
|
|
332
|
+
seen = set()
|
|
333
|
+
for bound in era.family_rate_bounds:
|
|
334
|
+
if bound.family == reference:
|
|
335
|
+
raise ValueError(
|
|
336
|
+
f"era {era.id!r}: a rate bound names the reference "
|
|
337
|
+
f"family {reference!r}")
|
|
338
|
+
if FAMILY_PARTICIPATION.get(bound.family) != "general":
|
|
339
|
+
raise ValueError(
|
|
340
|
+
f"era {era.id!r}: bound family {bound.family!r} is not "
|
|
341
|
+
f"a canonical general family")
|
|
342
|
+
if bound.family in seen:
|
|
343
|
+
raise ValueError(
|
|
344
|
+
f"era {era.id!r}: bound family {bound.family!r} is not "
|
|
345
|
+
f"unique")
|
|
346
|
+
seen.add(bound.family)
|
|
347
|
+
if not (_finite_number(bound.low) and _finite_number(bound.high)):
|
|
348
|
+
raise ValueError(
|
|
349
|
+
f"era {era.id!r}: bound for {bound.family!r} is not "
|
|
350
|
+
f"finite")
|
|
351
|
+
if not 0.0 <= bound.low <= bound.high:
|
|
352
|
+
raise ValueError(
|
|
353
|
+
f"era {era.id!r}: bound for {bound.family!r} violates "
|
|
354
|
+
f"0 <= low <= high")
|
|
355
|
+
if not isinstance(bound.provenance, str) \
|
|
356
|
+
or not bound.provenance.strip():
|
|
357
|
+
raise ValueError(
|
|
358
|
+
f"era {era.id!r}: bound for {bound.family!r} has no "
|
|
359
|
+
f"provenance")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _finite_number(value) -> bool:
|
|
363
|
+
"""A real, finite int or float, never a bool or a string."""
|
|
364
|
+
return (isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
365
|
+
and math.isfinite(value))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
validate_coefficient_eras(COEFFICIENT_ERAS)
|
|
369
|
+
|
|
370
|
+
|
|
267
371
|
def _era_payload(era: CoefficientEra) -> dict:
|
|
268
372
|
return {
|
|
269
373
|
"id": era.id,
|
|
@@ -272,6 +376,11 @@ def _era_payload(era: CoefficientEra) -> dict:
|
|
|
272
376
|
"tokenClassWeights": era.weight_map(),
|
|
273
377
|
"fastModeParticipation": era.fast_mode_participation,
|
|
274
378
|
"provenance": era.provenance,
|
|
379
|
+
"referenceFamily": era.reference_family,
|
|
380
|
+
"familyRateBounds": [
|
|
381
|
+
{"family": b.family, "low": b.low, "high": b.high,
|
|
382
|
+
"provenance": b.provenance}
|
|
383
|
+
for b in era.family_rate_bounds],
|
|
275
384
|
}
|
|
276
385
|
|
|
277
386
|
#: The reading-to-interval rule of section 4. Bumped when that rule changes.
|
|
@@ -301,8 +410,8 @@ DEDICATED_POOL_SCOPE_NOTE: str = (
|
|
|
301
410
|
"only general-quota draining is modelled"
|
|
302
411
|
)
|
|
303
412
|
|
|
304
|
-
QUOTA_MODEL_ALGORITHM_REVISION: int =
|
|
305
|
-
QUOTA_MODEL_VERIFIED_AT: str = "2026-10-
|
|
413
|
+
QUOTA_MODEL_ALGORITHM_REVISION: int = 5
|
|
414
|
+
QUOTA_MODEL_VERIFIED_AT: str = "2026-10-30"
|
|
306
415
|
|
|
307
416
|
|
|
308
417
|
def constants_payload() -> dict:
|
|
@@ -323,6 +432,7 @@ def constants_payload() -> dict:
|
|
|
323
432
|
"detector": dict(DETECTOR),
|
|
324
433
|
"trust": dict(TRUST),
|
|
325
434
|
"mixSupport": dict(MIX_SUPPORT),
|
|
435
|
+
"mixSupportPolicyRevision": MIX_SUPPORT_POLICY_REVISION,
|
|
326
436
|
"coefficientEras": [_era_payload(era) for era in COEFFICIENT_ERAS],
|
|
327
437
|
}
|
|
328
438
|
|
|
@@ -339,7 +449,7 @@ def constants_fingerprint() -> str:
|
|
|
339
449
|
#: constant above without re-pinning this line is caught rather than silently
|
|
340
450
|
#: invalidating every persisted calibration.
|
|
341
451
|
QUOTA_MODEL_CONSTANTS_FINGERPRINT: str = (
|
|
342
|
-
"
|
|
452
|
+
"dfdd6a7de768802d3008d88bab453db6480812dc266187c593c3d4604554d42f"
|
|
343
453
|
)
|
|
344
454
|
|
|
345
455
|
|
|
@@ -619,9 +729,17 @@ def worst_status(statuses) -> CalibrationStatus:
|
|
|
619
729
|
#: member. An observation-level defect uses a cause; a calibration-level
|
|
620
730
|
#: trust failure uses the corresponding status, so an immature successor
|
|
621
731
|
#: carries `insufficient-history`. No seventh `WithholdingCause` is added.
|
|
732
|
+
#:
|
|
733
|
+
#: #1015 spec 5.4 adds one stored-calibration refusal, `recalibration-needed`:
|
|
734
|
+
#: the open regime was fitted before a correction to its inputs. It is neither
|
|
735
|
+
#: a `CalibrationStatus` (no precedence or exit-code change) nor a
|
|
736
|
+
#: `WithholdingCause`, so the union has sixteen members.
|
|
737
|
+
RECALIBRATION_NEEDED: str = "recalibration-needed"
|
|
738
|
+
|
|
622
739
|
EVIDENCE_CODES: frozenset = frozenset(
|
|
623
740
|
[c.value for c in WithholdingCause]
|
|
624
741
|
+ [s.value for s in CalibrationStatus if s is not CalibrationStatus.OK]
|
|
742
|
+
+ [RECALIBRATION_NEEDED]
|
|
625
743
|
)
|
|
626
744
|
|
|
627
745
|
|
|
@@ -748,6 +866,24 @@ def family_participation(family: str | None) -> str:
|
|
|
748
866
|
return FAMILY_PARTICIPATION.get(family, "unknown")
|
|
749
867
|
|
|
750
868
|
|
|
869
|
+
def _float_total(values) -> float:
|
|
870
|
+
"""`math.fsum` of `values`, or the plain `sum()` when `fsum` cannot.
|
|
871
|
+
|
|
872
|
+
`fsum` is correctly rounded, so a total is the same on Python 3.11, whose
|
|
873
|
+
float `sum()` is uncompensated, and on 3.12+, whose `sum()` is not; a
|
|
874
|
+
plain sum printed different last digits from one interpreter to the next.
|
|
875
|
+
But `fsum` raises where `sum()` saturates: `OverflowError` once finite
|
|
876
|
+
terms pass the float range, `ValueError` on `inf + -inf`. The plain sum's
|
|
877
|
+
`inf`/`nan` is what the section-39 `_finite_quantity` guards withhold, so
|
|
878
|
+
that path returns it unchanged rather than raising out of the kernel.
|
|
879
|
+
"""
|
|
880
|
+
items = list(values)
|
|
881
|
+
try:
|
|
882
|
+
return math.fsum(items)
|
|
883
|
+
except (OverflowError, ValueError):
|
|
884
|
+
return sum(items)
|
|
885
|
+
|
|
886
|
+
|
|
751
887
|
def _finite_quantity(value) -> bool:
|
|
752
888
|
"""True when a token quantity is a real, finite number.
|
|
753
889
|
|
|
@@ -883,8 +1019,8 @@ def class_units(classes, era: CoefficientEra) -> float:
|
|
|
883
1019
|
a multiplication of its own.
|
|
884
1020
|
"""
|
|
885
1021
|
weights = era.weight_map()
|
|
886
|
-
return
|
|
887
|
-
|
|
1022
|
+
return _float_total(float(classes.get(name, 0.0)) * weights[name]
|
|
1023
|
+
for name in TOKEN_CLASSES)
|
|
888
1024
|
|
|
889
1025
|
|
|
890
1026
|
class EntryWeighting(typing.NamedTuple):
|
|
@@ -1345,7 +1481,7 @@ def build_daily_series(segments, entries, *, now, newest_entry_at,
|
|
|
1345
1481
|
entry_units = class_units(quantities, era)
|
|
1346
1482
|
units += entry_units
|
|
1347
1483
|
families[family] = families.get(family, 0.0) + entry_units
|
|
1348
|
-
raw_total =
|
|
1484
|
+
raw_total = _float_total(raw.values())
|
|
1349
1485
|
|
|
1350
1486
|
# Cause precedence follows STATUS_PRECEDENCE: an unknown split
|
|
1351
1487
|
# outranks incomplete history, which outranks an unsupported mix.
|
|
@@ -1580,7 +1716,7 @@ def composition_centre(vectors) -> dict[str, float] | None:
|
|
|
1580
1716
|
return None
|
|
1581
1717
|
keys = sorted({k for row in rows for k in row})
|
|
1582
1718
|
raw = {k: median([row.get(k, 0.0) for row in rows]) for k in keys}
|
|
1583
|
-
total =
|
|
1719
|
+
total = _float_total(raw.values())
|
|
1584
1720
|
if not math.isfinite(total) or total <= 0.0:
|
|
1585
1721
|
return None
|
|
1586
1722
|
return {k: v / total for k, v in raw.items()}
|
|
@@ -1596,7 +1732,14 @@ def tv_distance(p, centre) -> float:
|
|
|
1596
1732
|
order and on every supported interpreter.
|
|
1597
1733
|
"""
|
|
1598
1734
|
keys = set(p) | set(centre)
|
|
1599
|
-
|
|
1735
|
+
try:
|
|
1736
|
+
return 0.5 * math.fsum(abs(p.get(k, 0.0) - centre.get(k, 0.0))
|
|
1737
|
+
for k in keys)
|
|
1738
|
+
except OverflowError:
|
|
1739
|
+
# A stored centre of finite shares whose differences leave the float
|
|
1740
|
+
# range (#991 review): the distance is unmeasurable, so it lies
|
|
1741
|
+
# outside every radius rather than raising into the consumers.
|
|
1742
|
+
return math.inf
|
|
1600
1743
|
|
|
1601
1744
|
|
|
1602
1745
|
def support_radius(vectors, centre) -> float | None:
|
|
@@ -1700,8 +1843,8 @@ def aggregate_composition(entries, *, era: CoefficientEra) -> tuple | None:
|
|
|
1700
1843
|
raw[name] += qty
|
|
1701
1844
|
units = class_units(quantities, era)
|
|
1702
1845
|
families[family] = families.get(family, 0.0) + units
|
|
1703
|
-
raw_total =
|
|
1704
|
-
unit_total =
|
|
1846
|
+
raw_total = _float_total(raw.values())
|
|
1847
|
+
unit_total = _float_total(families.values())
|
|
1705
1848
|
if not _finite_quantity(raw_total) or not _finite_quantity(unit_total):
|
|
1706
1849
|
return None
|
|
1707
1850
|
if raw_total <= 0.0 or unit_total <= 0.0:
|
|
@@ -1731,6 +1874,169 @@ def is_supported(family_shares, class_shares, family_centre, family_radius,
|
|
|
1731
1874
|
return True
|
|
1732
1875
|
|
|
1733
1876
|
|
|
1877
|
+
# ---------------------------------------------------------------------------
|
|
1878
|
+
# The evidence-bounded family test for predictions (#991 spec 4.2 and 4.3).
|
|
1879
|
+
# ---------------------------------------------------------------------------
|
|
1880
|
+
class FamilyEffectUnavailable(str, enum.Enum):
|
|
1881
|
+
"""Why `family_effect_bound` computed no number. A closed set."""
|
|
1882
|
+
|
|
1883
|
+
UNEVIDENCED_FAMILY_CHANGE = "unevidenced-family-change"
|
|
1884
|
+
NO_FIT_REFERENCE = "no-fit-reference"
|
|
1885
|
+
MALFORMED_INPUT = "malformed-input"
|
|
1886
|
+
NONPOSITIVE_DENOMINATOR = "nonpositive-denominator"
|
|
1887
|
+
|
|
1888
|
+
|
|
1889
|
+
@dataclasses.dataclass(frozen=True)
|
|
1890
|
+
class FamilyEffect:
|
|
1891
|
+
"""A bound on the family-induced relative forecast error, or its absence.
|
|
1892
|
+
|
|
1893
|
+
`families` lists, sorted, the non-reference families without a rate
|
|
1894
|
+
bound whose share changed; it is empty for every other outcome.
|
|
1895
|
+
"""
|
|
1896
|
+
|
|
1897
|
+
bound: "float | None"
|
|
1898
|
+
unavailable: "FamilyEffectUnavailable | None"
|
|
1899
|
+
families: tuple = ()
|
|
1900
|
+
|
|
1901
|
+
|
|
1902
|
+
def _share_distribution(value) -> "dict | None":
|
|
1903
|
+
"""`value` when it is a dict of finite non-negative shares summing to one
|
|
1904
|
+
within 1e-9 by `math.fsum`, otherwise None. Never renormalized."""
|
|
1905
|
+
if not isinstance(value, dict):
|
|
1906
|
+
return None
|
|
1907
|
+
for share in value.values():
|
|
1908
|
+
if not _finite_number(share) or share < 0.0:
|
|
1909
|
+
return None
|
|
1910
|
+
try:
|
|
1911
|
+
total = math.fsum(value[k] for k in sorted(value))
|
|
1912
|
+
except OverflowError:
|
|
1913
|
+
# Finite shares whose sum leaves the float range: malformed input,
|
|
1914
|
+
# never an exception out of the evaluator.
|
|
1915
|
+
return None
|
|
1916
|
+
if abs(total - 1.0) > 1e-9:
|
|
1917
|
+
return None
|
|
1918
|
+
return value
|
|
1919
|
+
|
|
1920
|
+
|
|
1921
|
+
def family_effect_bound(p, q, *, era: CoefficientEra) -> FamilyEffect:
|
|
1922
|
+
"""`E(p, q) = fsum(|p_f - q_f| d_f) / fsum(q_f lo_f)` (spec 4.2).
|
|
1923
|
+
|
|
1924
|
+
`p` is the forecast population's family shares and `q` the fit-weighted
|
|
1925
|
+
family composition of the days the applicable fit was taken over. E
|
|
1926
|
+
bounds the family-induced error of the forecast relative to the fitted
|
|
1927
|
+
blend, conditional on the era's evidence box and a common metering scale;
|
|
1928
|
+
it does not bound total forecast error.
|
|
1929
|
+
|
|
1930
|
+
Per family: the reference has `lo = 1, d = 0`; a bounded family has
|
|
1931
|
+
`lo = low, d = max(|low - 1|, |high - 1|)`; an unbounded family whose
|
|
1932
|
+
share is unchanged contributes nothing to the numerator and `lo = 0`, and
|
|
1933
|
+
one whose share changed makes the bound unavailable. "Changed" is exact
|
|
1934
|
+
float inequality, with no tolerance: a tolerance would admit an
|
|
1935
|
+
unevidenced family that moved by less than it.
|
|
1936
|
+
|
|
1937
|
+
Every sum is `math.fsum` over a sorted key set, so the result cannot
|
|
1938
|
+
depend on dict or hash order.
|
|
1939
|
+
"""
|
|
1940
|
+
if _share_distribution(p) is None:
|
|
1941
|
+
return FamilyEffect(None, FamilyEffectUnavailable.MALFORMED_INPUT)
|
|
1942
|
+
if q is None:
|
|
1943
|
+
return FamilyEffect(None, FamilyEffectUnavailable.NO_FIT_REFERENCE)
|
|
1944
|
+
if _share_distribution(q) is None:
|
|
1945
|
+
return FamilyEffect(None, FamilyEffectUnavailable.MALFORMED_INPUT)
|
|
1946
|
+
_require_era(era)
|
|
1947
|
+
bounds = {}
|
|
1948
|
+
for bound in era.family_rate_bounds:
|
|
1949
|
+
if not (_finite_number(bound.low) and _finite_number(bound.high)) \
|
|
1950
|
+
or not 0.0 <= bound.low <= bound.high:
|
|
1951
|
+
return FamilyEffect(None, FamilyEffectUnavailable.MALFORMED_INPUT)
|
|
1952
|
+
bounds[bound.family] = bound
|
|
1953
|
+
numerator, denominator, unevidenced = [], [], []
|
|
1954
|
+
for family in sorted(set(p) | set(q)):
|
|
1955
|
+
p_f, q_f = p.get(family, 0.0), q.get(family, 0.0)
|
|
1956
|
+
if family == era.reference_family:
|
|
1957
|
+
lo, d = 1.0, 0.0
|
|
1958
|
+
elif family in bounds:
|
|
1959
|
+
bound = bounds[family]
|
|
1960
|
+
lo = bound.low
|
|
1961
|
+
d = max(abs(bound.low - 1.0), abs(bound.high - 1.0))
|
|
1962
|
+
elif p_f != q_f:
|
|
1963
|
+
unevidenced.append(family)
|
|
1964
|
+
continue
|
|
1965
|
+
else:
|
|
1966
|
+
lo, d = 0.0, 0.0
|
|
1967
|
+
numerator.append(abs(p_f - q_f) * d)
|
|
1968
|
+
denominator.append(q_f * lo)
|
|
1969
|
+
if unevidenced:
|
|
1970
|
+
return FamilyEffect(None,
|
|
1971
|
+
FamilyEffectUnavailable.UNEVIDENCED_FAMILY_CHANGE,
|
|
1972
|
+
tuple(sorted(unevidenced)))
|
|
1973
|
+
den = math.fsum(denominator)
|
|
1974
|
+
if not math.isfinite(den) or den <= 0.0:
|
|
1975
|
+
return FamilyEffect(None,
|
|
1976
|
+
FamilyEffectUnavailable.NONPOSITIVE_DENOMINATOR)
|
|
1977
|
+
return FamilyEffect(math.fsum(numerator) / den, None)
|
|
1978
|
+
|
|
1979
|
+
|
|
1980
|
+
@dataclasses.dataclass(frozen=True)
|
|
1981
|
+
class PredictionSupport:
|
|
1982
|
+
"""One prediction-support decision and its diagnostics (spec 4.3)."""
|
|
1983
|
+
|
|
1984
|
+
supported: bool
|
|
1985
|
+
family_distance: "float | None"
|
|
1986
|
+
family_within_radius: "bool | None"
|
|
1987
|
+
family_effect: FamilyEffect
|
|
1988
|
+
family_admitted_by: "str | None"
|
|
1989
|
+
class_distance: "float | None"
|
|
1990
|
+
class_within_radius: "bool | None"
|
|
1991
|
+
|
|
1992
|
+
|
|
1993
|
+
def evaluate_prediction_support(family_shares, class_shares, *,
|
|
1994
|
+
family_centre, family_radius, class_centre,
|
|
1995
|
+
class_radius, fit_family_shares,
|
|
1996
|
+
era) -> PredictionSupport:
|
|
1997
|
+
"""Whether a population a PREDICTION is made for is supported (#991).
|
|
1998
|
+
|
|
1999
|
+
The forecast-aggregate probe and `apply_regime` call this; the decisive
|
|
2000
|
+
watch days keep `is_supported`, the two-axis total-variation rule, because
|
|
2001
|
+
they guard detection rather than projection.
|
|
2002
|
+
|
|
2003
|
+
The class axis is mandatory. The family axis admits by the unchanged
|
|
2004
|
+
radius rule, or else when `family_effect_bound` against the fit's own
|
|
2005
|
+
fit-weighted composition is a number at most
|
|
2006
|
+
`MIX_SUPPORT["family_max_relative_effect"]` (equality admits). An
|
|
2007
|
+
undefined centre or radius is unsupported, exactly as in `is_supported`.
|
|
2008
|
+
The family effect is always computed, as a diagnostic.
|
|
2009
|
+
"""
|
|
2010
|
+
effect = family_effect_bound(
|
|
2011
|
+
None if family_shares is None else dict(family_shares),
|
|
2012
|
+
fit_family_shares, era=era)
|
|
2013
|
+
if family_centre is None or family_radius is None \
|
|
2014
|
+
or class_centre is None or class_radius is None:
|
|
2015
|
+
return PredictionSupport(
|
|
2016
|
+
supported=False, family_distance=None, family_within_radius=None,
|
|
2017
|
+
family_effect=effect, family_admitted_by=None,
|
|
2018
|
+
class_distance=None, class_within_radius=None)
|
|
2019
|
+
family_distance = tv_distance(dict(family_shares), family_centre)
|
|
2020
|
+
family_within = family_distance <= family_radius
|
|
2021
|
+
class_distance = tv_distance(dict(class_shares), class_centre)
|
|
2022
|
+
class_within = class_distance <= class_radius
|
|
2023
|
+
if family_within:
|
|
2024
|
+
admitted_by = "radius"
|
|
2025
|
+
elif effect.bound is not None and effect.bound <= float(
|
|
2026
|
+
MIX_SUPPORT["family_max_relative_effect"]):
|
|
2027
|
+
admitted_by = "materiality"
|
|
2028
|
+
else:
|
|
2029
|
+
admitted_by = None
|
|
2030
|
+
return PredictionSupport(
|
|
2031
|
+
supported=bool(class_within and admitted_by is not None),
|
|
2032
|
+
family_distance=family_distance,
|
|
2033
|
+
family_within_radius=family_within,
|
|
2034
|
+
family_effect=effect,
|
|
2035
|
+
family_admitted_by=admitted_by,
|
|
2036
|
+
class_distance=class_distance,
|
|
2037
|
+
class_within_radius=class_within)
|
|
2038
|
+
|
|
2039
|
+
|
|
1734
2040
|
# ---------------------------------------------------------------------------
|
|
1735
2041
|
# The fit (section 6 and 15).
|
|
1736
2042
|
# ---------------------------------------------------------------------------
|
|
@@ -1772,8 +2078,8 @@ def jackknife_fit(days) -> tuple[float, Interval] | None:
|
|
|
1772
2078
|
if any(not _finite_quantity(d.units) or not _finite_quantity(d.meter_delta)
|
|
1773
2079
|
for d in rows):
|
|
1774
2080
|
return None
|
|
1775
|
-
total_units =
|
|
1776
|
-
total_meter =
|
|
2081
|
+
total_units = _float_total(d.units for d in rows)
|
|
2082
|
+
total_meter = _float_total(d.meter_delta for d in rows)
|
|
1777
2083
|
if total_meter <= 0.0 or total_units <= 0.0:
|
|
1778
2084
|
return None
|
|
1779
2085
|
point = total_units / total_meter
|
|
@@ -1783,10 +2089,10 @@ def jackknife_fit(days) -> tuple[float, Interval] | None:
|
|
|
1783
2089
|
if rest_meter <= 0.0:
|
|
1784
2090
|
return None
|
|
1785
2091
|
replicates.append((total_units - row.units) / rest_meter)
|
|
1786
|
-
mean =
|
|
2092
|
+
mean = _float_total(replicates) / n
|
|
1787
2093
|
# A product, not `** 2`, which is the C library's `pow`.
|
|
1788
|
-
se = math.sqrt((n - 1) / n *
|
|
1789
|
-
|
|
2094
|
+
se = math.sqrt((n - 1) / n * _float_total((v - mean) * (v - mean)
|
|
2095
|
+
for v in replicates))
|
|
1790
2096
|
half = t_quantile_975(n - 1) * se
|
|
1791
2097
|
return point, Interval(max(0.0, point - half), point + half)
|
|
1792
2098
|
|
|
@@ -2306,6 +2612,59 @@ class QuotaAnalysis:
|
|
|
2306
2612
|
#: harmful direction on this axis is permitting.
|
|
2307
2613
|
detector_input_causes: frozenset | None = None
|
|
2308
2614
|
composition_provenance: frozenset | None = None
|
|
2615
|
+
#: #991 spec 4.4. The fit-weighted family composition of exactly the days
|
|
2616
|
+
#: each fit was taken over: `fit_family_shares` for the published fit
|
|
2617
|
+
#: (the successor after a confirmed change) and
|
|
2618
|
+
#: `baseline_fit_family_shares` for the baseline fit. They are the
|
|
2619
|
+
#: references the evidence-bounded family test measures a forecast
|
|
2620
|
+
#: against, never a median centre.
|
|
2621
|
+
fit_family_shares: dict | None = None
|
|
2622
|
+
baseline_fit_family_shares: dict | None = None
|
|
2623
|
+
#: The exact `aggregate_composition` vectors of the probed forecast
|
|
2624
|
+
#: entries, or None when nothing was probed or it could not be
|
|
2625
|
+
#: aggregated. Not the `current_*` centres.
|
|
2626
|
+
forecast_family_shares: dict | None = None
|
|
2627
|
+
forecast_class_shares: dict | None = None
|
|
2628
|
+
#: `not-probed`, `unaggregatable`, `no-reference` or `probed`: a
|
|
2629
|
+
#: diagnostic of the forecast aggregation and the evaluator. It never
|
|
2630
|
+
#: decides `composition_provenance`.
|
|
2631
|
+
forecast_probe_state: str = "not-probed"
|
|
2632
|
+
forecast_support: "PredictionSupport | None" = None
|
|
2633
|
+
#: False exactly when the provenance holds `FORECAST_AGGREGATE`,
|
|
2634
|
+
#: `UNAGGREGATABLE_FORECAST` or `UNDEFINED_REFERENCE`.
|
|
2635
|
+
forecast_supported: bool = True
|
|
2636
|
+
#: #1015 spec 5.2. `(first, last)` date of the days the published fit
|
|
2637
|
+
#: (the successor after a confirmed change) and the baseline fit were
|
|
2638
|
+
#: computed over, or None when a fit had no days or the analysis was
|
|
2639
|
+
#: constructed by a path that does not publish them. Informational: a
|
|
2640
|
+
#: regime's `fitLineage.estimator` records them, and the lineage
|
|
2641
|
+
#: predicate uses only its dependency windows.
|
|
2642
|
+
published_day_bounds: "tuple | None" = None
|
|
2643
|
+
baseline_day_bounds: "tuple | None" = None
|
|
2644
|
+
|
|
2645
|
+
|
|
2646
|
+
#: The qualifications a withheld `fitted`, `consumption`, `headroom` and
|
|
2647
|
+
#: `projection` carry when the forecast probe's FAMILY axis withheld them
|
|
2648
|
+
#: (#991 spec 4.4): a computed bound above the limit, or no bound at all.
|
|
2649
|
+
FORECAST_FAMILY_EFFECT_OVER_LIMIT: str = "forecast-family-effect-over-limit"
|
|
2650
|
+
FORECAST_FAMILY_UNBOUNDED: str = "forecast-family-unbounded"
|
|
2651
|
+
|
|
2652
|
+
def fit_family_shares(days) -> "dict[str, float] | None":
|
|
2653
|
+
"""The unit-weighted family composition of `days` (#991 spec 4.4).
|
|
2654
|
+
|
|
2655
|
+
`q_f = sum(day.units * day.family_shares[f]) / sum(day.units)` over the
|
|
2656
|
+
days handed in, which must be exactly the days a fit was computed over.
|
|
2657
|
+
Every sum is `math.fsum` over sorted keys. None for no days, or when the
|
|
2658
|
+
days carry no positive finite units.
|
|
2659
|
+
"""
|
|
2660
|
+
days = list(days)
|
|
2661
|
+
total = math.fsum(o.units for o in days)
|
|
2662
|
+
if not days or not math.isfinite(total) or total <= 0.0:
|
|
2663
|
+
return None
|
|
2664
|
+
keys = sorted({k for o in days for k in o.family_shares})
|
|
2665
|
+
return {k: math.fsum(o.units * o.family_shares.get(k, 0.0)
|
|
2666
|
+
for o in days) / total
|
|
2667
|
+
for k in keys}
|
|
2309
2668
|
|
|
2310
2669
|
|
|
2311
2670
|
def overlay_status(analysis: QuotaAnalysis,
|
|
@@ -2408,10 +2767,21 @@ def transition_persistence_permitted(analysis) -> bool:
|
|
|
2408
2767
|
return provenance == frozenset({CompositionProvenance.FORECAST_AGGREGATE})
|
|
2409
2768
|
|
|
2410
2769
|
|
|
2411
|
-
def _composition_provenance(
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2770
|
+
def _composition_provenance(**kwargs) -> frozenset:
|
|
2771
|
+
"""The provenance alone; see `_composition_provenance_with_support`.
|
|
2772
|
+
|
|
2773
|
+
Keeps its keyword signature and its frozenset return for every existing
|
|
2774
|
+
caller. Called without `fit_family_shares` the forecast probe has no fit
|
|
2775
|
+
reference, so its materiality branch is unavailable and it decides by the
|
|
2776
|
+
radius rule alone.
|
|
2777
|
+
"""
|
|
2778
|
+
return _composition_provenance_with_support(**kwargs)[0]
|
|
2779
|
+
|
|
2780
|
+
|
|
2781
|
+
def _composition_provenance_with_support(
|
|
2782
|
+
*, confirmed, reference_days, decisive_days, forecast_probed=False,
|
|
2783
|
+
forecast_shares, family_centre, family_radius, class_centre,
|
|
2784
|
+
class_radius, fit_family_shares=None, era=None) -> tuple:
|
|
2415
2785
|
"""Apply section 18's two-radius test, which section 25 requires.
|
|
2416
2786
|
|
|
2417
2787
|
Two probes, because section 18 names two populations:
|
|
@@ -2442,13 +2812,21 @@ def _composition_provenance(*, confirmed, reference_days, decisive_days,
|
|
|
2442
2812
|
branches on truthiness, but a boolean discarded the one distinction the
|
|
2443
2813
|
persistence permit needs: the decisive-day probe guards DETECTION and the
|
|
2444
2814
|
forecast probe guards PROJECTION, and they must be answered differently.
|
|
2815
|
+
|
|
2816
|
+
#991: the forecast probe goes through `evaluate_prediction_support` with
|
|
2817
|
+
the published fit's own fit-weighted family composition, so a family
|
|
2818
|
+
shift the era's meter evidence bounds within 5% is admitted; the
|
|
2819
|
+
decisive-day probe keeps `is_supported`. The return is the provenance and
|
|
2820
|
+
the forecast `PredictionSupport` (None whenever an early return decided
|
|
2821
|
+
before the evaluator ran). The early returns and their order are
|
|
2822
|
+
unchanged.
|
|
2445
2823
|
"""
|
|
2446
2824
|
if not reference_days:
|
|
2447
|
-
return frozenset()
|
|
2825
|
+
return frozenset(), None
|
|
2448
2826
|
if family_centre is None or class_centre is None:
|
|
2449
|
-
return frozenset({CompositionProvenance.UNDEFINED_REFERENCE})
|
|
2827
|
+
return frozenset({CompositionProvenance.UNDEFINED_REFERENCE}), None
|
|
2450
2828
|
if family_radius is None or class_radius is None:
|
|
2451
|
-
return frozenset()
|
|
2829
|
+
return frozenset(), None
|
|
2452
2830
|
# A population that WAS probed but could not be aggregated is unsupported,
|
|
2453
2831
|
# not exempt. Every entry from an unrecognised family contributes no
|
|
2454
2832
|
# units, so `aggregate_composition` returns None and the probe list would
|
|
@@ -2456,20 +2834,24 @@ def _composition_provenance(*, confirmed, reference_days, decisive_days,
|
|
|
2456
2834
|
# omitted `forecast_population` would, which is what section 40 made that
|
|
2457
2835
|
# parameter required to prevent.
|
|
2458
2836
|
if forecast_probed and forecast_shares is None:
|
|
2459
|
-
return frozenset({CompositionProvenance.UNAGGREGATABLE_FORECAST})
|
|
2837
|
+
return frozenset({CompositionProvenance.UNAGGREGATABLE_FORECAST}), None
|
|
2460
2838
|
found = set()
|
|
2461
2839
|
for o in (decisive_days if confirmed else ()):
|
|
2462
2840
|
if not is_supported(o.family_shares, o.class_shares, family_centre,
|
|
2463
2841
|
family_radius, class_centre, class_radius):
|
|
2464
2842
|
found.add(CompositionProvenance.DECISIVE_DAY)
|
|
2465
2843
|
break
|
|
2844
|
+
support = None
|
|
2466
2845
|
if forecast_shares is not None:
|
|
2467
2846
|
forecast_family_shares, forecast_class_shares = forecast_shares
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2847
|
+
support = evaluate_prediction_support(
|
|
2848
|
+
forecast_family_shares, forecast_class_shares,
|
|
2849
|
+
family_centre=family_centre, family_radius=family_radius,
|
|
2850
|
+
class_centre=class_centre, class_radius=class_radius,
|
|
2851
|
+
fit_family_shares=fit_family_shares, era=era)
|
|
2852
|
+
if not support.supported:
|
|
2471
2853
|
found.add(CompositionProvenance.FORECAST_AGGREGATE)
|
|
2472
|
-
return frozenset(found)
|
|
2854
|
+
return frozenset(found), support
|
|
2473
2855
|
|
|
2474
2856
|
|
|
2475
2857
|
def analyse(series, *, now, newest_at, forecast_population,
|
|
@@ -2577,6 +2959,10 @@ def analyse(series, *, now, newest_at, forecast_population,
|
|
|
2577
2959
|
published_days = eligible[-max_fit_population_days():]
|
|
2578
2960
|
baseline_raw = jackknife_fit(baseline_days)
|
|
2579
2961
|
fit = jackknife_fit(published_days)
|
|
2962
|
+
# #991: the materiality references come from exactly the days each fit
|
|
2963
|
+
# above was computed over.
|
|
2964
|
+
published_q = fit_family_shares(published_days)
|
|
2965
|
+
baseline_q = fit_family_shares(baseline_days)
|
|
2580
2966
|
# The reference centres and radii come from the regime that ESTABLISHED
|
|
2581
2967
|
# the composition, so a successor is measured against what preceded it.
|
|
2582
2968
|
# With no confirmed change the bounded fit population is the reference,
|
|
@@ -2618,14 +3004,38 @@ def analyse(series, *, now, newest_at, forecast_population,
|
|
|
2618
3004
|
if probed_entries else None)
|
|
2619
3005
|
|
|
2620
3006
|
contributed = list(extra_statuses)
|
|
2621
|
-
composition_provenance =
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
3007
|
+
composition_provenance, forecast_support = \
|
|
3008
|
+
_composition_provenance_with_support(
|
|
3009
|
+
confirmed=confirmed, reference_days=reference_days,
|
|
3010
|
+
decisive_days=published_days, forecast_shares=forecast_shares,
|
|
3011
|
+
forecast_probed=bool(probed_entries),
|
|
3012
|
+
family_centre=family_centre, family_radius=family_radius,
|
|
3013
|
+
class_centre=class_centre, class_radius=class_radius,
|
|
3014
|
+
fit_family_shares=published_q, era=era)
|
|
2627
3015
|
if composition_provenance:
|
|
2628
3016
|
contributed.append(CalibrationStatus.UNSUPPORTED_MODEL_MIX)
|
|
3017
|
+
if not probed_entries:
|
|
3018
|
+
forecast_probe_state = "not-probed"
|
|
3019
|
+
elif forecast_shares is None:
|
|
3020
|
+
forecast_probe_state = "unaggregatable"
|
|
3021
|
+
elif forecast_support is None:
|
|
3022
|
+
forecast_probe_state = "no-reference"
|
|
3023
|
+
else:
|
|
3024
|
+
forecast_probe_state = "probed"
|
|
3025
|
+
forecast_supported = not (composition_provenance & frozenset({
|
|
3026
|
+
CompositionProvenance.FORECAST_AGGREGATE,
|
|
3027
|
+
CompositionProvenance.UNAGGREGATABLE_FORECAST,
|
|
3028
|
+
CompositionProvenance.UNDEFINED_REFERENCE}))
|
|
3029
|
+
# The forecast-family qualifier: only when the forecast aggregate fired
|
|
3030
|
+
# AND its family axis failed, never for a class-axis-only failure.
|
|
3031
|
+
family_qualifier = None
|
|
3032
|
+
if CompositionProvenance.FORECAST_AGGREGATE in composition_provenance \
|
|
3033
|
+
and forecast_support is not None \
|
|
3034
|
+
and forecast_support.family_admitted_by is None:
|
|
3035
|
+
family_qualifier = (
|
|
3036
|
+
FORECAST_FAMILY_EFFECT_OVER_LIMIT
|
|
3037
|
+
if forecast_support.family_effect.bound is not None
|
|
3038
|
+
else FORECAST_FAMILY_UNBOUNDED)
|
|
2629
3039
|
if confirmed:
|
|
2630
3040
|
window_start = detector.split_date
|
|
2631
3041
|
elif published_days:
|
|
@@ -2699,6 +3109,10 @@ def analyse(series, *, now, newest_at, forecast_population,
|
|
|
2699
3109
|
|
|
2700
3110
|
published_qualifications = (("successor-regime",) if confirmed else ()) \
|
|
2701
3111
|
+ tuple(reason.value for reason in blocking)
|
|
3112
|
+
# The forecast probe firing contributes `unsupported-model-mix`, so every
|
|
3113
|
+
# value carrying these qualifications is withheld whenever it is set.
|
|
3114
|
+
if family_qualifier is not None:
|
|
3115
|
+
published_qualifications += (family_qualifier,)
|
|
2702
3116
|
if status is not CalibrationStatus.OK or fit is None:
|
|
2703
3117
|
withheld_code = status.value if status is not CalibrationStatus.OK \
|
|
2704
3118
|
else CalibrationStatus.INSUFFICIENT_HISTORY.value
|
|
@@ -2806,9 +3220,26 @@ def analyse(series, *, now, newest_at, forecast_population,
|
|
|
2806
3220
|
detector_input_causes=frozenset(
|
|
2807
3221
|
o.cause for o in window if o.cause is not None),
|
|
2808
3222
|
composition_provenance=composition_provenance,
|
|
3223
|
+
fit_family_shares=published_q,
|
|
3224
|
+
baseline_fit_family_shares=baseline_q,
|
|
3225
|
+
forecast_family_shares=(None if forecast_shares is None
|
|
3226
|
+
else dict(forecast_shares[0])),
|
|
3227
|
+
forecast_class_shares=(None if forecast_shares is None
|
|
3228
|
+
else dict(forecast_shares[1])),
|
|
3229
|
+
forecast_probe_state=forecast_probe_state,
|
|
3230
|
+
forecast_support=forecast_support,
|
|
3231
|
+
forecast_supported=forecast_supported,
|
|
3232
|
+
published_day_bounds=_day_bounds(published_days),
|
|
3233
|
+
baseline_day_bounds=_day_bounds(baseline_days),
|
|
2809
3234
|
)
|
|
2810
3235
|
|
|
2811
3236
|
|
|
3237
|
+
def _day_bounds(days) -> "tuple | None":
|
|
3238
|
+
"""`(first date, last date)` of the days a fit was taken over (#1015)."""
|
|
3239
|
+
dates = [o.date for o in days]
|
|
3240
|
+
return (min(dates), max(dates)) if dates else None
|
|
3241
|
+
|
|
3242
|
+
|
|
2812
3243
|
def _usable_quantity(value) -> bool:
|
|
2813
3244
|
"""A finite, non-negative number the arithmetic below can consume."""
|
|
2814
3245
|
return _finite_quantity(value) and value >= 0.0
|