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.
Files changed (76) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +57 -3
  4. package/bin/_cctally_cache.py +5720 -885
  5. package/bin/_cctally_claude_attribution.py +997 -0
  6. package/bin/_cctally_completion.py +101 -28
  7. package/bin/_cctally_core.py +235 -12
  8. package/bin/_cctally_dashboard.py +263 -17
  9. package/bin/_cctally_dashboard_conversation.py +107 -21
  10. package/bin/_cctally_dashboard_envelope.py +44 -0
  11. package/bin/_cctally_db.py +928 -80
  12. package/bin/_cctally_diagnosis_sources.py +78 -13
  13. package/bin/_cctally_doctor.py +211 -86
  14. package/bin/_cctally_five_hour.py +446 -22
  15. package/bin/_cctally_forecast.py +302 -6
  16. package/bin/_cctally_journal.py +2418 -158
  17. package/bin/_cctally_milestone_history.py +379 -91
  18. package/bin/_cctally_oauth_samples.py +185 -7
  19. package/bin/_cctally_parser.py +38 -0
  20. package/bin/_cctally_project.py +34 -8
  21. package/bin/_cctally_quarantine.py +821 -343
  22. package/bin/_cctally_quota.py +69 -4
  23. package/bin/_cctally_quota_calibration.py +677 -3
  24. package/bin/_cctally_quota_model.py +927 -127
  25. package/bin/_cctally_record.py +922 -445
  26. package/bin/_cctally_rederive.py +119 -15
  27. package/bin/_cctally_statusline.py +1048 -176
  28. package/bin/_cctally_store.py +544 -110
  29. package/bin/_cctally_tui.py +455 -42
  30. package/bin/_cctally_update.py +77 -14
  31. package/bin/_lib_account_protocol.py +227 -19
  32. package/bin/_lib_claude_attribution.py +421 -0
  33. package/bin/_lib_clone_copy.py +30 -0
  34. package/bin/_lib_codex_conversation_query.py +45 -9
  35. package/bin/_lib_codex_conversation_watch.py +72 -13
  36. package/bin/_lib_codex_rollout_container.py +234 -19
  37. package/bin/_lib_codex_worker_lifecycle.py +374 -0
  38. package/bin/_lib_credit.py +22 -13
  39. package/bin/_lib_dashboard_sources.py +257 -18
  40. package/bin/_lib_detector_access_registry.py +1335 -166
  41. package/bin/_lib_doctor.py +276 -92
  42. package/bin/_lib_ingest_frontier.py +269 -36
  43. package/bin/_lib_journal.py +273 -22
  44. package/bin/_lib_jsonl.py +12 -0
  45. package/bin/_lib_meter_rate_change.py +72 -0
  46. package/bin/_lib_outcome_quarantine.py +917 -554
  47. package/bin/_lib_provider_activity.py +24 -6
  48. package/bin/_lib_quota_calibration.py +57 -3
  49. package/bin/_lib_quota_copy.py +7 -2
  50. package/bin/_lib_quota_lineage.py +613 -0
  51. package/bin/_lib_quota_model.py +464 -33
  52. package/bin/_lib_rate_change_delivery.py +16 -0
  53. package/bin/_lib_reclaim_planner.py +6 -4
  54. package/bin/_lib_rederive.py +2046 -21
  55. package/bin/_lib_selector_state.py +131 -3
  56. package/bin/_lib_snapshot_cache.py +8 -0
  57. package/bin/_lib_stale_proof.py +113 -4
  58. package/bin/_lib_statusline.py +72 -3
  59. package/bin/_lib_statusline_candidates.py +246 -42
  60. package/bin/_lib_tick_stats.py +10 -5
  61. package/bin/_lib_write_io.py +419 -6
  62. package/bin/cctally +28 -11
  63. package/dashboard/static/assets/{ConversationsView-Dflb_myA.js → ConversationsView-oqNsWzWV.js} +1 -1
  64. package/dashboard/static/assets/{DoctorModal-DFX5CfIs.js → DoctorModal-B0DnU9d9.js} +1 -1
  65. package/dashboard/static/assets/ModalRoot-CYua2wP_.js +1 -0
  66. package/dashboard/static/assets/{ProjectsDrillPanel-C3PJy1xx.js → ProjectsDrillPanel-DSxXQSa-.js} +1 -1
  67. package/dashboard/static/assets/{SourceDetailModal-BczsWKhD.js → SourceDetailModal-BleDpQqG.js} +1 -1
  68. package/dashboard/static/assets/{UpdateModal-Os-Lo5cs.js → UpdateModal-CMqaAkSK.js} +1 -1
  69. package/dashboard/static/assets/{index-CYZ3NLqF.js → index-BjQV-0-D.js} +3 -3
  70. package/dashboard/static/assets/index-D34eYhAc.css +1 -0
  71. package/dashboard/static/assets/{outlineNavigation-k-zIif6W.js → outlineNavigation-CO-VWBm7.js} +1 -1
  72. package/dashboard/static/assets/{useKeymap-CW7Up0Ay.js → useKeymap-CUgEnnua.js} +1 -1
  73. package/dashboard/static/dashboard.html +3 -3
  74. package/package.json +5 -1
  75. package/dashboard/static/assets/ModalRoot-HlxoagIa.js +0 -1
  76. package/dashboard/static/assets/index-BKITk1CT.css +0 -1
@@ -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 = 4
305
- QUOTA_MODEL_VERIFIED_AT: str = "2026-10-20"
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
- "5835b430978b2fc556c577d44c9e62d5d7807e62533b32819ed63d37e3af671a"
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 sum(float(classes.get(name, 0.0)) * weights[name]
887
- for name in TOKEN_CLASSES)
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 = sum(raw.values())
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 = sum(raw.values())
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
- return 0.5 * math.fsum(abs(p.get(k, 0.0) - centre.get(k, 0.0)) for k in keys)
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 = sum(raw.values())
1704
- unit_total = sum(families.values())
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 = sum(d.units for d in rows)
1776
- total_meter = sum(d.meter_delta for d in rows)
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 = sum(replicates) / n
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 * sum((v - mean) * (v - mean)
1789
- for v in replicates))
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(*, confirmed, reference_days, decisive_days,
2412
- forecast_probed=False,
2413
- forecast_shares, family_centre, family_radius,
2414
- class_centre, class_radius) -> frozenset:
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
- if not is_supported(forecast_family_shares, forecast_class_shares,
2469
- family_centre, family_radius, class_centre,
2470
- class_radius):
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 = _composition_provenance(
2622
- confirmed=confirmed, reference_days=reference_days,
2623
- decisive_days=published_days, forecast_shares=forecast_shares,
2624
- forecast_probed=bool(probed_entries),
2625
- family_centre=family_centre, family_radius=family_radius,
2626
- class_centre=class_centre, class_radius=class_radius)
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