selection-fragility 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,73 @@
1
+ """selection_fragility — decision breakdown point (k*) and ranking-fragility diagnostics for
2
+ forecast model selection.
3
+
4
+ Given per-period losses for a set of candidate models, answers: is the "best model" a real,
5
+ stable choice, or a fragile artifact of a few periods?
6
+
7
+ Start with the staged v1.0 pipeline:
8
+ from selection_fragility import LossPanel, report
9
+ panel = LossPanel.from_losses({"ar1": ..., "nbeats": ..., "ets": ...})
10
+ print(report(panel))
11
+
12
+ `report()` prints identification (Model Confidence Set), the leaderboard, resolution (observed
13
+ edge vs. minimum detectable edge, MCB bound), and the pivot (k*, responsible periods,
14
+ concentration). See the project's README (rendered on its PyPI page) for the full quick start and
15
+ `EVALUATION_CARD.md` (in the source repository -- CORRECTED 2026-09-07, round-4 audit: this file
16
+ does not ship inside a `pip install`, only the source repo has it) for what each diagnostic does
17
+ and does not support.
18
+
19
+ CONTRIBUTORS/EXTENDERS: `selection_fragility.fragility`, `.mcs`, `.compare`, and `.report` are
20
+ FUNCTIONS here (re-exported below), not the submodules of the same name -- `import
21
+ selection_fragility.mcs as m` gives you the `mcs()` function, not the module, so `m._block_idx`
22
+ fails confusingly. Use `from selection_fragility._internals import mcs, fragility, compare,
23
+ report` (or `panel`/`identify`/`resolution`/`prop22`/`pivot`) for a stable, non-shadowed path to
24
+ the real submodules and their private helpers. See `_internals.py` for the full explanation.
25
+ """
26
+ from .fragility import (
27
+ fragility,
28
+ decision_breakdown,
29
+ breakdown_number,
30
+ winner_stability,
31
+ exchangeable_benchmark,
32
+ surprise_concentration,
33
+ pooled_winner,
34
+ per_period_winner,
35
+ condorcet_winner,
36
+ condorcet_status,
37
+ )
38
+ from .mcs import mcs, model_confidence_set
39
+
40
+ # --- Round 10 (v1.0) surface: the staged pipeline the redesign introduced ------------
41
+ from .panel import LossPanel # Stage 0
42
+ from .identify import identified, mcs_size # Stage 1
43
+ from .resolution import ( # Stage 2
44
+ resolution_report,
45
+ minimum_detectable_edge,
46
+ selection_regret,
47
+ mcb_bound,
48
+ significance_boundary,
49
+ )
50
+ from .prop22 import prop22_certifies, certified_tied_subset # Stage 3
51
+ from .pivot import concentration_share, pivot_agreement # Stage 4
52
+ from .compare import compare, ChangeReport # Stage 5
53
+ from .report import report # Stage 6
54
+
55
+ __version__ = "1.0.0"
56
+
57
+ __all__ = [
58
+ # instrument
59
+ "fragility", "decision_breakdown", "breakdown_number", "winner_stability",
60
+ "exchangeable_benchmark", "surprise_concentration", "pooled_winner",
61
+ "per_period_winner", "condorcet_winner", "condorcet_status",
62
+ "mcs", "model_confidence_set",
63
+ # v1.0 staged surface
64
+ "LossPanel",
65
+ "identified", "mcs_size",
66
+ "resolution_report", "minimum_detectable_edge", "selection_regret", "mcb_bound",
67
+ "significance_boundary",
68
+ "prop22_certifies", "certified_tied_subset",
69
+ "concentration_share", "pivot_agreement",
70
+ "compare", "ChangeReport",
71
+ "report",
72
+ "__version__",
73
+ ]
@@ -0,0 +1,39 @@
1
+ """python -m selection_fragility -- CLI entry point. Currently one subcommand: `compare`, so
2
+ `compare()` can gate a CI/pipeline promotion step without writing any Python."""
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from .panel import LossPanel
7
+ from .compare import compare
8
+ def main(argv=None):
9
+ parser = argparse.ArgumentParser(prog="selection_fragility")
10
+ sub = parser.add_subparsers(dest="command", required=True)
11
+ p_compare = sub.add_parser("compare", help="compare two saved LossPanels (previous vs current run)")
12
+ p_compare.add_argument("previous", help="path to a LossPanel saved with .save()")
13
+ p_compare.add_argument("current", help="path to a LossPanel saved with .save()")
14
+ p_compare.add_argument("--alpha", type=float, default=0.10, help="Model Confidence Set level")
15
+ p_compare.add_argument("--exit-code", action="store_true",
16
+ help="exit 1 when the report recommends action (.act=True); exit 0 otherwise")
17
+ args = parser.parse_args(argv)
18
+ if args.command == "compare":
19
+ # FIXED 2026-08-26 (round-2 review, "does it deliver on its promises" lens): CHANGELOG.md
20
+ # already claimed "every CLI error path... exits 2", but nothing here ever caught anything --
21
+ # a missing/malformed file surfaced as a raw Python traceback at exit 1 (Python's default for
22
+ # an unhandled exception), indistinguishable from a genuine internal bug. Catch only the
23
+ # user-input-class failures LossPanel.load()/compare() are documented to raise for bad data
24
+ # (a missing file, corrupt/non-LossPanel JSON, or a real data problem like a mismatched model
25
+ # set) and exit 2 with a clean one-line message; anything else (a real programming error) is
26
+ # deliberately left to propagate as an uncaught exception so it isn't mistaken for bad input.
27
+ try:
28
+ prev = LossPanel.load(args.previous)
29
+ curr = LossPanel.load(args.current)
30
+ r = compare(prev, curr, alpha=args.alpha)
31
+ except (FileNotFoundError, json.JSONDecodeError, KeyError, ValueError) as e:
32
+ print(f"selection_fragility compare: {e}", file=sys.stderr)
33
+ sys.exit(2)
34
+ print(r)
35
+ if args.exit_code and r.act:
36
+ sys.exit(1)
37
+ sys.exit(0)
38
+ if __name__ == "__main__":
39
+ main()
@@ -0,0 +1,52 @@
1
+ """selection_fragility._internals — stable access to real submodules for contributors.
2
+
3
+ `selection_fragility/__init__.py` re-exports several public functions/classes under the SAME
4
+ name as the submodule that defines them (`from .fragility import fragility`, `from .mcs import
5
+ mcs`, `from .compare import compare`, `from .report import report`). Each of those `from .X
6
+ import X` lines rebinds the package attribute `selection_fragility.X` to the re-exported
7
+ function/class, shadowing the actual SUBMODULE that was at that attribute path a moment
8
+ earlier. `import selection_fragility.mcs as m; m._block_idx` therefore fails with a confusing
9
+ `AttributeError: 'function' object has no attribute '_block_idx'` -- `m` is the `mcs()`
10
+ function, not the `mcs` module. Found in round-5 review (2026-08-27) while testing whether a
11
+ contributor could reach package internals to build an extension.
12
+
13
+ This is deliberate, unchanged public-API behavior (`from selection_fragility import mcs` must
14
+ keep returning the function for existing users) -- not something to "fix" by breaking v1.0's
15
+ API on the eve of release. This module is the fix for CONTRIBUTORS instead: a stable, explicit,
16
+ documented path to the real submodule objects (and their private helpers) that never shadows.
17
+
18
+ Usage, instead of the confusing `import selection_fragility.mcs as m`:
19
+ from selection_fragility._internals import mcs, fragility, compare, report
20
+ mcs._block_idx(...) # the real submodule, its private helpers included
21
+ fragility._MAX_ABS_LOSS # etc.
22
+
23
+ The other submodules (panel, identify, resolution, prop22, pivot) were never shadowed --
24
+ their re-exported names differ from the submodule name -- but are re-exposed here too, for one
25
+ consistent import path regardless of which internals a contributor needs.
26
+ """
27
+ import sys as _sys
28
+
29
+ _PKG = __name__.rsplit(".", 1)[0] # "selection_fragility"
30
+
31
+ # Each of these is looked up in sys.modules by dotted name, NOT via `from . import X` or
32
+ # `getattr(package, X)` -- both of those would just return whatever the (possibly-shadowed)
33
+ # package attribute currently holds. sys.modules always holds the actual module object,
34
+ # regardless of what the package's own top-level attribute of the same name was rebound to.
35
+ # Requires this module to be imported only after selection_fragility/__init__.py has already
36
+ # executed its own `from .X import ...` lines (which is when Python registers each submodule
37
+ # in sys.modules) -- true whenever a caller does `from selection_fragility._internals import
38
+ # ...`, since importing the submodule `selection_fragility._internals` always first finishes
39
+ # importing and running its parent package `selection_fragility/__init__.py`.
40
+ fragility = _sys.modules[f"{_PKG}.fragility"]
41
+ mcs = _sys.modules[f"{_PKG}.mcs"]
42
+ compare = _sys.modules[f"{_PKG}.compare"]
43
+ report = _sys.modules[f"{_PKG}.report"]
44
+ panel = _sys.modules[f"{_PKG}.panel"]
45
+ identify = _sys.modules[f"{_PKG}.identify"]
46
+ resolution = _sys.modules[f"{_PKG}.resolution"]
47
+ prop22 = _sys.modules[f"{_PKG}.prop22"]
48
+ pivot = _sys.modules[f"{_PKG}.pivot"]
49
+
50
+ __all__ = [
51
+ "fragility", "mcs", "compare", "report", "panel", "identify", "resolution", "prop22", "pivot",
52
+ ]
@@ -0,0 +1,276 @@
1
+ """
2
+ selection_fragility.compare — compare(previous, current), the run-over-run champion-change diagnostic.
3
+ The single most-requested missing feature from the practitioner review: "the champion changed this
4
+ week -- is that signal or noise?"
5
+ """
6
+ import numpy as np
7
+
8
+ from .fragility import _validate_losses, pooled_winner, decision_breakdown
9
+ from .identify import mcs_size, _run_mcs
10
+
11
+
12
+ class ChangeReport:
13
+ """The result of `compare(previous, current)`. `.act` is the single bool a CI/pipeline promotion
14
+ step should gate on: True only when the champion changed AND the old champion has genuinely left
15
+ the Model Confidence Set (a real separation, not two models still statistically tied).
16
+
17
+ ROUND-8 FIX (2026-08-27, fresh-eyes full-codebase review): on a genuinely non-uniform-weight
18
+ panel, `arch.bootstrap.MCS` has no native support for the weights -- the same refusal
19
+ `report()`/`resolution_report()` already surface cleanly (round 6/7 fixes) instead of computing
20
+ an MCS on a silently different (unweighted) question than the champion itself was determined
21
+ with. `compare()` previously let that ValueError propagate raw and uncaught. Now caught here:
22
+ `old_champion_still_in_mcs` is `None` (undetermined, not a silent False) and `mcs_error` carries
23
+ the reason. `.act` is conservatively forced to `False` in this case -- a CI/pipeline promotion
24
+ step must never treat "MCS couldn't be computed" as "the old champion left the MCS."
25
+ """
26
+
27
+ def __init__(self, *, previous_champion, current_champion, champion_changed,
28
+ champion_without_new_periods, n_new_periods, old_champion_still_in_mcs,
29
+ k_star, churn_base_rate, act, mcs_error=None):
30
+ self.previous_champion = previous_champion
31
+ self.current_champion = current_champion
32
+ self.champion_changed = champion_changed
33
+ self.champion_without_new_periods = champion_without_new_periods
34
+ self.n_new_periods = n_new_periods
35
+ self.old_champion_still_in_mcs = old_champion_still_in_mcs
36
+ self.k_star = k_star
37
+ self.churn_base_rate = churn_base_rate
38
+ self.act = act
39
+ self.mcs_error = mcs_error
40
+
41
+ def __repr__(self):
42
+ if not self.champion_changed:
43
+ head = f"no champion change ({self.current_champion}); act=False"
44
+ elif self.champion_without_new_periods is None:
45
+ head = (f"champion changed {self.previous_champion} -> {self.current_champion}; current "
46
+ f"shares no periods with previous -- cannot attribute the change; act={self.act}")
47
+ elif self.champion_without_new_periods == self.previous_champion:
48
+ head = (f"champion changed {self.previous_champion} -> {self.current_champion}, entirely "
49
+ f"due to the {self.n_new_periods} new period(s) (removing them reverts to the old "
50
+ f"champion); act={self.act}")
51
+ else:
52
+ head = f"champion changed {self.previous_champion} -> {self.current_champion}; act={self.act}"
53
+ if self.mcs_error is not None:
54
+ mcs = f"MCS undetermined ({self.mcs_error})"
55
+ else:
56
+ mcs = "still in the MCS" if self.old_champion_still_in_mcs else "left the MCS"
57
+ return (f"ChangeReport({head}, old champion {mcs}, k*={self.k_star}, "
58
+ f"churn_base_rate={self.churn_base_rate:.3f})")
59
+
60
+
61
+ def _churn_base_rate(L, w, n_perm=400, seed=0):
62
+ """How often would the champion appear to change, RUN-OVER-RUN, out of pure noise -- i.e. if ONE
63
+ new period arrives that behaves like a typical historical period in shape but carries no
64
+ consistent model identity (a within-period label permutation applied to a single, randomly-drawn
65
+ EXISTING period, then appended), how often does that alone flip the pooled champion away from the
66
+ one observed on the T periods actually in hand? Distribution-free, built entirely from the
67
+ caller's own data.
68
+
69
+ REDESIGNED 2026-08-16 (independent 'wild' review): the original version independently permuted
70
+ EVERY period's model-assignment on EVERY trial -- a full reshuffle of the whole T-period panel,
71
+ not a single new period arriving. Verified directly this made the field measure almost exactly
72
+ (K-1)/K regardless of the actual data (K=6 gave 0.83 whether the panel had no true skill
73
+ difference OR one model with a massive true edge -- literally the wrong direction, since more
74
+ real signal should make the champion MORE robust to a single new period, not equally fragile).
75
+ The root cause: a full reshuffle destroys ALL cross-period structure, so every model's post-
76
+ permutation pooled mean becomes an independent draw from the same pool and the "champion" is
77
+ essentially a uniform pick among K labels. The fix keeps the T periods actually observed
78
+ UNCHANGED (preserving whatever real, if fragile, lead the data has) and only randomizes the
79
+ identity assignment of ONE new incoming period, drawn from the shape of a real historical period
80
+ -- matching what a single real `compare()` call actually represents. Verified against
81
+ tests/test_stage5_compare.py::test_base_rate_churn_cross_check's own independently-validated
82
+ target (a TRUE null panel, one new null period added, ~0.08-0.35 with target ~0.18): the redesign
83
+ lands at ~0.13, inside that range; the OLD version gave ~0.83, roughly 5x outside it, and the
84
+ cross-check test only ever validated `champion_changed` from real `compare()` calls, never this
85
+ internal field directly, which is how the gap went undetected across 4 prior review rounds.
86
+
87
+ CONFIRMED REGRESSION (determinism/statelessness audit, 2026-08-17): `models = list(L)` used the
88
+ caller's dict insertion order to build M's column order, so the SAME logical panel (same model
89
+ names, same values) produced a DIFFERENT churn_base_rate depending purely on how the caller's
90
+ dict happened to be constructed -- every other stochastic field in this package (winner_stability,
91
+ pivot_agreement) avoids this since they re-dispatch through pooled_winner/decision_breakdown,
92
+ which sort internally; this was the one function still shuffling at raw array-position level.
93
+ Verified directly: same seed, same 6-model/30-period panel, reversed dict key order moved
94
+ churn_base_rate from 0.42 to 0.465 -- a headline user-facing diagnostic swinging ~0.05 absolute
95
+ purely from dict construction order, against a documented ~0.08-0.35 target band. Fixed the same
96
+ way as identify.py::_to_frame and mcs.py::model_confidence_set's earlier order-dependence fixes:
97
+ sort models by name so M's column order is a function of model identity, not insertion order."""
98
+ # FIXED 2026-09-02 (10-agent code-review pass, CONFIRMED BUG): n_perm is a PUBLIC, documented
99
+ # kwarg of compare() (e.g. a caller computing it from a config value that can go negative by
100
+ # mistake). A negative n_perm made `range(n_perm)` empty (the tally stayed 0) so
101
+ # `changes/n_perm` silently returned -0.0 -- indistinguishable in comparison semantics from a
102
+ # genuine "zero churn expected by chance" reading -- with no resampling ever actually
103
+ # performed and no error raised.
104
+ if n_perm <= 0:
105
+ raise ValueError(f"n_perm must be a positive integer; got {n_perm}.")
106
+ models = sorted(L)
107
+ M = np.column_stack([np.asarray(L[m], float) for m in models]) # T x K, REAL unmodified data
108
+ obs_champ_idx = int(np.argmin(np.average(M, axis=0, weights=w)))
109
+ rng = np.random.default_rng(seed)
110
+ T = M.shape[0]
111
+ mean_w = float(np.mean(w))
112
+ changes = 0
113
+ for _ in range(n_perm):
114
+ t_star = int(rng.integers(0, T))
115
+ new_period = rng.permutation(M[t_star]) # same values as a real period, random model assignment
116
+ Mp = np.vstack([M, new_period])
117
+ wp = np.append(w, mean_w)
118
+ perm_champ_idx = int(np.argmin(np.average(Mp, axis=0, weights=wp)))
119
+ if perm_champ_idx != obs_champ_idx:
120
+ changes += 1
121
+ return changes / n_perm
122
+
123
+
124
+ def compare(previous, current, *, alpha=0.10, n_perm=400, seed=0):
125
+ """Compare two LossPanels from consecutive runs of the same evaluation and report whether the
126
+ pooled champion changed, and whether that change looks like signal or noise.
127
+
128
+ Computes, all cheap: (1) did the pooled champion change; (2) recompute the champion on `current`
129
+ restricted to the periods it shares with `previous` (by LABEL, not position) -- if that reverts to
130
+ the OLD champion, the change is entirely the new data, exact and free; (3) is the old champion
131
+ still in the current Model Confidence Set (if both remain tied, there is no real separation to act
132
+ on); (4) k* of the new champion; (5) the within-period-permutation churn base rate (§2.6) -- how
133
+ often the champion would appear to change out of pure label noise on data this size.
134
+
135
+ A period in `current` counts as "new" iff its label does not appear anywhere in `previous.labels`
136
+ -- this is a set-membership test, not a positional slice, so it is correct whether the new
137
+ period(s) land at the end (the common append case), get inserted mid-panel by a merge/sort, or a
138
+ rolling window drops an old period while adding a new one at the same total T (previously
139
+ invisible to this diagnostic, since `n_new` was computed purely from `len(current) - len(previous)`
140
+ and came out 0). If `current` shares NO periods with `previous` at all (e.g. two genuinely
141
+ unrelated panels), there is nothing to restrict to -- `champion_without_new_periods` is reported as
142
+ None rather than silently attributing the change to data it never actually removed.
143
+
144
+ Raises ValueError if `previous` and `current` have different model sets -- comparing mismatched
145
+ columns silently would be worse than refusing."""
146
+ # FIXED 2026-09-02 (10-agent code-review pass, CONFIRMED GAP): compare() used to access
147
+ # previous.models/.losses directly with no type check, so raw dicts (an easy mistake for a
148
+ # caller who built L={'a':...} for the raw-array tier and assumes it also works here) raised a
149
+ # bare AttributeError instead of a clear message. The CI/pipeline-gating use case this function
150
+ # is built for makes an uncaught internal AttributeError especially bad: a promotion script
151
+ # catching a narrow exception type would not catch this and would crash the pipeline rather
152
+ # than fail cleanly.
153
+ for _name, _p in (("previous", previous), ("current", current)):
154
+ if not (hasattr(_p, "losses") and hasattr(_p, "models") and hasattr(_p, "weights")
155
+ and hasattr(_p, "labels")):
156
+ raise TypeError(
157
+ f"compare() expects two LossPanel objects, got {type(_p).__name__} for {_name!r}. "
158
+ f"Build one first with LossPanel.from_losses(...) or LossPanel.from_forecasts(...)."
159
+ )
160
+ if set(previous.models) != set(current.models):
161
+ raise ValueError(
162
+ f"previous and current panels have different model sets -- cannot compare mismatched "
163
+ f"models: {sorted(previous.models)} vs {sorted(current.models)}."
164
+ )
165
+ prev_L, prev_w = previous.losses, previous.weights
166
+ curr_L, curr_w = current.losses, current.weights
167
+ _validate_losses(curr_L, curr_w)
168
+
169
+ prev_champ = pooled_winner(prev_L, prev_w)
170
+ curr_champ = pooled_winner(curr_L, curr_w)
171
+ champion_changed = prev_champ != curr_champ
172
+
173
+ curr_labels = list(current.labels)
174
+ T_curr = len(curr_labels)
175
+ # POSITIONAL-LABEL FALLBACK, FIXED 2026-08-17 (independent 'wild' review, cross-fix interaction
176
+ # lens): the label-based new-period detection above assumes labels carry real cross-panel period
177
+ # IDENTITY. `LossPanel.from_losses()`'s default construction (no explicit `labels=`, the most
178
+ # common real usage -- a bare dict of arrays or an ndarray) assigns synthetic positional labels
179
+ # `0..T-1` and sets `labels_are_positional=True`. Two ENTIRELY UNRELATED panels built this way
180
+ # collide on labels purely by list-index coincidence, defeating this function's own "unrelated
181
+ # panels -> None, don't guess" safety net -- verified directly: two panels built from independent
182
+ # random draws (no shared periods in any real sense) still reported the champion change as
183
+ # "entirely due to new periods" or wrongly claimed 10 shared periods, purely because both used
184
+ # the default 0..9 labels. Positional labels carry NO real identity information at all outside
185
+ # the one panel that assigned them, so label-set comparison is meaningless here -- fall back to
186
+ # the position/count-based detection (assume the newest periods are the trailing rows) whenever
187
+ # EITHER side lacks real labels, which is the best available signal absent any identity
188
+ # information the caller chose not to provide. Real, non-positional labels (the case this
189
+ # function's label-based redesign was actually built for) are unaffected.
190
+ if previous.labels_are_positional or current.labels_are_positional:
191
+ T_prev = len(previous.labels)
192
+ n_new = max(0, T_curr - T_prev)
193
+ if n_new == 0:
194
+ # CONFIRMED REGRESSION (control-flow/static-logic audit, 2026-08-17): this function's own
195
+ # docstring promises "if `current` shares NO periods with `previous` at all... reported
196
+ # as None rather than silently attributing the change to data it never actually removed"
197
+ # -- the label-based branch below honors that (n_new==T_curr -> None), but this positional
198
+ # branch did not: `n_new = max(0, T_curr - T_prev)` is forced to 0 whenever T_curr <= T_prev,
199
+ # regardless of whether the two panels share anything real, so it fell straight through to
200
+ # a confident `curr_champ` instead. Positional labels carry NO identity information at
201
+ # all (both panels always trivially have labels 0..T-1), so unlike the label-based branch,
202
+ # there is no way to tell "the literal same panel, nothing new" apart from "an entirely
203
+ # unrelated panel of the same or smaller size" here -- verified directly: two panels built
204
+ # from independent random draws, both length 10, positional labels, reported a confident
205
+ # champion_without_new_periods instead of the honest None this diagnostic exists to give
206
+ # when it cannot actually attribute the change to specific removed data. T_curr < T_prev
207
+ # is even less determinable (nothing to even align positionally), so None applies there
208
+ # too, not just at T_curr == T_prev.
209
+ champion_without_new_periods = None
210
+ else:
211
+ keep = list(range(T_curr - n_new))
212
+ L_trunc = {m: np.asarray(v, float)[keep] for m, v in curr_L.items()}
213
+ w_trunc = np.asarray(curr_w, float)[keep]
214
+ champion_without_new_periods = pooled_winner(L_trunc, w_trunc)
215
+ else:
216
+ # STR-NORMALIZED, FIXED 2026-08-17 (caught in self-review before shipping): `LossPanel.save()`
217
+ # stringifies every label (`[str(l) for l in self.labels]`), so a panel round-tripped through
218
+ # save()/load() has str labels while a freshly-built panel with the SAME periods can have
219
+ # Timestamp (or other) labels -- exactly the real `compare()` workflow (load last run's saved
220
+ # baseline, compare against a freshly computed current run). Comparing raw label objects made
221
+ # EVERY period in `current` look "new" whenever only one side had been through a save/load
222
+ # round trip (Timestamp('2020-01-31') != '2020-01-31 00:00:00' by identity, even though they
223
+ # name the same period) -- reproduced directly: n_new_periods came out as T instead of 0 on two
224
+ # otherwise-identical panels. Comparing str(label) on both sides matches what save() already
225
+ # does, so a loaded and a freshly-built panel over the same periods agree regardless of which
226
+ # side (if either) went through a round trip.
227
+ prev_label_set = {str(lbl) for lbl in previous.labels}
228
+ is_new = [str(lbl) not in prev_label_set for lbl in curr_labels]
229
+ n_new = sum(is_new)
230
+ if n_new == 0:
231
+ champion_without_new_periods = curr_champ
232
+ elif n_new == T_curr:
233
+ champion_without_new_periods = None
234
+ else:
235
+ keep = [i for i, new in enumerate(is_new) if not new]
236
+ L_trunc = {m: np.asarray(v, float)[keep] for m, v in curr_L.items()}
237
+ w_trunc = np.asarray(curr_w, float)[keep]
238
+ champion_without_new_periods = pooled_winner(L_trunc, w_trunc)
239
+
240
+ # w THREADED THROUGH, FIXED 2026-08-16 (independent 'wild' review): matches the same fix in
241
+ # report()/resolution_report() -- the MCS must see the same weights the champion is computed
242
+ # with, or `old_champion_still_in_mcs` can silently answer a different, unweighted question.
243
+ #
244
+ # ROUND-8 FIX (2026-08-27): a genuinely non-uniform curr_w has no native MCS support in
245
+ # arch.bootstrap.MCS -- report()/resolution_report() already refuse cleanly rather than compute
246
+ # the MCS on a silently different (unweighted) question; this call previously let that
247
+ # ValueError propagate raw and uncaught. Caught here the same way, undetermined not silently
248
+ # False, and `.act` is forced False so a CI/pipeline promotion step never treats "couldn't
249
+ # compute" as "genuinely left the MCS."
250
+ mcs_error = None
251
+ try:
252
+ mcs_size(curr_L, alpha=alpha, seed=seed, w=curr_w)
253
+ m = _run_mcs(curr_L, alpha=alpha, seed=seed, w=curr_w)
254
+ old_champion_still_in_mcs = prev_champ in set(m.included)
255
+ except ValueError as e:
256
+ old_champion_still_in_mcs = None
257
+ mcs_error = str(e)
258
+
259
+ k_curr, _opp_curr, _removed_curr = decision_breakdown(curr_L, curr_w, a=curr_champ)
260
+
261
+ churn = _churn_base_rate(curr_L, curr_w, n_perm=n_perm, seed=seed)
262
+
263
+ act = bool(champion_changed and old_champion_still_in_mcs is False)
264
+
265
+ return ChangeReport(
266
+ previous_champion=prev_champ,
267
+ current_champion=curr_champ,
268
+ champion_changed=champion_changed,
269
+ champion_without_new_periods=champion_without_new_periods,
270
+ n_new_periods=n_new,
271
+ old_champion_still_in_mcs=old_champion_still_in_mcs,
272
+ k_star=k_curr,
273
+ churn_base_rate=churn,
274
+ act=act,
275
+ mcs_error=mcs_error,
276
+ )