1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
//! End-to-end CLI-equivalent n-sweep regression for #2273 at the real
//! `fit_from_formula` pipeline layer.
//!
//! #2273's repro is a bare **exact** (not near-, not quasi-) separation: two
//! classes with a genuine gap between them (`y=0` at `x∈[1,·]`, `y=1` at
//! `x∈[10,·]`, step 0.1), reproducing down to n=6 with the default `y ~ x`
//! formula. Before the two landed fixes, the fit hard-failed with NO model
//! at n∈{6,10,20,30,40,100,150} (and a *different* failure signature —
//! `hessian_psd=NO` — at every n tried for `y ~ smooth(x)`), while n∈{60,80,
//! 400} happened to converge: a non-monotonic pass/fail-by-n pattern that was
//! itself the tell that this was a fragile-outer-search bug, not a genuine
//! capacity/identifiability boundary (see the issue's root-cause comments).
//!
//! Two independent fixes landed:
//! 1. `FitConvergenceEvidence::try_from_parts` now mints a
//! `StalledAtValidMinimum` fit when the analytic outer criterion
//! certificate certifies (measurement over status-enum taxonomy) — this
//! is what lets the automatic Firth retry actually rescue a fit instead
//! of reaching a certified-but-refused inner state.
//! 2. `run_outer`'s stale-tolerance desync fix (7e6af6e): a solver
//! convergence claim that fails analytic certification is retried once,
//! re-seeded at the refused checkpoint, so the retry's tolerance anchor
//! matches the certificate's bound by construction — this is what fixes
//! the *non*-Firth path's premature 1-outer-iteration give-up.
//!
//! This file is the CLI n-sweep verification the issue explicitly calls out
//! as owed: every n across the reported failure/pass boundary must now
//! return a MODEL (mint), not a hard error, through the production
//! `fit_from_formula` entry point (the same one `gam fit ... --out m.json`
//! resolves to) — the top-level `gam` crate itself cannot build in this
//! environment (a `build.rs` author tripwire, see the #1762 sibling test
//! file), so this exercises the identical formula-fit path one layer down,
//! in `gam-models`, which builds standalone.
use fit_from_formula;
use ;
use StringRecord;
use encode_recordswith_inferred_schema;
/// Build the issue's EXACT (not statistically near-) separation fixture:
/// `n/2` rows of class 0 at `x = 1.0, 1.1, 1.2, ...` and `n/2` rows of class
/// 1 at `x = 10.0, 10.1, 10.2, ...` — a genuine gap between the two support
/// intervals `[1, 1+0.1·(n/2−1)]` and `[10, 10+0.1·(n/2−1)]`, deterministic
/// (no RNG), mirroring the issue's `sep_n6.csv`/n-sweep table verbatim. `n`
/// must be even (every n the issue reports, 6..400, is).
/// Fit `formula` on the exact-separation fixture at `n` through the
/// production formula-fit entry point and assert a MODEL is minted (not a
/// hard error) — the #2273 contract. `firth` mirrors the CLI's `--firth`
/// flag; `false` is the default CLI path (automatic Firth retry still
/// engages internally when the base error is Firth-retryable, per
/// `firth_can_rescue` in `fit_orchestration/fit.rs`).
/// The issue's exact n-sweep table for the default `y ~ x` formula
/// (automatic-retry path, no explicit `--firth`): every listed n — both the
/// ones that hard-failed pre-fix (6, 10, 20, 30, 40, 100, 150) and the ones
/// that happened to already converge (60, 80, 400) — must mint a model.
/// The non-monotonic fail/pass-by-n pattern in the original report is
/// exactly why every one of these is asserted individually rather than
/// spot-checking a single n.
/// The `smooth(x)` variant the issue reports failing at every n tried
/// (40/60/80/100) with a *different* signature (`hessian_psd=NO`, a
/// genuinely indefinite outer REML Hessian rather than a gradient-tolerance
/// miss) — a separate code path from the linear-term sweep above, so it gets
/// its own regression rather than riding the linear assertion.
/// The issue's explicit `--firth` repro at n=40 (`gam fit ... --firth`,
/// which pre-fix reached exactly `StalledAtValidMinimum` and was refused by
/// the strict fit-assembly gate even though the certified stationarity
/// residual was five orders of magnitude inside its own bound). Explicit
/// Firth must mint here too, independent of the automatic-retry path
/// exercised by the two sweeps above.
/// The coefficient-runaway / Fisher-weight-collapse separation pathology is
/// NOT logit-specific — it afflicts every binomial inverse link. Before the
/// link-general rescue, the reactive Firth retry in `fit_from_formula` was
/// gated on `is_binomial_logit`, so a NON-logit binomial fit
/// (`link(type=probit)`, `link(type=cloglog)`) on exactly-separated data
/// produced a retryable `RemlDidNotConverge`/PrefitSeparation error that the
/// link gate then refused to act on — a hard failure with no model, off the
/// default link, despite the README promising Firth handles binomial
/// separation.
///
/// The headline case is probit at n=6: measured to halt on a flat-valley
/// outer stall (|g|≈1.9e2 ≫ bound=1.0) and mint only under Firth, with the
/// automatic retry never engaging pre-fix. After gating the rescue on
/// `LikelihoodSpec::supports_firth()` (Binomial + Fisher-weight-jet link),
/// every one of these mints through the same no-explicit-`--firth`
/// automatic-retry path the default-link sweeps use. This test goes red if
/// the rescue is ever narrowed back to the logit special case.