Skip to main content

gam_solve/
topology_selector.rs

1//! Auto-selection helpers for latent-coordinate topology candidates.
2//!
3//! This module deliberately returns a single selected topology rather than a
4//! stacked predictive-distribution mixture. The selector is an evidence
5//! comparator: its inputs are one scalar REML/LAML evidence summary per fitted
6//! topology plus null-space normalizers. It does not receive a per-observation
7//! held-out log predictive density table, nor does the saved-model prediction
8//! path retain the alternative candidate fits required to evaluate a mixture at
9//! new rows. A pseudo-BMA softmax over the scalar TK scores would therefore be
10//! model-probability averaging of incomparable topology objects, not stacking of
11//! predictive distributions. Until a real per-point LOO/ALO density consumer is
12//! introduced alongside retained candidate predictors, the principled live path
13//! is winner-take-all with deterministic score ordering.
14//!
15//! ## Selection-time stacking is now wired (WP-C / Object 3a)
16//!
17//! The winner-take-all blocker above is specifically about the SAVED-MODEL
18//! PREDICTION path: that path does not retain alternative candidate predictors,
19//! so it cannot evaluate a mixture (or any losing candidate) at new rows. That
20//! blocker still stands for out-of-sample prediction.
21//!
22//! It does NOT, however, block stacking *at selection time*. During the race
23//! the candidate fits all exist, so we can build a per-observation held-out
24//! predictive log-density table by cross-validation folds within the race and
25//! feed it to [`crate::evidence::solve_stacking_weights`]. This module
26//! does exactly that when a race mixes model classes (smooth manifold vs the
27//! discrete-mixture rung): the HEADLINE ranking statistic switches to held-out
28//! predictive log-density / stacking weights, with the rank-aware Laplace
29//! evidence retained as corroboration. Same-class races keep today's
30//! winner-take-all evidence behavior. The class mix is auto-detected from the
31//! candidate kinds — there is no flag.
32//!
33//! What is still future work: persisting a mixture predictor for OOS prediction
34//! on new rows. The selection-time stacking table is computed from fits that
35//! exist only during the race; it is not retained for the saved-model
36//! prediction path. That OOS-retention package is out of scope here.
37
38use crate::evidence::{
39    GaussianMixtureConfig, StackingConfig, StackingWeights, TopologyScoreScale,
40    UNION_STRUCTURE_LADDER, UnionStructure, UnionStructureFit, fit_gaussian_mixture,
41    fit_union_ladder, fit_union_structure, solve_stacking_weights, union_per_point_log_density,
42};
43use crate::priority_selection::{PriorityCandidate, rank_priority_candidates};
44use crate::row_sampling_measure::CoresetCertificate;
45use ndarray::{Array2, ArrayView2};
46use serde_json::Value as JsonValue;
47use statrs::distribution::{ChiSquared, ContinuousCDF};
48use std::sync::Mutex;
49use std::time::{Duration, Instant};
50
51const TK_LOG_2PI: f64 = 1.8378770664093453_f64;
52
53/// Fixed component ladder swept for the discrete-mixture rung. Deterministic;
54/// each `k` is priced by its own free-parameter count via the rank-aware
55/// Laplace evidence and ranked against the others in-class before the winning
56/// mixture order competes cross-class.
57pub const MIXTURE_K_LADDER: &[usize] = &[1, 2, 3, 5, 7, 9];
58
59/// Number of cross-validation folds used to build the selection-time held-out
60/// predictive log-density table for cross-class stacking. Fixed (no flag).
61pub const STACKING_CV_FOLDS: usize = 5;
62
63/// Default seed mixed into the deterministic CV fold assignment for cross-class
64/// stacking. Matches the pyo3 `seed = 11` default of `adjudicate_atom_shape`, so
65/// the FFI default and the in-tree default produce the identical (deterministic)
66/// folding. Different seeds yield different — but still deterministic — foldings;
67/// the same seed always reproduces the same folding (#1386).
68pub const STACKING_CV_SEED: u64 = 11;
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
71pub enum AutoTopologyKind {
72    Euclidean,
73    Circle,
74    Sphere,
75    Torus,
76    Cylinder,
77    /// Möbius band (#2240): the unique non-orientable smooth `d = 2`
78    /// candidate. Genuinely non-homotopic to the torus / cylinder / sphere /
79    /// patch, so it races as its own discrete candidate — no curvature fusion
80    /// applies to it.
81    Mobius,
82    /// Flexible thin-plate (Duchon) 2-D sheet (#2240): topologically a plane,
83    /// but with UNRESTRICTED embedding curvature — the chart for
84    /// swiss-roll-class sheets a degree-2 flat patch cannot follow. It is not a
85    /// fixed constant-curvature space form (its curvature is neither constant
86    /// nor a single estimated κ), so the #944 Euclidean/Sphere fusion must not
87    /// absorb it: it races as its own discrete candidate.
88    DuchonSheet,
89    /// Constant-curvature space form `M_κ` with the sectional curvature κ
90    /// ESTIMATED (#944 stage 4). This single candidate replaces the family of
91    /// fixed simply-connected constant-curvature geometries — `Euclidean`
92    /// (κ = 0), `Sphere` (κ > 0), and the hyperbolic disk (κ < 0) — which are
93    /// all the same `S^d ← ℝ^d → H^d` manifold at different κ. Rather than
94    /// racing those as separate discrete candidates, the curvature is fitted as
95    /// a continuous estimand (`curv(...)`), so the topology stack only has to
96    /// adjudicate genuinely non-homotopic candidates (`Circle`/`Torus`/
97    /// `Cylinder`/discrete rungs). "Within a candidate, curvature is estimated."
98    ConstantCurvature,
99    /// Discrete `k`-component Gaussian-mixture rung (Object 3a / WP-C). Not a
100    /// smooth manifold: a finite-parameter clustered density. Its presence in a
101    /// race triggers cross-class stacking adjudication.
102    Mixture {
103        k: usize,
104    },
105    /// Structured-union composite (#907): a small FIXED set of component
106    /// structures joined by a hard responsibility split (e.g. circle+circle,
107    /// circle+cluster, line+cluster). Like the mixture rung it is a discrete,
108    /// non-smooth density class — its evidence is the SUMMED rank-aware Laplace
109    /// evidence of its components, priced by total parameter count. Its presence
110    /// in a race triggers cross-class stacking adjudication.
111    Union {
112        structure: UnionStructure,
113    },
114}
115
116impl AutoTopologyKind {
117    /// Stable display name. The mixture variant carries its order `k`, so its
118    /// label is rendered with [`AutoTopologyKind::display_name`]; the borrowed
119    /// `as_str` returns the class tag for the smooth variants and the bare
120    /// `"mixture"` tag for the discrete rung.
121    pub const fn as_str(self) -> &'static str {
122        match self {
123            AutoTopologyKind::Euclidean => "euclidean",
124            AutoTopologyKind::Circle => "circle",
125            AutoTopologyKind::Sphere => "sphere",
126            AutoTopologyKind::Torus => "torus",
127            AutoTopologyKind::Cylinder => "cylinder",
128            AutoTopologyKind::Mobius => "mobius",
129            AutoTopologyKind::DuchonSheet => "duchon_sheet",
130            AutoTopologyKind::ConstantCurvature => "constant_curvature",
131            AutoTopologyKind::Mixture { .. } => "mixture",
132            AutoTopologyKind::Union { structure } => structure.as_str(),
133        }
134    }
135
136    /// Owned display name including the mixture order, e.g. `"mixture_k7"`, or
137    /// the union composite tag, e.g. `"union_circle+circle"`.
138    pub fn display_name(self) -> String {
139        match self {
140            AutoTopologyKind::Mixture { k } => format!("mixture_k{k}"),
141            other => other.as_str().to_string(),
142        }
143    }
144
145    /// `true` iff this candidate is the discrete-mixture model class (as
146    /// opposed to a smooth manifold / Euclidean latent topology).
147    pub const fn is_discrete_mixture(self) -> bool {
148        matches!(self, AutoTopologyKind::Mixture { .. })
149    }
150
151    /// `true` iff this candidate is the structured-union composite class (#907).
152    pub const fn is_structured_union(self) -> bool {
153        matches!(self, AutoTopologyKind::Union { .. })
154    }
155
156    /// `true` iff this candidate is a discrete (non-smooth) density class — the
157    /// mixture rung or a structured union. Cross-class stacking adjudication is
158    /// triggered when a race mixes a smooth/Euclidean candidate with any
159    /// discrete one.
160    pub const fn is_discrete_class(self) -> bool {
161        self.is_discrete_mixture() || self.is_structured_union()
162    }
163
164    pub fn parse(value: &str) -> Result<Self, String> {
165        let normalized = value.trim().to_ascii_lowercase().replace('-', "_");
166        if let Some(structure) = parse_union_name(&normalized) {
167            return Ok(AutoTopologyKind::Union { structure });
168        }
169        if let Some(rest) = normalized.strip_prefix("mixture") {
170            // Accept "mixture", "mixture_k7", "mixture7", "mixture_7".
171            let digits: String = rest.chars().filter(|c| c.is_ascii_digit()).collect();
172            if digits.is_empty() {
173                // Bare "mixture" expands to the full ladder; callers that want a
174                // single order pass "mixture_k{n}". Here we default to the
175                // richest ladder order so a singleton request is still valid.
176                return Ok(AutoTopologyKind::Mixture {
177                    k: *MIXTURE_K_LADDER.last().unwrap_or(&7),
178                });
179            }
180            let k: usize = digits
181                .parse()
182                .map_err(|_| format!("mixture order must be a positive integer; got {value:?}"))?;
183            if k == 0 {
184                return Err("mixture order k must be >= 1".to_string());
185            }
186            return Ok(AutoTopologyKind::Mixture { k });
187        }
188        match normalized.as_str() {
189            "euclidean" | "flat" | "euclidean_patch" | "euclideanpatch" => {
190                Ok(AutoTopologyKind::Euclidean)
191            }
192            "circle" | "periodic" | "s1" => Ok(AutoTopologyKind::Circle),
193            "sphere" | "s2" => Ok(AutoTopologyKind::Sphere),
194            "torus" => Ok(AutoTopologyKind::Torus),
195            "cylinder" => Ok(AutoTopologyKind::Cylinder),
196            "duchon" | "duchon_sheet" | "duchonsheet" | "thin_plate" | "thinplate" => {
197                Ok(AutoTopologyKind::DuchonSheet)
198            }
199            "constant_curvature" | "curv" | "curvature" | "mkappa" | "m_kappa" => {
200                Ok(AutoTopologyKind::ConstantCurvature)
201            }
202            other => Err(format!(
203                "topology candidate must be euclidean, circle, sphere, torus, cylinder, duchon_sheet, constant_curvature, mixture[_k{{n}}], or a union (union_circle+circle, union_circle+cluster, union_line+cluster); got {other:?}"
204            )),
205        }
206    }
207
208    pub fn all() -> Vec<Self> {
209        vec![
210            AutoTopologyKind::Euclidean,
211            AutoTopologyKind::Circle,
212            AutoTopologyKind::Sphere,
213            AutoTopologyKind::Torus,
214            AutoTopologyKind::Cylinder,
215        ]
216    }
217
218    /// `true` iff this candidate is a FIXED member of the simply-connected
219    /// constant-curvature space-form family `M_κ` — the geometries that differ
220    /// only by their (fixed) sectional curvature κ and are therefore the *same*
221    /// continuous `S^d ← ℝ^d → H^d` manifold at different κ:
222    ///   * [`Euclidean`](AutoTopologyKind::Euclidean) — κ = 0,
223    ///   * [`Sphere`](AutoTopologyKind::Sphere) — κ > 0.
224    /// (`Circle`/`Torus`/`Cylinder` are NOT in this family: they are not
225    /// simply connected — distinct topology, not distinct curvature — so they
226    /// must keep racing as separate candidates.) The fitted-κ
227    /// [`ConstantCurvature`](AutoTopologyKind::ConstantCurvature) candidate is
228    /// itself the fusion target, not a member to be fused.
229    pub const fn is_fixed_constant_curvature_form(self) -> bool {
230        matches!(self, AutoTopologyKind::Euclidean | AutoTopologyKind::Sphere)
231    }
232
233    /// #944 stage 4 — "within a candidate, curvature is estimated." Collapse the
234    /// fixed constant-curvature space forms in a candidate set into ONE
235    /// [`ConstantCurvature`](AutoTopologyKind::ConstantCurvature) candidate whose
236    /// κ is fitted, so the discrete topology stack only adjudicates genuinely
237    /// non-homotopic candidates. Magic by default: the fusion fires exactly when
238    /// the set contains **≥ 2** fixed constant-curvature forms (e.g. both
239    /// `Euclidean` and `Sphere`) — a single such form is left untouched (there
240    /// is nothing to estimate κ *across*), and a set already carrying an
241    /// explicit `ConstantCurvature` candidate simply has its redundant fixed
242    /// forms removed.
243    ///
244    /// Order is preserved and otherwise stable: the fused `ConstantCurvature`
245    /// candidate takes the position of the first fixed form it replaces; all
246    /// non-family candidates (`Circle`/`Torus`/`Cylinder`/`Mixture`/`Union`) and
247    /// any duplicates keep their relative order. Idempotent.
248    pub fn fuse_constant_curvature_family(candidates: &[Self]) -> Vec<Self> {
249        let already_has_cc = candidates
250            .iter()
251            .any(|c| matches!(c, AutoTopologyKind::ConstantCurvature));
252        let fixed_form_count = candidates
253            .iter()
254            .filter(|c| c.is_fixed_constant_curvature_form())
255            .count();
256        // Fuse when ≥2 fixed forms are present, OR when an explicit
257        // ConstantCurvature candidate already subsumes ≥1 redundant fixed form.
258        let should_fuse = fixed_form_count >= 2 || (already_has_cc && fixed_form_count >= 1);
259        if !should_fuse {
260            return candidates.to_vec();
261        }
262        let mut out = Vec::with_capacity(candidates.len());
263        let mut emitted_cc = false;
264        for &c in candidates {
265            if c.is_fixed_constant_curvature_form() {
266                // Replace the first fixed form by the fitted-κ candidate (unless
267                // an explicit one already exists); drop the rest.
268                if !already_has_cc && !emitted_cc {
269                    out.push(AutoTopologyKind::ConstantCurvature);
270                    emitted_cc = true;
271                }
272                continue;
273            }
274            if matches!(c, AutoTopologyKind::ConstantCurvature) {
275                if emitted_cc {
276                    continue; // collapse duplicate CC candidates
277                }
278                emitted_cc = true;
279            }
280            out.push(c);
281        }
282        out
283    }
284
285    /// The full discrete-mixture rung: one candidate per `k` in
286    /// [`MIXTURE_K_LADDER`].
287    pub fn mixture_ladder() -> Vec<Self> {
288        MIXTURE_K_LADDER
289            .iter()
290            .map(|&k| AutoTopologyKind::Mixture { k })
291            .collect()
292    }
293
294    /// The full structured-union rung: one candidate per composite in
295    /// [`UNION_STRUCTURE_LADDER`] (#907). Fixed and closed — no open-ended
296    /// structure search (that stays owned by #976's move set).
297    pub fn union_ladder() -> Vec<Self> {
298        UNION_STRUCTURE_LADDER
299            .iter()
300            .map(|&structure| AutoTopologyKind::Union { structure })
301            .collect()
302    }
303}
304
305/// Parse a structured-union composite name (#907). Accepts the canonical display
306/// tags (`union_circle+circle`, `union_circle+cluster`, `union_line+cluster`)
307/// and tolerant `_`/`-` separators in place of `+`. Returns `None` for any name
308/// that is not a union so [`AutoTopologyKind::parse`] can fall through to the
309/// smooth/mixture variants. Input is assumed already lowercased with `-`→`_`.
310pub fn parse_union_name(normalized: &str) -> Option<UnionStructure> {
311    let Some(rest) = normalized.strip_prefix("union") else {
312        return None;
313    };
314    // Canonicalize component separators: both `+` and the tolerant `_`/`-`
315    // forms collapse to `+`, and any leading separator after the `union`
316    // prefix is dropped (so `union_circle+circle` and `union__circle_circle`
317    // both normalize to `circle+circle`).
318    let body: String = rest
319        .chars()
320        .map(|c| if c == '_' || c == '-' { '+' } else { c })
321        .collect();
322    let body = body.trim_matches('+');
323    match body {
324        "circle+circle" => Some(UnionStructure::CircleCircle),
325        "circle+cluster" | "circle+point+cluster" | "circle+pointcluster" => {
326            Some(UnionStructure::CirclePointCluster)
327        }
328        "line+cluster" | "line+point+cluster" | "line+pointcluster" => {
329            Some(UnionStructure::LineCluster)
330        }
331        _ => None,
332    }
333}
334
335#[derive(Debug, Clone)]
336pub struct TopologyAutoSelector {
337    pub candidates: Vec<AutoTopologyKind>,
338    pub score_scale: TopologyScoreScale,
339    pub latent: Option<String>,
340}
341
342impl TopologyAutoSelector {
343    pub fn new(candidates: Option<Vec<AutoTopologyKind>>) -> Self {
344        Self {
345            candidates: candidates.unwrap_or_else(AutoTopologyKind::all),
346            score_scale: TopologyScoreScale::PerEffectiveDim,
347            latent: None,
348        }
349    }
350
351    pub fn from_json(value: &JsonValue) -> Result<Self, String> {
352        let obj = value
353            .as_object()
354            .ok_or_else(|| "topology_auto_selector must be an object".to_string())?;
355        let candidates = match obj.get("candidates").filter(|value| !value.is_null()) {
356            None => AutoTopologyKind::all(),
357            Some(raw) => {
358                let items = raw.as_array().ok_or_else(|| {
359                    "topology_auto_selector.candidates must be a list".to_string()
360                })?;
361                if items.is_empty() {
362                    return Err(
363                        "topology_auto_selector.candidates must have at least one entry"
364                            .to_string(),
365                    );
366                }
367                let mut out = Vec::with_capacity(items.len());
368                for (idx, item) in items.iter().enumerate() {
369                    let name = item.as_str().ok_or_else(|| {
370                        format!("topology_auto_selector.candidates[{idx}] must be a string")
371                    })?;
372                    let kind = AutoTopologyKind::parse(name)?;
373                    if out.contains(&kind) {
374                        return Err(format!(
375                            "topology_auto_selector duplicate candidate {:?}",
376                            kind.as_str()
377                        ));
378                    }
379                    out.push(kind);
380                }
381                out
382            }
383        };
384        let score_scale = match obj
385            .get("score_scale")
386            .and_then(JsonValue::as_str)
387            .unwrap_or("per_effective_dim")
388            .trim()
389            .to_ascii_lowercase()
390            .replace('-', "_")
391            .as_str()
392        {
393            "per_observation" => TopologyScoreScale::PerObservation,
394            "per_effective_dim" => TopologyScoreScale::PerEffectiveDim,
395            other => {
396                return Err(format!(
397                    "topology_auto_selector.score_scale must be per_effective_dim or per_observation; got {other:?}"
398                ));
399            }
400        };
401        let latent = obj
402            .get("latent")
403            .filter(|value| !value.is_null())
404            .map(|value| {
405                value
406                    .as_str()
407                    .map(str::to_string)
408                    .ok_or_else(|| "topology_auto_selector.latent must be a string".to_string())
409            })
410            .transpose()?;
411        Ok(Self {
412            candidates,
413            score_scale,
414            latent,
415        })
416    }
417}
418
419#[derive(Debug, Clone)]
420pub struct TopologyAutoFitEvidence<FitHandle> {
421    pub topology_name: String,
422    pub raw_reml: f64,
423    pub null_dim: f64,
424    pub null_space_logdet: Option<f64>,
425    pub effective_dim: f64,
426    pub n_obs: usize,
427    pub fit_handle: FitHandle,
428}
429
430#[derive(Debug, Clone)]
431pub struct TopologyAutoRankedFit<FitHandle> {
432    pub topology_name: String,
433    pub tk_score: f64,
434    pub raw_reml: f64,
435    pub effective_dim: f64,
436    pub n_obs: usize,
437    pub fit_handle: FitHandle,
438}
439
440#[derive(Debug, Clone)]
441pub struct TopologyAutoSelectorResult<FitHandle> {
442    pub ranked: Vec<TopologyAutoRankedFit<FitHandle>>,
443    pub winner_index: usize,
444    /// Every requested candidate that did not produce selectable, finite
445    /// evidence. Failures remain part of the lifecycle result even when another
446    /// candidate wins; callers must never reconstruct them from log strings.
447    pub failed: Vec<TopologyAutoFailedCandidate>,
448}
449
450impl<FitHandle> TopologyAutoSelectorResult<FitHandle> {
451    pub fn winner(&self) -> Option<&TopologyAutoRankedFit<FitHandle>> {
452        self.ranked.get(self.winner_index)
453    }
454}
455
456/// Stage at which a topology candidate became non-selectable.
457#[derive(Debug, Clone, Copy, PartialEq, Eq)]
458pub enum TopologyCandidateFailureStage {
459    Assembly,
460    Fit,
461    Evidence,
462}
463
464impl TopologyCandidateFailureStage {
465    pub const fn as_str(self) -> &'static str {
466        match self {
467            Self::Assembly => "assembly",
468            Self::Fit => "fit",
469            Self::Evidence => "evidence",
470        }
471    }
472}
473
474/// Explicit record for a requested topology candidate that failed.
475///
476/// `evidence_at_failure` is populated when the fit converged but its evidence
477/// metadata was invalid. A failed record is never ranked and is never silently
478/// substituted by a cheaper or previously fitted candidate.
479#[derive(Debug, Clone)]
480pub struct TopologyAutoFailedCandidate {
481    pub candidate: AutoTopologyKind,
482    pub topology_name: String,
483    pub stage: TopologyCandidateFailureStage,
484    pub message: String,
485    pub evidence_at_failure: Option<f64>,
486}
487
488/// Evidence family used to adjudicate a completed topology candidate fit.
489#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490pub enum TopologySelectionScoreKind {
491    Reml,
492    Laml,
493    Bic,
494    Tk,
495}
496
497impl TopologySelectionScoreKind {
498    pub const fn as_str(self) -> &'static str {
499        match self {
500            Self::Reml => "reml",
501            Self::Laml => "laml",
502            Self::Bic => "bic",
503            Self::Tk => "tk",
504        }
505    }
506}
507
508/// Scale applied after the candidate's raw evidence cost is formed.
509#[derive(Debug, Clone, Copy, PartialEq, Eq)]
510pub enum TopologySelectionScoreScale {
511    Raw,
512    PerObservation,
513    PerEffectiveDim,
514}
515
516impl TopologySelectionScoreScale {
517    pub const fn as_str(self) -> &'static str {
518        match self {
519            Self::Raw => "raw",
520            Self::PerObservation => "per_observation",
521            Self::PerEffectiveDim => "per_effective_dim",
522        }
523    }
524}
525
526/// Typed metadata extracted from one completed candidate fit.
527///
528/// Optional fields are score-specific: LAML requires `laml`, BIC requires
529/// `deviance`, and TK/LAML require `null_dim` (plus `null_space_logdet` when the
530/// null dimension is non-zero). The lifecycle selector validates only the
531/// requested headline score; unavailable secondary scores are omitted from the
532/// disagreement diagnostic instead of changing candidate eligibility.
533#[derive(Debug, Clone)]
534pub struct TopologyCandidateEvidence {
535    pub name: String,
536    pub raw_reml: f64,
537    pub laml: Option<f64>,
538    pub deviance: Option<f64>,
539    pub null_dim: Option<f64>,
540    pub null_space_logdet: Option<f64>,
541    pub effective_dim: f64,
542    pub basis_size: usize,
543    pub n_obs: usize,
544}
545
546/// One failed candidate supplied to, or produced by, the lifecycle selector.
547#[derive(Debug, Clone)]
548pub struct TopologyCandidateFailure {
549    pub name: String,
550    pub stage: TopologyCandidateFailureStage,
551    pub error_type: String,
552    pub message: String,
553    pub evidence_at_failure: Option<f64>,
554}
555
556/// Exactly one terminal outcome for a requested topology candidate.
557#[derive(Debug, Clone)]
558pub enum TopologyCandidateOutcome {
559    Fitted(TopologyCandidateEvidence),
560    Failed(TopologyCandidateFailure),
561}
562
563impl TopologyCandidateOutcome {
564    fn name(&self) -> &str {
565        match self {
566            Self::Fitted(evidence) => &evidence.name,
567            Self::Failed(failure) => &failure.name,
568        }
569    }
570}
571
572/// A selectable candidate after score construction and validation.
573#[derive(Debug, Clone)]
574pub struct TopologyCandidateRanked {
575    pub name: String,
576    pub score: f64,
577    pub raw_reml: f64,
578    pub effective_dim: f64,
579    pub basis_size: usize,
580    pub n_obs: usize,
581}
582
583/// Complete candidate lifecycle: survivors, failures, winner, and diagnostics.
584#[derive(Debug, Clone)]
585pub struct TopologyCandidateSelectionResult {
586    pub ranked: Vec<TopologyCandidateRanked>,
587    pub winner_index: Option<usize>,
588    pub failed: Vec<TopologyCandidateFailure>,
589    pub warnings: Vec<String>,
590}
591
592fn failed_topology_summary(failed: &[TopologyAutoFailedCandidate]) -> String {
593    failed
594        .iter()
595        .map(|failure| {
596            format!(
597                "{} [{}]: {}",
598                failure.topology_name,
599                failure.stage.as_str(),
600                failure.message
601            )
602        })
603        .collect::<Vec<_>>()
604        .join("; ")
605}
606
607/// Result for one candidate executed by [`run_topology_race_parallel`].
608#[derive(Debug, Clone)]
609pub struct TopologyRaceParallelCandidate<FitResult> {
610    /// Original position in the input candidate vector.
611    pub candidate_index: usize,
612    /// The number of Rayon workers made available to this candidate's fit body.
613    pub per_fit_threads: usize,
614    /// Wall-clock time spent inside the candidate's local Rayon pool.
615    pub wall_time: Duration,
616    /// The fit closure's output. Use `FitResult = Result<T, E>` when individual
617    /// candidate failures should be collected rather than short-circuiting.
618    pub result: FitResult,
619}
620
621#[derive(Debug, Clone, Copy, PartialEq, Eq)]
622struct TopologyRaceThreadPlan {
623    coordinator_threads: usize,
624    per_fit_threads: usize,
625    concurrent_fits: usize,
626}
627
628impl TopologyRaceThreadPlan {
629    fn for_budget(candidate_count: usize, max_total_threads: usize) -> Self {
630        let max_total_threads = max_total_threads.max(1);
631        if candidate_count <= 1 {
632            return Self {
633                coordinator_threads: 0,
634                per_fit_threads: max_total_threads,
635                concurrent_fits: candidate_count,
636            };
637        }
638
639        let concurrent_fits = if max_total_threads >= 4 {
640            candidate_count.min(max_total_threads / 2).max(1)
641        } else {
642            1
643        };
644        let coordinator_threads = concurrent_fits;
645        let remaining = max_total_threads.saturating_sub(coordinator_threads);
646        let per_fit_threads = if remaining == 0 {
647            1
648        } else {
649            (remaining / concurrent_fits).max(1)
650        };
651        Self {
652            coordinator_threads,
653            per_fit_threads,
654            concurrent_fits,
655        }
656    }
657}
658
659/// Run independent topology-race candidates concurrently with bounded nested
660/// Rayon use.
661///
662/// Each candidate is executed inside its own local Rayon pool, so fit internals
663/// that call `par_iter`, `rayon::join`, or faer-through-Rayon consume the
664/// candidate's `per_fit_threads` budget rather than the global pool. For
665/// multi-candidate races the runner batches candidates through a Rayon scope and
666/// chooses `concurrent_fits`/`per_fit_threads` so the coordinator workers plus
667/// per-fit workers do not exceed `std::thread::available_parallelism()` on hosts
668/// with at least two cores. Single-core hosts run candidates sequentially.
669///
670/// The return vector is in input order and keeps each closure output intact; use
671/// `FitResult = Result<T, E>` to collect per-candidate failures with wall times.
672pub fn run_topology_race_parallel<Candidate, FitResult, FitOne>(
673    candidates: Vec<Candidate>,
674    fit_one: FitOne,
675) -> Result<Vec<TopologyRaceParallelCandidate<FitResult>>, String>
676where
677    Candidate: Send,
678    FitResult: Send,
679    FitOne: Fn(Candidate) -> FitResult + Sync,
680{
681    let max_total_threads = std::thread::available_parallelism()
682        .map(std::num::NonZeroUsize::get)
683        .unwrap_or(1);
684    run_topology_race_parallel_with_budget(candidates, fit_one, max_total_threads)
685}
686
687fn run_topology_race_parallel_with_budget<Candidate, FitResult, FitOne>(
688    candidates: Vec<Candidate>,
689    fit_one: FitOne,
690    max_total_threads: usize,
691) -> Result<Vec<TopologyRaceParallelCandidate<FitResult>>, String>
692where
693    Candidate: Send,
694    FitResult: Send,
695    FitOne: Fn(Candidate) -> FitResult + Sync,
696{
697    let candidate_count = candidates.len();
698    if candidate_count == 0 {
699        return Ok(Vec::new());
700    }
701
702    let plan = TopologyRaceThreadPlan::for_budget(candidate_count, max_total_threads);
703    let mut candidates: Vec<Option<Candidate>> = candidates.into_iter().map(Some).collect();
704    let slots: Vec<Mutex<Option<TopologyRaceParallelCandidate<FitResult>>>> =
705        (0..candidate_count).map(|_| Mutex::new(None)).collect();
706    let pool_error: Mutex<Option<String>> = Mutex::new(None);
707
708    if plan.concurrent_fits <= 1 {
709        for idx in 0..candidate_count {
710            let candidate = candidates[idx]
711                .take()
712                .expect("topology race candidate must be present");
713            run_one_topology_race_candidate(
714                idx,
715                candidate,
716                &fit_one,
717                plan.per_fit_threads,
718                &slots[idx],
719                &pool_error,
720            );
721            if let Some(err) = pool_error.lock().expect("pool_error mutex poisoned").take() {
722                return Err(err);
723            }
724        }
725    } else {
726        let coordinator_pool = rayon::ThreadPoolBuilder::new()
727            .num_threads(plan.coordinator_threads)
728            .thread_name(|idx| format!("topology-race-coordinator-{idx}"))
729            .build()
730            .map_err(|err| format!("topology race coordinator Rayon pool: {err}"))?;
731        let mut batch_start = 0usize;
732        while batch_start < candidate_count {
733            let batch_end = (batch_start + plan.concurrent_fits).min(candidate_count);
734            coordinator_pool.scope(|scope| {
735                for idx in batch_start..batch_end {
736                    let candidate = candidates[idx]
737                        .take()
738                        .expect("topology race candidate must be present");
739                    let slot = &slots[idx];
740                    let pool_error = &pool_error;
741                    let fit_one = &fit_one;
742                    scope.spawn(move |_| {
743                        run_one_topology_race_candidate(
744                            idx,
745                            candidate,
746                            fit_one,
747                            plan.per_fit_threads,
748                            slot,
749                            pool_error,
750                        );
751                    });
752                }
753            });
754            if let Some(err) = pool_error.lock().expect("pool_error mutex poisoned").take() {
755                return Err(err);
756            }
757            batch_start = batch_end;
758        }
759    }
760
761    let mut out = Vec::with_capacity(candidate_count);
762    for (idx, slot) in slots.into_iter().enumerate() {
763        let row = slot
764            .into_inner()
765            .expect("topology race result mutex poisoned")
766            .ok_or_else(|| format!("topology race candidate {idx} did not produce a result"))?;
767        out.push(row);
768    }
769    Ok(out)
770}
771
772fn run_one_topology_race_candidate<Candidate, FitResult, FitOne>(
773    candidate_index: usize,
774    candidate: Candidate,
775    fit_one: &FitOne,
776    per_fit_threads: usize,
777    slot: &Mutex<Option<TopologyRaceParallelCandidate<FitResult>>>,
778    pool_error: &Mutex<Option<String>>,
779) where
780    Candidate: Send,
781    FitResult: Send,
782    FitOne: Fn(Candidate) -> FitResult + Sync,
783{
784    let pool = match rayon::ThreadPoolBuilder::new()
785        .num_threads(per_fit_threads)
786        .thread_name(move |idx| format!("topology-race-fit-{candidate_index}-{idx}"))
787        .build()
788    {
789        Ok(pool) => pool,
790        Err(err) => {
791            *pool_error.lock().expect("pool_error mutex poisoned") =
792                Some(format!("topology race candidate Rayon pool: {err}"));
793            return;
794        }
795    };
796
797    let started = Instant::now();
798    // #2074 — each candidate fit runs inside its own nested Rayon pool. faer's
799    // high-level solvers (arrow-Schur Cholesky/solve, SVD, QR) read the global
800    // parallelism policy and, under the default `Par::rayon(0)`, dispatch through
801    // faer's `spindle` barrier pool. From inside this already-nested Rayon worker
802    // that barrier waits for pool slots the outer fan-out holds, deadlocking the
803    // fit at 0% CPU. Pin faer to `Par::Seq` for the whole nested fit so it never
804    // spawns a nested barrier pool; the per-candidate parallelism is the race
805    // itself, and faer reductions are parallelism-invariant so the result is
806    // bit-identical to the sequential path.
807    let result =
808        pool.install(|| gam_linalg::faer_ndarray::with_faer_sequential(|| fit_one(candidate)));
809    let wall_time = started.elapsed();
810    *slot.lock().expect("topology race result mutex poisoned") =
811        Some(TopologyRaceParallelCandidate {
812            candidate_index,
813            per_fit_threads,
814            wall_time,
815            result,
816        });
817}
818
819pub fn select_topology_with_fit<FitHandle, FitErr>(
820    selector: &TopologyAutoSelector,
821    mut fit_one: impl FnMut(AutoTopologyKind) -> Result<TopologyAutoFitEvidence<FitHandle>, FitErr>,
822) -> Result<TopologyAutoSelectorResult<FitHandle>, String>
823where
824    FitErr: ToString,
825{
826    // #944 stage 4: collapse fixed simply-connected constant-curvature forms
827    // (Euclidean/Sphere) into ONE estimated-κ ConstantCurvature candidate so the
828    // discrete stack only adjudicates genuinely non-homotopic topologies.
829    let fused = AutoTopologyKind::fuse_constant_curvature_family(&selector.candidates);
830    let mut ranked = Vec::with_capacity(fused.len());
831    let mut failed = Vec::new();
832    for candidate in &fused {
833        match fit_one(*candidate) {
834            Ok(evidence) => {
835                let tk_score = match tk_normalized_score(
836                    evidence.raw_reml,
837                    evidence.null_dim,
838                    evidence.null_space_logdet,
839                    evidence.effective_dim,
840                    evidence.n_obs,
841                    selector.score_scale,
842                ) {
843                    Ok(score) => score,
844                    Err(message) => {
845                        failed.push(TopologyAutoFailedCandidate {
846                            candidate: *candidate,
847                            topology_name: evidence.topology_name,
848                            stage: TopologyCandidateFailureStage::Evidence,
849                            message,
850                            evidence_at_failure: evidence
851                                .raw_reml
852                                .is_finite()
853                                .then_some(evidence.raw_reml),
854                        });
855                        continue;
856                    }
857                };
858                ranked.push(TopologyAutoRankedFit {
859                    topology_name: evidence.topology_name,
860                    tk_score,
861                    raw_reml: evidence.raw_reml,
862                    effective_dim: evidence.effective_dim,
863                    n_obs: evidence.n_obs,
864                    fit_handle: evidence.fit_handle,
865                });
866            }
867            Err(err) => failed.push(TopologyAutoFailedCandidate {
868                candidate: *candidate,
869                topology_name: candidate.display_name(),
870                stage: TopologyCandidateFailureStage::Fit,
871                message: err.to_string(),
872                evidence_at_failure: None,
873            }),
874        }
875    }
876    if ranked.is_empty() {
877        return Err(format!(
878            "TopologyAutoSelector found no fittable topology candidates{}",
879            if failed.is_empty() {
880                String::new()
881            } else {
882                format!(" ({})", failed_topology_summary(&failed))
883            }
884        ));
885    }
886    // Sign convention (issue #396, see `solver::evidence`): `tk_score` is a
887    // minimised TK / REML cost, so LOWER is better. Route through the shared
888    // priority selector so topology ranking, seed screening, and model
889    // comparison share one deterministic ordering contract (#782).
890    ranked = rank_priority_candidates(
891        ranked
892            .into_iter()
893            .enumerate()
894            .map(|(idx, row)| {
895                let score = row.tk_score;
896                PriorityCandidate::new(row, idx, score, 0)
897            })
898            .collect(),
899    )
900    .into_iter()
901    .map(|row| row.item)
902    .collect();
903    Ok(TopologyAutoSelectorResult {
904        ranked,
905        winner_index: 0,
906        failed,
907    })
908}
909
910/// Driver-level parallel sibling of [`select_topology_with_fit`] (#1017 Phase 0).
911///
912/// Topology candidates are INDEPENDENT fits — the sequential
913/// [`select_topology_with_fit`] loop walks them one at a time, leaving 5–20× on
914/// the table on a multi-core host. This variant fans the candidate fits across
915/// the bounded-nested-Rayon [`run_topology_race_parallel`] driver (the same one
916/// the closure-profile grid and the SAE-resident race already use), then ranks
917/// the survivors through the IDENTICAL deterministic priority selector — results
918/// come back in input order, so the winner is bit-identical to the sequential
919/// path. The only contract difference is `fit_one: Fn + Sync` (each candidate
920/// fit must be callable concurrently) instead of `FnMut`; callers whose fit
921/// closure captures shared mutable state keep the sequential entry.
922pub fn select_topology_with_fit_parallel<FitHandle, FitErr>(
923    selector: &TopologyAutoSelector,
924    fit_one: impl Fn(AutoTopologyKind) -> Result<TopologyAutoFitEvidence<FitHandle>, FitErr> + Sync,
925) -> Result<TopologyAutoSelectorResult<FitHandle>, String>
926where
927    FitHandle: Send,
928    FitErr: ToString + Send,
929{
930    // #944 stage 4: collapse fixed simply-connected constant-curvature forms
931    // into ONE estimated-κ ConstantCurvature candidate before the parallel race.
932    let candidates: Vec<AutoTopologyKind> =
933        AutoTopologyKind::fuse_constant_curvature_family(&selector.candidates);
934    let race = run_topology_race_parallel(candidates, |candidate| {
935        // Carry the candidate kind alongside the fit so per-candidate failures
936        // are reported with their topology name, exactly as the sequential path.
937        (candidate, fit_one(candidate))
938    })?;
939
940    let mut ranked = Vec::with_capacity(race.len());
941    let mut failed = Vec::new();
942    for entry in race {
943        let (candidate, fit_result) = entry.result;
944        match fit_result {
945            Ok(evidence) => {
946                let tk_score = match tk_normalized_score(
947                    evidence.raw_reml,
948                    evidence.null_dim,
949                    evidence.null_space_logdet,
950                    evidence.effective_dim,
951                    evidence.n_obs,
952                    selector.score_scale,
953                ) {
954                    Ok(score) => score,
955                    Err(message) => {
956                        failed.push(TopologyAutoFailedCandidate {
957                            candidate,
958                            topology_name: evidence.topology_name,
959                            stage: TopologyCandidateFailureStage::Evidence,
960                            message,
961                            evidence_at_failure: evidence
962                                .raw_reml
963                                .is_finite()
964                                .then_some(evidence.raw_reml),
965                        });
966                        continue;
967                    }
968                };
969                ranked.push(TopologyAutoRankedFit {
970                    topology_name: evidence.topology_name,
971                    tk_score,
972                    raw_reml: evidence.raw_reml,
973                    effective_dim: evidence.effective_dim,
974                    n_obs: evidence.n_obs,
975                    fit_handle: evidence.fit_handle,
976                });
977            }
978            Err(err) => failed.push(TopologyAutoFailedCandidate {
979                candidate,
980                topology_name: candidate.display_name(),
981                stage: TopologyCandidateFailureStage::Fit,
982                message: err.to_string(),
983                evidence_at_failure: None,
984            }),
985        }
986    }
987    if ranked.is_empty() {
988        return Err(format!(
989            "TopologyAutoSelector found no fittable topology candidates{}",
990            if failed.is_empty() {
991                String::new()
992            } else {
993                format!(" ({})", failed_topology_summary(&failed))
994            }
995        ));
996    }
997    // Same deterministic priority ranking as the sequential path (#782): lower
998    // tk_score is better; route through the shared selector so ordering is
999    // identical regardless of which entry produced the candidates.
1000    ranked = rank_priority_candidates(
1001        ranked
1002            .into_iter()
1003            .enumerate()
1004            .map(|(idx, row)| {
1005                let score = row.tk_score;
1006                PriorityCandidate::new(row, idx, score, 0)
1007            })
1008            .collect(),
1009    )
1010    .into_iter()
1011    .map(|row| row.item)
1012    .collect();
1013    Ok(TopologyAutoSelectorResult {
1014        ranked,
1015        winner_index: 0,
1016        failed,
1017    })
1018}
1019
1020pub fn tk_normalized_score(
1021    raw_reml: f64,
1022    null_dim: f64,
1023    null_space_logdet: Option<f64>,
1024    effective_dim: f64,
1025    n_obs: usize,
1026    score_scale: TopologyScoreScale,
1027) -> Result<f64, String> {
1028    let tk = raw_reml + topology_tk_normalizer(Some(null_dim), null_space_logdet)?;
1029    match score_scale {
1030        TopologyScoreScale::PerObservation => {
1031            if n_obs == 0 {
1032                Err("TopologyAutoSelector requires n_obs > 0".to_string())
1033            } else {
1034                Ok(tk / n_obs as f64)
1035            }
1036        }
1037        TopologyScoreScale::PerEffectiveDim => {
1038            if !(effective_dim.is_finite() && effective_dim > 0.0) {
1039                Err("TopologyAutoSelector requires finite positive effective_dim".to_string())
1040            } else {
1041                Ok(tk / effective_dim)
1042            }
1043        }
1044    }
1045}
1046
1047fn topology_tk_normalizer(
1048    null_dim: Option<f64>,
1049    null_space_logdet: Option<f64>,
1050) -> Result<f64, String> {
1051    let null_dim = null_dim.ok_or_else(|| {
1052        "topology evidence requires null-dimension metadata for TK normalization".to_string()
1053    })?;
1054    if !null_dim.is_finite() || null_dim < -1.0e-9 {
1055        return Err("topology evidence null dimension must be finite and non-negative".to_string());
1056    }
1057    if null_dim.max(0.0) == 0.0 {
1058        return Ok(0.0);
1059    }
1060    let logdet = null_space_logdet.ok_or_else(|| {
1061        "topology evidence TK normalizer requires null-space Hessian logdet".to_string()
1062    })?;
1063    if !logdet.is_finite() {
1064        return Err("topology evidence null-space Hessian logdet must be finite".to_string());
1065    }
1066    Ok(-0.5 * null_dim.max(0.0) * TK_LOG_2PI + 0.5 * logdet)
1067}
1068
1069fn topology_candidate_raw_score(
1070    evidence: &TopologyCandidateEvidence,
1071    score_kind: TopologySelectionScoreKind,
1072) -> Result<f64, String> {
1073    if !evidence.effective_dim.is_finite() {
1074        return Err(format!(
1075            "candidate {:?} has non-finite effective_dim {:?}",
1076            evidence.name, evidence.effective_dim
1077        ));
1078    }
1079    if evidence.n_obs == 0 {
1080        return Err(format!("candidate {:?} requires n_obs > 0", evidence.name));
1081    }
1082    if !evidence.raw_reml.is_finite() {
1083        return Err(format!(
1084            "candidate {:?} has non-finite REML evidence {:?}",
1085            evidence.name, evidence.raw_reml
1086        ));
1087    }
1088    match score_kind {
1089        TopologySelectionScoreKind::Reml => Ok(evidence.raw_reml),
1090        TopologySelectionScoreKind::Tk => Ok(evidence.raw_reml
1091            + topology_tk_normalizer(evidence.null_dim, evidence.null_space_logdet)?),
1092        TopologySelectionScoreKind::Laml => {
1093            let laml = evidence.laml.ok_or_else(|| {
1094                format!(
1095                    "candidate {:?} is missing LAML evidence metadata",
1096                    evidence.name
1097                )
1098            })?;
1099            if !laml.is_finite() {
1100                return Err(format!(
1101                    "candidate {:?} has non-finite LAML evidence {laml:?}",
1102                    evidence.name
1103                ));
1104            }
1105            Ok(laml + topology_tk_normalizer(evidence.null_dim, evidence.null_space_logdet)?)
1106        }
1107        TopologySelectionScoreKind::Bic => {
1108            let deviance = evidence.deviance.ok_or_else(|| {
1109                format!(
1110                    "candidate {:?} is missing deviance metadata required for BIC",
1111                    evidence.name
1112                )
1113            })?;
1114            bic_score(deviance, evidence.n_obs, evidence.basis_size)
1115        }
1116    }
1117}
1118
1119fn scale_topology_candidate_score(
1120    score: f64,
1121    scale: TopologySelectionScoreScale,
1122    evidence: &TopologyCandidateEvidence,
1123) -> Result<f64, String> {
1124    if !score.is_finite() {
1125        return Err(format!(
1126            "candidate {:?} has non-finite selected evidence {score:?}",
1127            evidence.name
1128        ));
1129    }
1130    match scale {
1131        TopologySelectionScoreScale::Raw => Ok(score),
1132        TopologySelectionScoreScale::PerObservation => {
1133            if evidence.n_obs == 0 {
1134                Err(format!(
1135                    "candidate {:?} requires n_obs > 0 for per-observation scoring",
1136                    evidence.name
1137                ))
1138            } else {
1139                Ok(score / evidence.n_obs as f64)
1140            }
1141        }
1142        TopologySelectionScoreScale::PerEffectiveDim => {
1143            if !(evidence.effective_dim.is_finite() && evidence.effective_dim > 0.0) {
1144                Err(format!(
1145                    "candidate {:?} requires finite positive effective_dim for per-effective-dimension scoring; got {:?}",
1146                    evidence.name, evidence.effective_dim
1147                ))
1148            } else {
1149                Ok(score / evidence.effective_dim)
1150            }
1151        }
1152    }
1153}
1154
1155fn topology_candidate_score(
1156    evidence: &TopologyCandidateEvidence,
1157    score_kind: TopologySelectionScoreKind,
1158    score_scale: TopologySelectionScoreScale,
1159) -> Result<f64, String> {
1160    let raw = topology_candidate_raw_score(evidence, score_kind)?;
1161    scale_topology_candidate_score(raw, score_scale, evidence)
1162}
1163
1164/// Resolve every declared candidate outcome through one evidence-validation,
1165/// failure-policy, deterministic-ranking, and winner-finalization entry.
1166///
1167/// Callers perform topology-specific assembly and invoke the model fitter, then
1168/// submit exactly one terminal outcome per declared name. A malformed lifecycle
1169/// (empty request or duplicate name) is rejected. Candidate-local evidence
1170/// errors become typed `Evidence` failures so one bad fit cannot erase the
1171/// other requested outcomes.
1172pub fn select_topology_candidate_lifecycle(
1173    outcomes: Vec<TopologyCandidateOutcome>,
1174    score_kind: TopologySelectionScoreKind,
1175    score_scale: TopologySelectionScoreScale,
1176) -> Result<TopologyCandidateSelectionResult, String> {
1177    if outcomes.is_empty() {
1178        return Err("topology selection requires at least one candidate outcome".to_string());
1179    }
1180    let mut names = std::collections::BTreeSet::new();
1181    for outcome in &outcomes {
1182        let name = outcome.name();
1183        if name.is_empty() {
1184            return Err("topology candidate names cannot be empty".to_string());
1185        }
1186        if !names.insert(name.to_string()) {
1187            return Err(format!("duplicate topology candidate {name:?}"));
1188        }
1189    }
1190
1191    let mut evidence_survivors = Vec::new();
1192    let mut ranked = Vec::new();
1193    let mut failed = Vec::new();
1194    for (candidate_index, outcome) in outcomes.into_iter().enumerate() {
1195        match outcome {
1196            TopologyCandidateOutcome::Failed(failure) => failed.push(failure),
1197            TopologyCandidateOutcome::Fitted(evidence) => {
1198                match topology_candidate_score(&evidence, score_kind, score_scale) {
1199                    Ok(score) => {
1200                        ranked.push(PriorityCandidate::new(
1201                            TopologyCandidateRanked {
1202                                name: evidence.name.clone(),
1203                                score,
1204                                raw_reml: evidence.raw_reml,
1205                                effective_dim: evidence.effective_dim,
1206                                basis_size: evidence.basis_size,
1207                                n_obs: evidence.n_obs,
1208                            },
1209                            candidate_index,
1210                            score,
1211                            0,
1212                        ));
1213                        evidence_survivors.push(evidence);
1214                    }
1215                    Err(message) => failed.push(TopologyCandidateFailure {
1216                        name: evidence.name,
1217                        stage: TopologyCandidateFailureStage::Evidence,
1218                        error_type: "gam_solve::topology_selector::EvidenceValidationError"
1219                            .to_string(),
1220                        message,
1221                        evidence_at_failure: evidence
1222                            .raw_reml
1223                            .is_finite()
1224                            .then_some(evidence.raw_reml),
1225                    }),
1226                }
1227            }
1228        }
1229    }
1230    let ranked: Vec<TopologyCandidateRanked> = rank_priority_candidates(ranked)
1231        .into_iter()
1232        .map(|candidate| candidate.item)
1233        .collect();
1234    let warnings = topology_score_disagreement_warnings(&evidence_survivors, score_scale);
1235    Ok(TopologyCandidateSelectionResult {
1236        winner_index: (!ranked.is_empty()).then_some(0),
1237        ranked,
1238        failed,
1239        warnings,
1240    })
1241}
1242
1243fn topology_score_disagreement_warnings(
1244    evidence: &[TopologyCandidateEvidence],
1245    score_scale: TopologySelectionScoreScale,
1246) -> Vec<String> {
1247    let mut orders = Vec::new();
1248    for kind in [
1249        TopologySelectionScoreKind::Reml,
1250        TopologySelectionScoreKind::Laml,
1251        TopologySelectionScoreKind::Bic,
1252    ] {
1253        let scored: Result<Vec<_>, _> = evidence
1254            .iter()
1255            .enumerate()
1256            .map(|(index, row)| {
1257                topology_candidate_score(row, kind, score_scale)
1258                    .map(|score| PriorityCandidate::new(row.name.clone(), index, score, 0))
1259            })
1260            .collect();
1261        let Ok(scored) = scored else {
1262            continue;
1263        };
1264        let order: Vec<String> = rank_priority_candidates(scored)
1265            .into_iter()
1266            .map(|row| row.item)
1267            .collect();
1268        orders.push((kind, order));
1269    }
1270    if orders.len() < 2 || orders.windows(2).all(|pair| pair[0].1 == pair[1].1) {
1271        return Vec::new();
1272    }
1273    let detail = orders
1274        .iter()
1275        .map(|(kind, order)| format!("{}: {}", kind.as_str(), order.join(", ")))
1276        .collect::<Vec<_>>()
1277        .join("; ");
1278    if score_scale == TopologySelectionScoreScale::Raw {
1279        vec![format!(
1280            "Topology score rankings differ across score kinds ({detail}). BIC and REML can disagree when candidate basis sizes differ wildly."
1281        )]
1282    } else {
1283        vec![format!(
1284            "Scaled topology score rankings still differ across score kinds under score_scale={:?} ({detail}). Treat BIC as a secondary diagnostic; the Tierney-Kadane Laplace normalizer handles the known cross-basis evidence scale issue.",
1285            score_scale.as_str()
1286        )]
1287    }
1288}
1289
1290pub fn bic_score(deviance: f64, n_obs: usize, basis_size: usize) -> Result<f64, String> {
1291    if n_obs <= 1 {
1292        return Err("BIC scoring requires at least two observations".to_string());
1293    }
1294    if !deviance.is_finite() {
1295        return Err("BIC scoring requires finite deviance".to_string());
1296    }
1297    Ok(deviance + (n_obs as f64).ln() * basis_size as f64)
1298}
1299
1300// ===========================================================================
1301// Discrete-mixture rung + cross-class adjudication (Object 3a / WP-C)
1302// ===========================================================================
1303
1304/// One fitted entry of the discrete-mixture rung: the mixture order `k`, the
1305/// fitted Gaussian mixture, and its rank-aware Laplace **negative** log evidence
1306/// computed through the SAME [`crate::evidence::laplace_evidence`]
1307/// entry point used by the smooth rungs. Lower negative-log-evidence is better.
1308#[derive(Debug, Clone)]
1309pub struct MixtureRungFit {
1310    pub k: usize,
1311    pub fit: crate::evidence::GaussianMixtureFit,
1312    /// Free-parameter count `P` — the quantity that enters the rank-aware
1313    /// normalizer as `dim(H) − rank(S) = P − 0`.
1314    pub num_parameters: usize,
1315    /// Rank-aware Laplace negative log evidence on the smooth-rung scale.
1316    pub negative_log_evidence: f64,
1317}
1318
1319/// Result of fitting the whole mixture ladder: every fitted order plus the index
1320/// of the in-class winner (lowest rank-aware Laplace negative-log-evidence).
1321#[derive(Debug, Clone)]
1322pub struct MixtureRungResult {
1323    pub fits: Vec<MixtureRungFit>,
1324    pub winner_index: usize,
1325}
1326
1327impl MixtureRungResult {
1328    pub fn winner(&self) -> &MixtureRungFit {
1329        &self.fits[self.winner_index]
1330    }
1331}
1332
1333/// Hard cap on the number of EXTRA orders the local refinement around the
1334/// coarse-ladder winner may probe. Refinement walks one neighbour at a time
1335/// and stops as soon as the running winner is bracketed (both immediate
1336/// neighbours fitted and worse), so this cap only binds on a pathological
1337/// evidence profile that keeps improving monotonically past the ladder — a
1338/// regime the rank-aware parameter pricing rules out for any real cluster
1339/// structure. It exists so the sweep stays a bounded pure function of the
1340/// data, never a runaway loop.
1341pub const MIXTURE_REFINEMENT_MAX_PROBES: usize = 16;
1342
1343/// Fit the discrete-mixture rung over a fixed `k`-ladder, then **refine
1344/// locally around the winner**, and rank in-class by rank-aware Laplace
1345/// evidence. Each order is priced by its own free-parameter count entering the
1346/// `−½ (dim(H) − rank(S)) log(2π)` normalizer. Deterministic: the seeding is
1347/// the basis k-means farthest-point init, EM is a pure map, and the refinement
1348/// order is a pure function of the fitted scores.
1349///
1350/// The coarse ladder ([`MIXTURE_K_LADDER`]) keeps the sweep cheap but cannot
1351/// *name* every order (it skips 4, 6, 8, …). Refinement closes that hole: after
1352/// the sweep, the immediate missing neighbours `k*−1`, `k*+1` of the running
1353/// winner are fitted, repeating until the winner is **bracketed** — both
1354/// neighbours present and scoring worse — so a planted `k = 4` truth is
1355/// recovered as exactly 4, not as the nearest ladder rung. On an in-ladder
1356/// winner the bracketing typically costs two extra EM fits and terminates at
1357/// the same order the coarse sweep found.
1358pub fn fit_mixture_rung(
1359    data: ArrayView2<'_, f64>,
1360    ladder: &[usize],
1361    config: GaussianMixtureConfig,
1362) -> Result<MixtureRungResult, String> {
1363    let n = data.nrows();
1364    let mut fits: Vec<MixtureRungFit> = Vec::new();
1365    let mut errors: Vec<String> = Vec::new();
1366    // Every order ever attempted (fitted OR failed): refinement must not
1367    // re-propose a failed order forever.
1368    let mut attempted: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
1369
1370    let try_order = |k: usize,
1371                     fits: &mut Vec<MixtureRungFit>,
1372                     errors: &mut Vec<String>,
1373                     attempted: &mut std::collections::BTreeSet<usize>| {
1374        if k == 0 || k > n || !attempted.insert(k) {
1375            return;
1376        }
1377        match fit_gaussian_mixture(data, k, config) {
1378            Ok(fit) => match fit.laplace_negative_log_evidence(data) {
1379                Ok(nle) => {
1380                    let num_parameters = fit.num_free_parameters();
1381                    fits.push(MixtureRungFit {
1382                        k,
1383                        fit,
1384                        num_parameters,
1385                        negative_log_evidence: nle,
1386                    });
1387                }
1388                Err(e) => errors.push(format!("mixture k={k} evidence: {e}")),
1389            },
1390            Err(e) => errors.push(format!("mixture k={k} fit: {e}")),
1391        }
1392    };
1393
1394    for &k in ladder {
1395        try_order(k, &mut fits, &mut errors, &mut attempted);
1396    }
1397    if fits.is_empty() {
1398        return Err(format!(
1399            "mixture rung produced no fittable orders{}",
1400            if errors.is_empty() {
1401                String::new()
1402            } else {
1403                format!(" ({})", errors.join("; "))
1404            }
1405        ));
1406    }
1407
1408    // Local refinement: bracket the running winner. The running winner uses
1409    // the same rule as the final ranking (lower negative-log-evidence, ties to
1410    // the smaller k), so refinement and ranking can never disagree about who
1411    // the winner is.
1412    let mut probes = 0usize;
1413    while probes < MIXTURE_REFINEMENT_MAX_PROBES {
1414        let best_k = fits
1415            .iter()
1416            .min_by(|a, b| {
1417                a.negative_log_evidence
1418                    .partial_cmp(&b.negative_log_evidence)
1419                    .unwrap_or(std::cmp::Ordering::Equal)
1420                    .then(a.k.cmp(&b.k))
1421            })
1422            .map(|f| f.k)
1423            .unwrap_or(1);
1424        let next = [best_k.saturating_sub(1), best_k + 1]
1425            .into_iter()
1426            .find(|&k| k >= 1 && k <= n && !attempted.contains(&k));
1427        let Some(k) = next else {
1428            break; // bracketed: both neighbours attempted (or out of range).
1429        };
1430        try_order(k, &mut fits, &mut errors, &mut attempted);
1431        probes += 1;
1432    }
1433    // In-class winner-take-all on the rank-aware evidence scale (lower wins).
1434    let ranked = rank_priority_candidates(
1435        fits.into_iter()
1436            .enumerate()
1437            .map(|(idx, row)| {
1438                let score = row.negative_log_evidence;
1439                let tie = row.k; // simpler (smaller k) wins ties
1440                PriorityCandidate::new(row, idx, score, tie)
1441            })
1442            .collect(),
1443    )
1444    .into_iter()
1445    .map(|row| row.item)
1446    .collect::<Vec<_>>();
1447    Ok(MixtureRungResult {
1448        fits: ranked,
1449        winner_index: 0,
1450    })
1451}
1452
1453// ===========================================================================
1454// Structured-union rung (#907)
1455// ===========================================================================
1456
1457/// One fitted entry of the structured-union rung: the composite structure, its
1458/// summed rank-aware Laplace **negative** log evidence (the SUM `Σ_c V_c` of its
1459/// components, each scored through the identical [`crate::evidence::laplace_evidence`]
1460/// entry point used by the smooth rungs and the mixture rung), and the TOTAL
1461/// free-parameter count across components (the complexity price). Lower
1462/// negative-log-evidence wins.
1463#[derive(Debug, Clone)]
1464pub struct UnionRungFit {
1465    pub structure: UnionStructure,
1466    pub fit: UnionStructureFit,
1467    /// `Σ_c P_c` — total free-parameter count across all components. This is the
1468    /// complexity quantity that the summed `+ ½ Σ_c P_c log(2π)` normalizer
1469    /// charges, so a union is strictly more expensive than either pure rung.
1470    pub total_parameters: usize,
1471    /// `Σ_c V_c` — summed rank-aware Laplace negative log evidence.
1472    pub negative_log_evidence: f64,
1473}
1474
1475/// Result of fitting the whole fixed union ladder: every fitted composite plus
1476/// the index of the in-class winner (lowest summed rank-aware Laplace
1477/// negative-log-evidence).
1478#[derive(Debug, Clone)]
1479pub struct UnionRungResult {
1480    pub fits: Vec<UnionRungFit>,
1481    pub winner_index: usize,
1482}
1483
1484impl UnionRungResult {
1485    pub fn winner(&self) -> &UnionRungFit {
1486        &self.fits[self.winner_index]
1487    }
1488}
1489
1490/// Fit the structured-union rung over the FIXED ladder
1491/// [`crate::evidence::UNION_STRUCTURE_LADDER`] and rank in-class by
1492/// summed rank-aware Laplace evidence. Each composite is hard-split into one
1493/// responsibility group per component (reusing the mixture rung's deterministic
1494/// seeding + EM), each component is REML/Laplace-fit on its group, and the
1495/// per-component evidences are SUMMED. Composites whose groups are too small to
1496/// identify their structure are skipped (they never enter the race rather than
1497/// scoring spuriously well). Deterministic: the split and the component fits are
1498/// pure functions of the data.
1499pub fn fit_union_rung(
1500    data: ArrayView2<'_, f64>,
1501    config: GaussianMixtureConfig,
1502) -> Result<UnionRungResult, String> {
1503    // `fit_union_ladder` already fits the fixed ladder and ranks best-first by
1504    // summed rank-aware evidence (cheaper composite wins ties). Re-wrap each
1505    // fit with its complexity price for the rung view.
1506    let ladder = fit_union_ladder(data, config)?;
1507    let fits: Vec<UnionRungFit> = ladder
1508        .into_iter()
1509        .map(|fit| UnionRungFit {
1510            structure: fit.structure,
1511            total_parameters: fit.total_parameters,
1512            negative_log_evidence: fit.negative_log_evidence,
1513            fit,
1514        })
1515        .collect();
1516    if fits.is_empty() {
1517        return Err("union rung produced no fittable composites".to_string());
1518    }
1519    Ok(UnionRungResult {
1520        fits,
1521        winner_index: 0,
1522    })
1523}
1524
1525/// Fit a SINGLE structured-union composite and return it as a rung fit. Thin
1526/// convenience over [`crate::evidence::fit_union_structure`] for callers
1527/// (and the race) that already chose a specific composite.
1528pub fn fit_union_candidate(
1529    data: ArrayView2<'_, f64>,
1530    structure: UnionStructure,
1531    config: GaussianMixtureConfig,
1532) -> Result<UnionRungFit, String> {
1533    let fit = fit_union_structure(data, structure, config)?;
1534    Ok(UnionRungFit {
1535        structure: fit.structure,
1536        total_parameters: fit.total_parameters,
1537        negative_log_evidence: fit.negative_log_evidence,
1538        fit,
1539    })
1540}
1541
1542/// A selection-time predictive-density provider: given the row indices to TRAIN
1543/// on and the row indices to EVALUATE on, it returns the per-eval-row held-out
1544/// log predictive density `log p(y_eval | train)`. This is the decoupled seam
1545/// that lets the cross-class race build a stacking table without persisting any
1546/// predictor: the closure refits on each fold's training rows.
1547///
1548/// The mixture provider is constructed here ([`mixture_density_provider`]); a
1549/// smooth-manifold provider is supplied by the caller (it owns the smooth
1550/// fitting machinery). Both refit per fold so the table is genuinely held-out.
1551pub type HeldOutDensityProvider<'a> =
1552    Box<dyn Fn(&[usize], &[usize]) -> Result<Vec<f64>, String> + 'a>;
1553
1554/// Build a mixture held-out-density provider for a fixed order `k`. It refits a
1555/// `k`-component mixture on the training rows and scores the eval rows.
1556pub fn mixture_density_provider<'a>(
1557    data: ArrayView2<'a, f64>,
1558    k: usize,
1559    config: GaussianMixtureConfig,
1560) -> HeldOutDensityProvider<'a> {
1561    let owned = data.to_owned();
1562    Box::new(
1563        move |train: &[usize], eval: &[usize]| -> Result<Vec<f64>, String> {
1564            let train_mat = gather_rows(owned.view(), train);
1565            let fit = fit_gaussian_mixture(train_mat.view(), k.min(train.len().max(1)), config)
1566                .map_err(|error| error.to_string())?;
1567            let eval_mat = gather_rows(owned.view(), eval);
1568            let dens = fit.per_point_log_density(eval_mat.view())?;
1569            Ok(dens.to_vec())
1570        },
1571    )
1572}
1573
1574/// Build a structured-union held-out-density provider for a fixed composite. It
1575/// refits the union's component densities on the training rows and scores the
1576/// eval rows under the soft mixture `log Σ_c π_c p_c(y)` (the union analogue of
1577/// [`mixture_density_provider`]). Refits per fold, so the stacking table is
1578/// genuinely held out.
1579pub fn union_density_provider<'a>(
1580    data: ArrayView2<'a, f64>,
1581    structure: UnionStructure,
1582    config: GaussianMixtureConfig,
1583) -> HeldOutDensityProvider<'a> {
1584    let owned = data.to_owned();
1585    Box::new(
1586        move |train: &[usize], eval: &[usize]| -> Result<Vec<f64>, String> {
1587            let train_mat = gather_rows(owned.view(), train);
1588            let eval_mat = gather_rows(owned.view(), eval);
1589            let dens =
1590                union_per_point_log_density(train_mat.view(), eval_mat.view(), structure, config)?;
1591            Ok(dens.to_vec())
1592        },
1593    )
1594}
1595
1596fn gather_rows(data: ArrayView2<'_, f64>, idx: &[usize]) -> Array2<f64> {
1597    let d = data.ncols();
1598    let mut out = Array2::<f64>::zeros((idx.len(), d));
1599    for (r, &i) in idx.iter().enumerate() {
1600        for c in 0..d {
1601            out[[r, c]] = data[[i, c]];
1602        }
1603    }
1604    out
1605}
1606
1607/// Deterministic contiguous `folds`-way CV partition of `0..n` (no clock
1608/// randomness). Returns, for each fold, `(train_indices, eval_indices)`.
1609///
1610/// Uses the default stacking seed ([`STACKING_CV_SEED`]); see
1611/// [`deterministic_cv_folds_seeded`] for the seed-reproducible variant (#1386).
1612pub fn deterministic_cv_folds(n: usize, folds: usize) -> Vec<(Vec<usize>, Vec<usize>)> {
1613    deterministic_cv_folds_seeded(n, folds, STACKING_CV_SEED)
1614}
1615
1616/// SplitMix64 finalizer — a full-avalanche integer hash. Pure and deterministic:
1617/// it never touches the clock or any RNG state, so the folding it drives is
1618/// reproducible for a given `(seed, index)` and decorrelated across seeds.
1619#[inline]
1620fn splitmix64(mut x: u64) -> u64 {
1621    x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
1622    let mut z = x;
1623    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1624    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1625    z ^ (z >> 31)
1626}
1627
1628/// Deterministic, seed-reproducible `folds`-way CV partition of `0..n` (no clock
1629/// randomness). The eval fold of sample `i` is `hash(seed, i) % folds`, so:
1630///   - the same `seed` always reproduces the identical folding (deterministic),
1631///   - different seeds give different (still-deterministic) foldings.
1632///
1633/// This is the seam that makes the `adjudicate_atom_shape` `seed=` kwarg
1634/// functional (#1386): it is mixed into the held-out CV partition rather than
1635/// being a silent no-op. Returns, for each fold, `(train_indices, eval_indices)`,
1636/// dropping any fold whose train or eval set is empty.
1637pub fn deterministic_cv_folds_seeded(
1638    n: usize,
1639    folds: usize,
1640    seed: u64,
1641) -> Vec<(Vec<usize>, Vec<usize>)> {
1642    let folds = folds.clamp(2, n.max(2));
1643    // Precompute the seed-mixed fold of every sample once.
1644    let assign: Vec<usize> = (0..n)
1645        .map(|i| {
1646            // Mix the seed with the index through a full-avalanche hash so the
1647            // partition genuinely depends on both. The modulo keeps fold sizes
1648            // balanced in expectation while remaining deterministic.
1649            (splitmix64(seed ^ splitmix64(i as u64)) % folds as u64) as usize
1650        })
1651        .collect();
1652    let mut out = Vec::with_capacity(folds);
1653    for f in 0..folds {
1654        let mut train = Vec::new();
1655        let mut eval = Vec::new();
1656        for (i, &fold) in assign.iter().enumerate() {
1657            if fold == f {
1658                eval.push(i);
1659            } else {
1660                train.push(i);
1661            }
1662        }
1663        if !eval.is_empty() && !train.is_empty() {
1664            out.push((train, eval));
1665        }
1666    }
1667    out
1668}
1669
1670/// Build the selection-time held-out predictive log-density table
1671/// `log_density[i, c] = log p_c(y_i | train_fold(i))`, with one column per
1672/// candidate provider. Each row `i` is scored by the candidate refit on the CV
1673/// fold whose eval set contains `i`, so every entry is genuinely held out. This
1674/// is exactly the table that feeds
1675/// [`crate::evidence::solve_stacking_weights`].
1676pub fn build_cv_log_density_table(
1677    n: usize,
1678    folds: usize,
1679    seed: u64,
1680    providers: &[HeldOutDensityProvider<'_>],
1681) -> Result<Array2<f64>, String> {
1682    if providers.is_empty() {
1683        return Err("stacking table requires at least one candidate provider".to_string());
1684    }
1685    let partition = deterministic_cv_folds_seeded(n, folds, seed);
1686    if partition.is_empty() {
1687        return Err("stacking CV partition is empty (n too small for folds)".to_string());
1688    }
1689    let mut table = Array2::<f64>::from_elem((n, providers.len()), f64::NEG_INFINITY);
1690    for (train, eval) in &partition {
1691        for (col, provider) in providers.iter().enumerate() {
1692            let dens = provider(train, eval)?;
1693            if dens.len() != eval.len() {
1694                return Err(format!(
1695                    "provider {col} returned {} densities for {} eval rows",
1696                    dens.len(),
1697                    eval.len()
1698                ));
1699            }
1700            for (slot, &row) in eval.iter().enumerate() {
1701                table[[row, col]] = dens[slot];
1702            }
1703        }
1704    }
1705    Ok(table)
1706}
1707
1708/// Adjudicated outcome of a cross-class race. When the race mixes a smooth
1709/// manifold candidate with the discrete-mixture rung, `headline` is the stacking
1710/// verdict (held-out predictive log-density), and the rank-aware Laplace
1711/// evidence is retained per-candidate as corroboration. Same-class races report
1712/// `Headline::Evidence` (winner-take-all on rank-aware evidence).
1713#[derive(Debug, Clone)]
1714pub struct CrossClassRaceVerdict {
1715    /// Candidate display names, column-aligned with the stacking table / weights.
1716    pub candidate_names: Vec<String>,
1717    /// Whether the race actually mixed model classes (smooth vs discrete).
1718    pub is_cross_class: bool,
1719    /// Rank-aware Laplace negative-log-evidence per candidate (corroboration;
1720    /// lower is better).
1721    pub negative_log_evidence: Vec<f64>,
1722    /// Stacking weights over the candidates (present iff `is_cross_class`).
1723    pub stacking: Option<StackingWeights>,
1724    /// Index of the headline winner. For cross-class races this is the max
1725    /// stacking-weight candidate; for same-class it is the min-evidence one.
1726    pub winner_index: usize,
1727    /// Which statistic drove the headline.
1728    pub headline: Headline,
1729    /// `Some(_)` when the same-class evidence winner's lead over the runner-up
1730    /// did NOT clear the decision margin required by approximate (enclosure /
1731    /// coreset) evidence — the verdict is provisional and the caller must
1732    /// refine or escalate to the exact path. `None` when the margin held (or no
1733    /// approximate evidence was involved, or the race adjudicated by stacking).
1734    pub insufficient_margin: Option<InsufficientRaceMargin>,
1735}
1736
1737/// Which statistic adjudicated the headline ranking.
1738#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1739pub enum Headline {
1740    /// Rank-aware Laplace evidence (same-class race, winner-take-all).
1741    Evidence,
1742    /// Held-out predictive log-density / stacking weights (cross-class race).
1743    Stacking,
1744}
1745
1746/// How a candidate's `negative_log_evidence` was certified — the source of the
1747/// decision-margin the same-class race must respect before it can transfer a
1748/// verdict from approximate evidence to the full-corpus verdict.
1749///
1750/// * [`Exact`] — the evidence is a genuine point value (dense logdet, full
1751///   corpus); no margin floor.
1752/// * [`Enclosure`] — the log-determinant half came from a
1753///   [`block_preconditioned_logdet_enclosure`]; the race lead Δ must exceed the
1754///   enclosure gap (#1011 contract) before the winner is trustworthy.
1755/// * [`Coreset`] — the evidence was raced on a certified row coreset; the lead
1756///   must exceed the certificate's [`CoresetCertificate::race_transfer_margin`]
1757///   (#1012 contract — the SAME margin seam as the enclosure).
1758///
1759/// [`Exact`]: EvidenceCertification::Exact
1760/// [`Enclosure`]: EvidenceCertification::Enclosure
1761/// [`Coreset`]: EvidenceCertification::Coreset
1762/// [`block_preconditioned_logdet_enclosure`]: crate::logdet_bounds::block_preconditioned_logdet_enclosure
1763#[derive(Clone, Copy, Debug, PartialEq)]
1764pub enum EvidenceCertification {
1765    Exact,
1766    Enclosure { gap: f64 },
1767    Coreset { certificate: CoresetCertificate },
1768}
1769
1770impl EvidenceCertification {
1771    /// The smallest race lead Δ for which this candidate's evidence is
1772    /// trustworthy. Exact evidence transfers at any positive lead; an enclosure
1773    /// needs its gap; a coreset needs its certified transfer margin.
1774    pub fn required_margin(&self) -> f64 {
1775        match self {
1776            EvidenceCertification::Exact => 0.0,
1777            EvidenceCertification::Enclosure { gap } => *gap,
1778            EvidenceCertification::Coreset { certificate } => certificate.race_transfer_margin(),
1779        }
1780    }
1781
1782    /// The unified `Verdict` (`gam_inference::certificates::Verdict`) for a race
1783    /// won by lead `race_lead` over the runner-up, on the shared certificate
1784    /// ladder (task #16). The race transfers its approximate-evidence verdict to
1785    /// the full corpus only when the lead strictly clears [`Self::required_margin`]
1786    /// — otherwise the consumer must refine or fall back to the exact dense path,
1787    /// so the verdict is `Insufficient`, never a silent pass. Exact evidence with
1788    /// any positive lead is `Certified`. A non-finite or non-positive lead leaves
1789    /// the race undecided (`Insufficient`); the per-family margin verdicts
1790    /// (`gam_inference::certificate_impls::coreset_race_verdict`,
1791    /// `gam_inference::certificate_impls::enclosure_margin_verdict`) supply
1792    /// the underlying mapping so there is one source of truth.
1793    pub fn race_verdict(&self, race_lead: f64) -> gam_problem::topology_certificates::Verdict {
1794        use gam_problem::topology_certificates::Verdict;
1795        if !(race_lead.is_finite() && race_lead > 0.0) {
1796            return Verdict::Insufficient;
1797        }
1798        match self {
1799            EvidenceCertification::Exact => Verdict::Certified,
1800            EvidenceCertification::Enclosure { gap } => {
1801                let enclosure = crate::logdet_bounds::LogdetEnclosure {
1802                    block_diag_logdet: 0.0,
1803                    lower: 0.0,
1804                    upper: *gap,
1805                    rho: 0.0,
1806                    p2: 0.0,
1807                    p3: None,
1808                };
1809                crate::inference::certificate_impls::enclosure_margin_verdict(&enclosure, race_lead)
1810            }
1811            EvidenceCertification::Coreset { certificate } => {
1812                crate::inference::certificate_impls::coreset_race_verdict(
1813                    certificate.certify_margin(race_lead),
1814                )
1815            }
1816        }
1817    }
1818}
1819
1820/// One candidate entering the cross-class adjudicator: its kind, its rank-aware
1821/// Laplace negative-log-evidence (already computed on the common scale), how
1822/// that evidence was certified (for the margin contract), and a selection-time
1823/// held-out-density provider that refits per CV fold.
1824pub struct CrossClassCandidate<'a> {
1825    pub kind: AutoTopologyKind,
1826    pub negative_log_evidence: f64,
1827    /// Certification of `negative_log_evidence`. Defaults conceptually to
1828    /// [`EvidenceCertification::Exact`]; construct with [`Self::exact`] for the
1829    /// classic point-value path.
1830    pub certification: EvidenceCertification,
1831    pub density_provider: HeldOutDensityProvider<'a>,
1832}
1833
1834impl<'a> CrossClassCandidate<'a> {
1835    /// Construct a candidate whose evidence is an exact point value (the
1836    /// classic full-corpus dense-logdet path — no margin floor).
1837    pub fn exact(
1838        kind: AutoTopologyKind,
1839        negative_log_evidence: f64,
1840        density_provider: HeldOutDensityProvider<'a>,
1841    ) -> Self {
1842        Self {
1843            kind,
1844            negative_log_evidence,
1845            certification: EvidenceCertification::Exact,
1846            density_provider,
1847        }
1848    }
1849}
1850
1851/// Why a same-class race could not transfer its approximate-evidence verdict to
1852/// the full corpus: the winner's lead Δ over the runner-up did not clear the
1853/// required decision margin (the enclosure gap or the coreset transfer margin).
1854/// The consumer must refine (more moments / pair absorption / a larger coreset)
1855/// or re-run the top contenders on the exact dense path.
1856#[derive(Clone, Copy, Debug, PartialEq)]
1857pub struct InsufficientRaceMargin {
1858    /// Index of the provisional (below-margin) winner.
1859    pub provisional_winner: usize,
1860    /// Index of the runner-up whose evidence is within margin of the winner.
1861    pub contender: usize,
1862    /// The realized lead Δ = nle[contender] − nle[winner] (≥ 0).
1863    pub lead: f64,
1864    /// The margin the lead had to exceed (max of the two candidates'
1865    /// required margins).
1866    pub required_margin: f64,
1867}
1868
1869/// Adjudicate a race that may mix smooth-manifold and discrete candidates
1870/// (the discrete-mixture rung and/or a structured union, #907). Cross-class
1871/// mixing is auto-detected from the candidate kinds (a race is cross-class iff
1872/// it contains BOTH at least one smooth/Euclidean candidate AND at least one
1873/// discrete candidate — [`AutoTopologyKind::Mixture`] or
1874/// [`AutoTopologyKind::Union`]). When cross-class,
1875/// the headline switches to stacking over a selection-time CV held-out
1876/// log-density table; otherwise the headline is the rank-aware evidence winner.
1877/// `seed` is mixed into the deterministic CV fold assignment (#1386): the same
1878/// seed reproduces the identical held-out folding, different seeds give
1879/// different — but still deterministic — foldings. It only affects the
1880/// cross-class (stacking) path; same-class races are winner-take-all on evidence
1881/// and ignore it. Pass [`STACKING_CV_SEED`] for the default folding.
1882pub fn adjudicate_cross_class_race(
1883    n: usize,
1884    candidates: Vec<CrossClassCandidate<'_>>,
1885    folds: usize,
1886    seed: u64,
1887    stacking_config: StackingConfig,
1888) -> Result<CrossClassRaceVerdict, String> {
1889    if candidates.is_empty() {
1890        return Err("cross-class race requires at least one candidate".to_string());
1891    }
1892    let names: Vec<String> = candidates.iter().map(|c| c.kind.display_name()).collect();
1893    let evidence: Vec<f64> = candidates.iter().map(|c| c.negative_log_evidence).collect();
1894
1895    // Cross-class iff the race mixes at least one discrete (non-smooth) density
1896    // class — the mixture rung OR a structured union (#907) — with at least one
1897    // smooth/Euclidean manifold candidate. A union competing against a smooth
1898    // ring must therefore adjudicate by held-out predictive stacking, exactly
1899    // like the mixture rung does.
1900    let has_discrete = candidates.iter().any(|c| c.kind.is_discrete_class());
1901    let has_smooth = candidates.iter().any(|c| !c.kind.is_discrete_class());
1902    let is_cross_class = has_discrete && has_smooth;
1903
1904    if !is_cross_class {
1905        // Same-class: winner-take-all on rank-aware evidence (lower wins).
1906        let certifications: Vec<EvidenceCertification> =
1907            candidates.iter().map(|c| c.certification).collect();
1908        let mut winner_index = 0usize;
1909        let mut best = f64::INFINITY;
1910        for (idx, &nle) in evidence.iter().enumerate() {
1911            if nle.is_finite() && nle < best {
1912                best = nle;
1913                winner_index = idx;
1914            }
1915        }
1916        // Decision-margin contract (#1011 enclosure / #1012 coreset, one seam):
1917        // the winner's lead over the closest contender must clear the larger of
1918        // the two candidates' required margins (an exact candidate floors at 0,
1919        // an enclosure at its gap, a coreset at its transfer margin). When the
1920        // lead is inside that margin the verdict is provisional — the
1921        // approximate evidence does not actually separate them — so we surface
1922        // an explicit escalation rather than silently anointing a winner the
1923        // bounds cannot distinguish.
1924        let mut insufficient_margin: Option<InsufficientRaceMargin> = None;
1925        for (idx, &nle) in evidence.iter().enumerate() {
1926            if idx == winner_index || !nle.is_finite() {
1927                continue;
1928            }
1929            let lead = nle - best;
1930            let required = certifications[winner_index]
1931                .required_margin()
1932                .max(certifications[idx].required_margin());
1933            if required > 0.0 && lead <= required {
1934                let tighter = insufficient_margin.map(|m| lead < m.lead).unwrap_or(true);
1935                if tighter {
1936                    insufficient_margin = Some(InsufficientRaceMargin {
1937                        provisional_winner: winner_index,
1938                        contender: idx,
1939                        lead,
1940                        required_margin: required,
1941                    });
1942                }
1943            }
1944        }
1945        return Ok(CrossClassRaceVerdict {
1946            candidate_names: names,
1947            is_cross_class: false,
1948            negative_log_evidence: evidence,
1949            stacking: None,
1950            winner_index,
1951            headline: Headline::Evidence,
1952            insufficient_margin,
1953        });
1954    }
1955
1956    // Cross-class: build the selection-time held-out density table and stack.
1957    let providers: Vec<HeldOutDensityProvider<'_>> =
1958        candidates.into_iter().map(|c| c.density_provider).collect();
1959    let table = build_cv_log_density_table(n, folds, seed, &providers)?;
1960    let stacking =
1961        solve_stacking_weights(table.view(), stacking_config).map_err(|error| error.to_string())?;
1962    // Headline winner = max stacking weight (most predictive mass).
1963    let mut winner_index = 0usize;
1964    let mut best_w = f64::NEG_INFINITY;
1965    for (idx, &w) in stacking.weights.iter().enumerate() {
1966        if w > best_w {
1967            best_w = w;
1968            winner_index = idx;
1969        }
1970    }
1971    Ok(CrossClassRaceVerdict {
1972        candidate_names: names,
1973        is_cross_class: true,
1974        negative_log_evidence: evidence,
1975        stacking: Some(stacking),
1976        winner_index,
1977        headline: Headline::Stacking,
1978        // Cross-class headlines adjudicate by held-out predictive stacking on
1979        // the full corpus, not by the approximate-evidence scalar, so the
1980        // enclosure/coreset margin contract does not gate the verdict here.
1981        insufficient_margin: None,
1982    })
1983}
1984
1985// ===========================================================================
1986// Closure-parameter smooth class (#1015): circle ⇄ interval as one estimand
1987// ===========================================================================
1988
1989/// One profiled point of the closure family: the closure value `γ`, the
1990/// profiled (θ and λ_smooth optimised) negative-log evidence and its exact
1991/// first two profile derivatives at that γ, and the fit handle the caller wants
1992/// carried for the winner.
1993#[derive(Debug, Clone)]
1994pub struct ClosureProfilePoint<FitHandle> {
1995    pub gamma: f64,
1996    pub tk_score: f64,
1997    pub score_gradient: f64,
1998    pub score_curvature: f64,
1999    /// Explicit structural diagnostic from the converged inner fit. Merely
2000    /// attaining `gamma = 0` does not by itself prove support collapse.
2001    pub support_collapsed: bool,
2002    pub fit_handle: FitHandle,
2003}
2004
2005/// Converged fixed-γ fit returned by the closure profile oracle.
2006#[derive(Debug, Clone)]
2007pub struct ClosureProfileFit<FitHandle> {
2008    pub tk_score: f64,
2009    pub score_gradient: f64,
2010    pub score_curvature: f64,
2011    pub support_collapsed: bool,
2012    pub fit_handle: FitHandle,
2013}
2014
2015/// KKT location of the continuously selected closure optimum.
2016#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2017pub enum ClosureOptimumKind {
2018    Interior,
2019    IntervalBoundary,
2020    CircleBoundary,
2021}
2022
2023/// First-order certificate for the selected closure parameter.
2024#[derive(Debug, Clone, Copy)]
2025pub struct ClosureStationarityCertificate {
2026    pub kind: ClosureOptimumKind,
2027    /// Absolute gradient in the interior, or the violated outward component
2028    /// of the one-sided KKT condition at a boundary.
2029    pub projected_gradient: f64,
2030    pub tolerance: f64,
2031    /// Certified bracket for the stationary abscissa (a point at an exact
2032    /// endpoint optimum).
2033    pub bracket: gam_math::score_opt::ClosedInterval,
2034    /// Outward score-derivative and curvature ranges on `bracket`.
2035    pub derivative_enclosure: gam_math::score_opt::DerivativeEnclosure,
2036}
2037
2038/// The result of profiling the closure parameter inside the smooth class.
2039///
2040/// The headline is `gamma_hat` with its profile-likelihood CI (a regular Wilks
2041/// interval when the optimum is interior; one-sided when it touches a boundary).
2042/// `representative` is the γ̂ fit, scored on the SAME TK evidence surface the
2043/// discrete race uses, so this single smooth-class entry competes directly with
2044/// the non-homotopic candidates (mixture/union) the discrete race retains: the
2045/// closure family absorbs the within-smooth-class circle/line grid, it does not
2046/// replace the cross-class race.
2047#[derive(Debug, Clone)]
2048pub struct ClosureSelection<FitHandle> {
2049    pub ci: gam_geometry::ClosureProfileCi,
2050    pub representative: ClosureProfilePoint<FitHandle>,
2051    pub stationarity: ClosureStationarityCertificate,
2052    /// True when the profile pinned γ at the singular cluster boundary — the
2053    /// "not a regular smooth 1-D topology" signal that must be routed to the
2054    /// #907 mixture/union rung rather than reported as a regular closure.
2055    pub route_to_mixture_rung: bool,
2056}
2057
2058fn closure_profile_ci_side<EvaluateScore>(
2059    evaluate_score: &EvaluateScore,
2060    gamma_hat: f64,
2061    target: f64,
2062    bound: f64,
2063    stationary_abscissae: &[f64],
2064    resolution: f64,
2065) -> Result<(f64, bool), String>
2066where
2067    EvaluateScore: Fn(f64) -> Result<f64, String>,
2068{
2069    let toward_lower = bound < gamma_hat;
2070    let mut probes: Vec<f64> = stationary_abscissae
2071        .iter()
2072        .copied()
2073        .filter(|&gamma| {
2074            if toward_lower {
2075                gamma < gamma_hat && gamma > bound
2076            } else {
2077                gamma > gamma_hat && gamma < bound
2078            }
2079        })
2080        .collect();
2081    probes.sort_by(f64::total_cmp);
2082    if toward_lower {
2083        probes.reverse();
2084    }
2085    probes.push(bound);
2086
2087    let mut inside = gamma_hat;
2088    for probe in probes {
2089        let value = evaluate_score(probe)?;
2090        if !value.is_finite() {
2091            return Err(format!(
2092                "closure profile CI produced non-finite evidence at γ={probe}"
2093            ));
2094        }
2095        let comparison_roundoff = f64::EPSILON * (1.0 + value.abs() + target.abs());
2096        if (value - target).abs() <= comparison_roundoff {
2097            return Ok((probe, probe == bound));
2098        }
2099        if value > target {
2100            // `probe` and `inside` are adjacent critical points of the
2101            // certified profile partition, so the score is monotone between
2102            // them. Bisection therefore isolates the nearest Wilks crossing
2103            // without a geometric probe schedule that could jump over an
2104            // exit/re-entry pair.
2105            let mut outside = probe;
2106            while (outside - inside).abs() > resolution {
2107                let midpoint = outside + 0.5 * (inside - outside);
2108                if midpoint == outside || midpoint == inside {
2109                    break;
2110                }
2111                let midpoint_value = evaluate_score(midpoint)?;
2112                if !midpoint_value.is_finite() {
2113                    return Err(format!(
2114                        "closure profile CI produced non-finite evidence at γ={midpoint}"
2115                    ));
2116                }
2117                if midpoint_value <= target {
2118                    inside = midpoint;
2119                } else {
2120                    outside = midpoint;
2121                }
2122            }
2123            return Ok((outside + 0.5 * (inside - outside), false));
2124        }
2125        inside = probe;
2126    }
2127    Ok((bound, true))
2128}
2129
2130/// Profile the closure parameter `γ`, returning the continuously refined
2131/// minimiser, its profile-likelihood CI, and the representative fit.
2132///
2133/// `fit_at_gamma` performs the converged inner fit at a fixed closure value and
2134/// returns a [`ClosureProfileFit`] containing the profiled score jet, an
2135/// explicit support-collapse diagnostic, and the fit handle. `tk_score` is the profiled
2136/// negative-log evidence on the same scale [`tk_normalized_score`] produces (so
2137/// γ and λ_smooth must both be optimised inside the closure, per the issue's
2138/// confounding contract). Lower score is better. The analytic score oracle is
2139/// searched continuously on `[0, 1]`; `enclose_derivatives` supplies outward
2140/// ranges containing the score gradient and curvature on every requested
2141/// interval. Exact endpoints compete with every certified isolated interior
2142/// stationary point. The same continuous oracle supplies the profile-Wilks
2143/// crossings, so neither the winner nor its interval is selected or
2144/// interpolated from a lattice. An interval whose stationary structure cannot
2145/// be certified is an error, never a best sampled point.
2146pub fn profile_closure_within_smooth_class<FitHandle, FitAtGamma, EncloseDerivatives>(
2147    fit_at_gamma: FitAtGamma,
2148    enclose_derivatives: EncloseDerivatives,
2149    level: f64,
2150) -> Result<ClosureSelection<FitHandle>, String>
2151where
2152    FitAtGamma: Fn(f64) -> Result<ClosureProfileFit<FitHandle>, String>,
2153    EncloseDerivatives: Fn(f64, f64) -> Result<gam_math::score_opt::DerivativeEnclosure, String>,
2154{
2155    let gamma_tolerance = f64::EPSILON.sqrt();
2156    let evaluate = |gamma: f64| -> Result<ClosureProfilePoint<FitHandle>, String> {
2157        let fit = fit_at_gamma(gamma)?;
2158        let ClosureProfileFit {
2159            tk_score,
2160            score_gradient,
2161            score_curvature,
2162            support_collapsed,
2163            fit_handle,
2164        } = fit;
2165        if !(tk_score.is_finite() && score_gradient.is_finite() && score_curvature.is_finite()) {
2166            return Err(format!(
2167                "closure profile produced a non-finite score jet at γ={gamma}"
2168            ));
2169        }
2170        Ok(ClosureProfilePoint {
2171            gamma,
2172            tk_score,
2173            score_gradient,
2174            score_curvature,
2175            support_collapsed,
2176            fit_handle,
2177        })
2178    };
2179
2180    let mut score_oracle = |gamma: f64| {
2181        let point = evaluate(gamma)?;
2182        Ok::<_, String>(gam_math::score_opt::ScoreJet {
2183            value: -point.tk_score,
2184            derivative: -point.score_gradient,
2185            curvature: -point.score_curvature,
2186        })
2187    };
2188    let mut score_enclosure = |lo: f64, hi: f64| {
2189        let tk = enclose_derivatives(lo, hi)?;
2190        Ok::<_, String>(gam_math::score_opt::DerivativeEnclosure {
2191            derivative: gam_math::score_opt::ClosedInterval::outward(
2192                -tk.derivative.hi,
2193                -tk.derivative.lo,
2194            ),
2195            curvature: gam_math::score_opt::ClosedInterval::outward(
2196                -tk.curvature.hi,
2197                -tk.curvature.lo,
2198            ),
2199        })
2200    };
2201    let search = gam_math::score_opt::maximize_score_1d(
2202        0.0,
2203        1.0,
2204        gamma_tolerance,
2205        &mut score_oracle,
2206        &mut score_enclosure,
2207    )
2208    .map_err(|error| format!("closure profile: {error}"))?;
2209    let representative = evaluate(search.optimum.x)?;
2210    let gradient_scale = 1.0
2211        + search.lower_boundary.derivative.abs()
2212        + search.upper_boundary.derivative.abs()
2213        + representative.score_curvature.abs();
2214    let stationarity_tolerance = f64::EPSILON.sqrt() * gradient_scale;
2215    let (kind, projected_gradient) = match search.location {
2216        gam_math::score_opt::ScoreOptimumLocation::LowerBoundary => (
2217            ClosureOptimumKind::IntervalBoundary,
2218            (-representative.score_gradient).max(0.0),
2219        ),
2220        gam_math::score_opt::ScoreOptimumLocation::UpperBoundary => (
2221            ClosureOptimumKind::CircleBoundary,
2222            representative.score_gradient.max(0.0),
2223        ),
2224        gam_math::score_opt::ScoreOptimumLocation::Stationary(_) => (
2225            ClosureOptimumKind::Interior,
2226            representative.score_gradient.abs(),
2227        ),
2228    };
2229    if projected_gradient > stationarity_tolerance
2230        || (kind == ClosureOptimumKind::Interior && representative.score_curvature <= 0.0)
2231    {
2232        return Err(format!(
2233            "closure profile did not certify its continuous optimum: γ={}, projected \
2234             gradient={}, curvature={}, tolerance={}",
2235            representative.gamma,
2236            projected_gradient,
2237            representative.score_curvature,
2238            stationarity_tolerance
2239        ));
2240    }
2241    let bracket = match search.location {
2242        gam_math::score_opt::ScoreOptimumLocation::LowerBoundary
2243        | gam_math::score_opt::ScoreOptimumLocation::UpperBoundary => {
2244            gam_math::score_opt::ClosedInterval::point(representative.gamma)
2245        }
2246        gam_math::score_opt::ScoreOptimumLocation::Stationary(index) => {
2247            search
2248                .stationary_points
2249                .get(index)
2250                .ok_or_else(|| {
2251                    "closure profile optimizer returned an invalid stationary index".to_string()
2252                })?
2253                .bracket
2254        }
2255    };
2256    let derivative_enclosure = enclose_derivatives(bracket.lo, bracket.hi)?;
2257    let stationarity = ClosureStationarityCertificate {
2258        kind,
2259        projected_gradient,
2260        tolerance: stationarity_tolerance,
2261        bracket,
2262        derivative_enclosure,
2263    };
2264
2265    if !(level.is_finite() && level > 0.0 && level < 1.0) {
2266        return Err("closure profile CI level must lie in (0, 1)".to_string());
2267    }
2268    let chi_squared = ChiSquared::new(1.0)
2269        .map_err(|error| format!("closure profile CI distribution: {error}"))?;
2270    let target = representative.tk_score + 0.5 * chi_squared.inverse_cdf(level);
2271    let stationary_abscissae: Vec<f64> = search
2272        .stationary_points
2273        .iter()
2274        .map(|stationary| stationary.sample.x)
2275        .collect();
2276    let evaluate_score = |gamma| evaluate(gamma).map(|point| point.tk_score);
2277    let (ci_lo, lo_at_bound) = if representative.gamma == 0.0 {
2278        (0.0, true)
2279    } else {
2280        closure_profile_ci_side(
2281            &evaluate_score,
2282            representative.gamma,
2283            target,
2284            0.0,
2285            &stationary_abscissae,
2286            gamma_tolerance,
2287        )?
2288    };
2289    let (ci_hi, hi_at_bound) = if representative.gamma == 1.0 {
2290        (1.0, true)
2291    } else {
2292        closure_profile_ci_side(
2293            &evaluate_score,
2294            representative.gamma,
2295            target,
2296            1.0,
2297            &stationary_abscissae,
2298            gamma_tolerance,
2299        )?
2300    };
2301    let singular_boundary = representative.support_collapsed;
2302    let ci = gam_geometry::ClosureProfileCi {
2303        gamma_hat: representative.gamma,
2304        ci_lo,
2305        ci_hi,
2306        ci_includes_circle: hi_at_bound,
2307        ci_includes_interval: lo_at_bound,
2308        singular_boundary,
2309    };
2310
2311    Ok(ClosureSelection {
2312        ci,
2313        representative,
2314        stationarity,
2315        route_to_mixture_rung: singular_boundary,
2316    })
2317}
2318
2319#[cfg(test)]
2320mod tests {
2321    use super::*;
2322    use rayon::iter::{IntoParallelIterator, ParallelIterator};
2323
2324    #[derive(Clone)]
2325    struct SyntheticRaceCandidate {
2326        seed: u64,
2327        len: usize,
2328    }
2329
2330    fn synthetic_fit(candidate: SyntheticRaceCandidate) -> Vec<u64> {
2331        (0..candidate.len)
2332            .into_par_iter()
2333            .map(|i| {
2334                let x = candidate.seed ^ (i as u64 + 1).wrapping_mul(0x9e37_79b9_7f4a_7c15);
2335                x.rotate_left((i % 31) as u32)
2336                    .wrapping_mul(0xbf58_476d_1ce4_e5b9)
2337            })
2338            .collect()
2339    }
2340
2341    #[test]
2342    fn topology_race_parallel_matches_sequential_synthetic_candidates() {
2343        let candidates = vec![
2344            SyntheticRaceCandidate { seed: 11, len: 64 },
2345            SyntheticRaceCandidate { seed: 29, len: 64 },
2346            SyntheticRaceCandidate { seed: 47, len: 64 },
2347        ];
2348        let sequential = candidates
2349            .iter()
2350            .cloned()
2351            .map(synthetic_fit)
2352            .collect::<Vec<_>>();
2353
2354        let parallel =
2355            run_topology_race_parallel_with_budget(candidates, synthetic_fit, 8).unwrap();
2356        assert_eq!(parallel.len(), 3);
2357        assert_eq!(
2358            parallel
2359                .iter()
2360                .map(|row| row.candidate_index)
2361                .collect::<Vec<_>>(),
2362            vec![0, 1, 2]
2363        );
2364        assert!(parallel.iter().all(|row| row.per_fit_threads == 1));
2365        let wall_times = parallel.iter().map(|row| row.wall_time).collect::<Vec<_>>();
2366        assert_eq!(wall_times.len(), 3);
2367        assert_eq!(
2368            parallel
2369                .into_iter()
2370                .map(|row| row.result)
2371                .collect::<Vec<_>>(),
2372            sequential
2373        );
2374    }
2375
2376    fn trivial_provider<'a>() -> HeldOutDensityProvider<'a> {
2377        Box::new(|_train: &[usize], eval: &[usize]| Ok(vec![0.0; eval.len()]))
2378    }
2379
2380    /// #1011/#1012 decision-margin contract on the same-class evidence race:
2381    /// when the winner's lead over the runner-up is inside the enclosure gap,
2382    /// the verdict is provisional (`insufficient_margin` set) so the caller must
2383    /// refine or escalate; a lead that clears the gap transfers cleanly.
2384    #[test]
2385    fn same_class_race_respects_enclosure_decision_margin() {
2386        // Two smooth candidates (same class) whose evidence came from a logdet
2387        // enclosure with gap 1.0. Lead of 0.5 < gap ⇒ provisional.
2388        let near = vec![
2389            CrossClassCandidate {
2390                kind: AutoTopologyKind::Circle,
2391                negative_log_evidence: 100.0,
2392                certification: EvidenceCertification::Enclosure { gap: 1.0 },
2393                density_provider: trivial_provider(),
2394            },
2395            CrossClassCandidate {
2396                kind: AutoTopologyKind::Euclidean,
2397                negative_log_evidence: 100.5,
2398                certification: EvidenceCertification::Enclosure { gap: 1.0 },
2399                density_provider: trivial_provider(),
2400            },
2401        ];
2402        let verdict = adjudicate_cross_class_race(
2403            8,
2404            near,
2405            STACKING_CV_FOLDS,
2406            STACKING_CV_SEED,
2407            StackingConfig::default(),
2408        )
2409        .expect("same-class race");
2410        assert!(!verdict.is_cross_class);
2411        assert_eq!(verdict.winner_index, 0);
2412        let escalation = verdict
2413            .insufficient_margin
2414            .expect("lead inside the enclosure gap must be flagged provisional");
2415        assert_eq!(escalation.provisional_winner, 0);
2416        assert_eq!(escalation.contender, 1);
2417        assert!((escalation.lead - 0.5).abs() < 1e-12);
2418        assert!((escalation.required_margin - 1.0).abs() < 1e-12);
2419
2420        // A lead that clears the gap transfers the verdict cleanly.
2421        let far = vec![
2422            CrossClassCandidate {
2423                kind: AutoTopologyKind::Circle,
2424                negative_log_evidence: 100.0,
2425                certification: EvidenceCertification::Enclosure { gap: 1.0 },
2426                density_provider: trivial_provider(),
2427            },
2428            CrossClassCandidate {
2429                kind: AutoTopologyKind::Euclidean,
2430                negative_log_evidence: 105.0,
2431                certification: EvidenceCertification::Enclosure { gap: 1.0 },
2432                density_provider: trivial_provider(),
2433            },
2434        ];
2435        let verdict_far = adjudicate_cross_class_race(
2436            8,
2437            far,
2438            STACKING_CV_FOLDS,
2439            STACKING_CV_SEED,
2440            StackingConfig::default(),
2441        )
2442        .expect("same-class race");
2443        assert_eq!(verdict_far.winner_index, 0);
2444        assert!(
2445            verdict_far.insufficient_margin.is_none(),
2446            "a lead clearing the enclosure gap must transfer the verdict"
2447        );
2448    }
2449
2450    /// The coreset transfer margin (#1012) flows through the SAME race seam: a
2451    /// lead inside `CoresetCertificate::race_transfer_margin` is provisional.
2452    #[test]
2453    fn same_class_race_respects_coreset_transfer_margin() {
2454        let cert = CoresetCertificate::new(0.05, 0.1, 32, 1000).expect("certificate");
2455        let required = cert.race_transfer_margin();
2456        // Lead strictly inside the certified transfer margin.
2457        let lead = 0.5 * required;
2458        let candidates = vec![
2459            CrossClassCandidate {
2460                kind: AutoTopologyKind::Circle,
2461                negative_log_evidence: 10.0,
2462                certification: EvidenceCertification::Coreset { certificate: cert },
2463                density_provider: trivial_provider(),
2464            },
2465            CrossClassCandidate {
2466                kind: AutoTopologyKind::Euclidean,
2467                negative_log_evidence: 10.0 + lead,
2468                certification: EvidenceCertification::Coreset { certificate: cert },
2469                density_provider: trivial_provider(),
2470            },
2471        ];
2472        let verdict = adjudicate_cross_class_race(
2473            8,
2474            candidates,
2475            STACKING_CV_FOLDS,
2476            STACKING_CV_SEED,
2477            StackingConfig::default(),
2478        )
2479        .expect("same-class race");
2480        let escalation = verdict
2481            .insufficient_margin
2482            .expect("lead inside the coreset transfer margin must be flagged");
2483        assert!((escalation.required_margin - required).abs() < 1e-9);
2484    }
2485
2486    /// #1386: the `seed` mixed into the cross-class CV folding is functional, not
2487    /// a silent no-op. The same seed must reproduce the identical fold
2488    /// assignment, and two different seeds must produce a different assignment
2489    /// for at least one sample (while both remain deterministic). This test FAILS
2490    /// when the seed is ignored (a pure `i % folds` rule is seed-independent, so
2491    /// the two-seed inequality below can never hold) and PASSES once the seed is
2492    /// genuinely threaded into the fold rule.
2493    #[test]
2494    fn cv_folds_are_seed_reproducible_and_seed_varying() {
2495        const N: usize = 40;
2496        const FOLDS: usize = 5;
2497
2498        // Flatten a partition into a per-sample fold-of-sample vector so two
2499        // foldings can be compared sample-by-sample regardless of fold order.
2500        fn fold_of_sample(n: usize, partition: &[(Vec<usize>, Vec<usize>)]) -> Vec<Option<usize>> {
2501            let mut assign = vec![None; n];
2502            for (fold, (_train, eval)) in partition.iter().enumerate() {
2503                for &i in eval {
2504                    assign[i] = Some(fold);
2505                }
2506            }
2507            assign
2508        }
2509
2510        // (a) Reproducible: the same seed gives the identical fold assignment.
2511        let a1 = deterministic_cv_folds_seeded(N, FOLDS, 11);
2512        let a2 = deterministic_cv_folds_seeded(N, FOLDS, 11);
2513        assert_eq!(
2514            fold_of_sample(N, &a1),
2515            fold_of_sample(N, &a2),
2516            "same seed must reproduce the identical CV folding"
2517        );
2518
2519        // (b) Seed-varying: two different seeds must differ for at least one
2520        // sample. If the seed were ignored this assertion could never pass.
2521        let b = deterministic_cv_folds_seeded(N, FOLDS, 12);
2522        assert_ne!(
2523            fold_of_sample(N, &a1),
2524            fold_of_sample(N, &b),
2525            "different seeds must produce different fold assignments (seed must \
2526             not be a no-op)"
2527        );
2528
2529        // The default-seed convenience wrapper agrees with the explicit default
2530        // seed, so existing seed-less call sites keep their deterministic folding.
2531        assert_eq!(
2532            fold_of_sample(N, &deterministic_cv_folds(N, FOLDS)),
2533            fold_of_sample(
2534                N,
2535                &deterministic_cv_folds_seeded(N, FOLDS, STACKING_CV_SEED)
2536            ),
2537            "deterministic_cv_folds must equal the default-seeded folding"
2538        );
2539    }
2540
2541    /// The unified certificate ladder (#16): `EvidenceCertification::race_verdict`
2542    /// maps the same margin contract onto `Verdict`. Exact transfers at any
2543    /// positive lead; an enclosure / coreset certifies only when the lead clears
2544    /// the required margin, else `Insufficient` (never a silent pass).
2545    #[test]
2546    fn race_verdict_maps_onto_unified_ladder() {
2547        use gam_problem::topology_certificates::Verdict;
2548        assert_eq!(
2549            EvidenceCertification::Exact.race_verdict(1e-6),
2550            Verdict::Certified
2551        );
2552        // Non-positive lead is undecided regardless of certification.
2553        assert_eq!(
2554            EvidenceCertification::Exact.race_verdict(0.0),
2555            Verdict::Insufficient
2556        );
2557        let enc = EvidenceCertification::Enclosure { gap: 0.2 };
2558        assert_eq!(enc.race_verdict(0.5), Verdict::Certified);
2559        assert_eq!(enc.race_verdict(0.1), Verdict::Insufficient);
2560        let cert = CoresetCertificate::new(0.05, 0.1, 32, 1000).expect("certificate");
2561        let required = cert.race_transfer_margin();
2562        let coreset = EvidenceCertification::Coreset { certificate: cert };
2563        assert_eq!(coreset.race_verdict(0.5 * required), Verdict::Insufficient);
2564        assert_eq!(
2565            coreset.race_verdict(2.0 * required + 1.0),
2566            Verdict::Certified
2567        );
2568    }
2569
2570    #[test]
2571    fn closure_profiler_recovers_interior_minimum_and_ci() {
2572        // A planted parabolic profile in γ with minimum at 0.7: the closure
2573        // smooth class must recover γ̂ ≈ 0.7 with a CI that excludes both the
2574        // circle (γ=1) and the interval (γ=0) boundaries, and must NOT route to
2575        // the mixture rung (this is a regular interior optimum).
2576        let selection = profile_closure_within_smooth_class(
2577            |gamma| {
2578                Ok::<_, String>(ClosureProfileFit {
2579                    tk_score: 100.0 + 80.0 * (gamma - 0.7).powi(2),
2580                    score_gradient: 160.0 * (gamma - 0.7),
2581                    score_curvature: 160.0,
2582                    support_collapsed: false,
2583                    fit_handle: gamma,
2584                })
2585            },
2586            |lo, hi| {
2587                Ok::<_, String>(gam_math::score_opt::DerivativeEnclosure {
2588                    derivative: gam_math::score_opt::ClosedInterval::outward(
2589                        160.0 * (lo - 0.7),
2590                        160.0 * (hi - 0.7),
2591                    ),
2592                    curvature: gam_math::score_opt::ClosedInterval::outward(160.0, 160.0),
2593                })
2594            },
2595            0.95,
2596        )
2597        .expect("closure profile");
2598        assert!(
2599            (selection.ci.gamma_hat - 0.7).abs() < 0.06,
2600            "γ̂ {}",
2601            selection.ci.gamma_hat
2602        );
2603        assert!(!selection.ci.ci_includes_circle);
2604        assert!(!selection.ci.ci_includes_interval);
2605        assert!(!selection.route_to_mixture_rung);
2606        assert_eq!(selection.stationarity.kind, ClosureOptimumKind::Interior);
2607        assert!(selection.stationarity.projected_gradient <= selection.stationarity.tolerance);
2608        // The representative fit handle is the γ̂ point.
2609        assert!((selection.representative.gamma - selection.ci.gamma_hat).abs() < 1e-12);
2610    }
2611
2612    #[test]
2613    fn closure_profiler_routes_collapse_to_mixture_rung() {
2614        // A profile that keeps improving toward γ=0 (support collapse) pins the
2615        // minimiser at the floor and must hand off to the mixture/union rung.
2616        let selection = profile_closure_within_smooth_class(
2617            |gamma| {
2618                Ok::<_, String>(ClosureProfileFit {
2619                    tk_score: 10.0 + 25.0 * gamma,
2620                    score_gradient: 25.0,
2621                    score_curvature: 0.0,
2622                    support_collapsed: gamma == 0.0,
2623                    fit_handle: gamma,
2624                })
2625            },
2626            |_lo, _hi| {
2627                Ok::<_, String>(gam_math::score_opt::DerivativeEnclosure {
2628                    derivative: gam_math::score_opt::ClosedInterval::outward(25.0, 25.0),
2629                    curvature: gam_math::score_opt::ClosedInterval::outward(0.0, 0.0),
2630                })
2631            },
2632            0.95,
2633        )
2634        .expect("closure profile");
2635        assert!(selection.ci.gamma_hat.abs() < 1e-9);
2636        assert!(selection.route_to_mixture_rung);
2637        assert!(selection.ci.ci_includes_interval);
2638        assert_eq!(
2639            selection.stationarity.kind,
2640            ClosureOptimumKind::IntervalBoundary
2641        );
2642    }
2643
2644    #[test]
2645    fn closure_profiler_does_not_infer_collapse_from_gamma_zero() {
2646        let selection = profile_closure_within_smooth_class(
2647            |gamma| {
2648                Ok::<_, String>(ClosureProfileFit {
2649                    tk_score: 4.0 + gamma,
2650                    score_gradient: 1.0,
2651                    score_curvature: 0.0,
2652                    support_collapsed: false,
2653                    fit_handle: gamma,
2654                })
2655            },
2656            |_lo, _hi| {
2657                Ok::<_, String>(gam_math::score_opt::DerivativeEnclosure {
2658                    derivative: gam_math::score_opt::ClosedInterval::outward(1.0, 1.0),
2659                    curvature: gam_math::score_opt::ClosedInterval::outward(0.0, 0.0),
2660                })
2661            },
2662            0.95,
2663        )
2664        .expect("regular interval-boundary profile");
2665        assert_eq!(
2666            selection.stationarity.kind,
2667            ClosureOptimumKind::IntervalBoundary
2668        );
2669        assert!(!selection.ci.singular_boundary);
2670        assert!(!selection.route_to_mixture_rung);
2671    }
2672
2673    #[test]
2674    fn closure_profiler_selects_a_non_lattice_optimum_and_continuous_ci() {
2675        let planted = 0.713_271_828_f64;
2676        let calls = std::sync::atomic::AtomicUsize::new(0);
2677        let selection = profile_closure_within_smooth_class(
2678            |gamma| {
2679                calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2680                let displacement = gamma - planted;
2681                Ok::<_, String>(ClosureProfileFit {
2682                    tk_score: 7.0 + 32.0 * displacement * displacement,
2683                    score_gradient: 64.0 * displacement,
2684                    score_curvature: 64.0,
2685                    support_collapsed: false,
2686                    fit_handle: gamma,
2687                })
2688            },
2689            |lo, hi| {
2690                Ok::<_, String>(gam_math::score_opt::DerivativeEnclosure {
2691                    derivative: gam_math::score_opt::ClosedInterval::outward(
2692                        64.0 * (lo - planted),
2693                        64.0 * (hi - planted),
2694                    ),
2695                    curvature: gam_math::score_opt::ClosedInterval::outward(64.0, 64.0),
2696                })
2697            },
2698            0.95,
2699        )
2700        .expect("continuous closure profile");
2701        assert!((selection.representative.gamma - planted).abs() < 1.0e-7);
2702        assert!(selection.ci.ci_lo < planted && selection.ci.ci_hi > planted);
2703        // Adaptive optimization and two root walks are expected to revisit the
2704        // oracle; this assertion guards specifically against resurrection of
2705        // the old exactly-17 complete-fit lattice sweep.
2706        assert_ne!(calls.load(std::sync::atomic::Ordering::Relaxed), 17);
2707    }
2708
2709    #[test]
2710    fn topology_race_thread_plan_bounds_nested_rayon_threads() {
2711        let plan = TopologyRaceThreadPlan::for_budget(3, 8);
2712        assert_eq!(plan.concurrent_fits, 3);
2713        assert!(
2714            plan.coordinator_threads + plan.concurrent_fits * plan.per_fit_threads <= 8,
2715            "plan must bound coordinator plus per-fit Rayon workers"
2716        );
2717
2718        let small = TopologyRaceThreadPlan::for_budget(3, 2);
2719        assert_eq!(small.concurrent_fits, 1);
2720        assert!(small.coordinator_threads + small.per_fit_threads <= 2);
2721    }
2722
2723    #[test]
2724    fn topology_selector_retains_failed_candidate_records() {
2725        let selector = TopologyAutoSelector::new(Some(vec![
2726            AutoTopologyKind::Circle,
2727            AutoTopologyKind::Torus,
2728        ]));
2729        let result = select_topology_with_fit(&selector, |kind| match kind {
2730            AutoTopologyKind::Circle => Err("inner REML stationarity failed".to_string()),
2731            AutoTopologyKind::Torus => Ok(TopologyAutoFitEvidence {
2732                topology_name: "torus".to_string(),
2733                raw_reml: 3.0,
2734                null_dim: 0.0,
2735                null_space_logdet: None,
2736                effective_dim: 2.0,
2737                n_obs: 40,
2738                fit_handle: (),
2739            }),
2740            _ => unreachable!(),
2741        })
2742        .expect("one converged candidate is selectable");
2743        assert_eq!(result.winner().unwrap().topology_name, "torus");
2744        assert_eq!(result.failed.len(), 1);
2745        assert_eq!(result.failed[0].topology_name, "circle");
2746        assert_eq!(result.failed[0].stage, TopologyCandidateFailureStage::Fit);
2747        assert!(result.failed[0].message.contains("stationarity"));
2748    }
2749
2750    fn lifecycle_evidence(
2751        name: &str,
2752        raw_reml: f64,
2753        laml: Option<f64>,
2754        deviance: Option<f64>,
2755        effective_dim: f64,
2756    ) -> TopologyCandidateOutcome {
2757        TopologyCandidateOutcome::Fitted(TopologyCandidateEvidence {
2758            name: name.to_string(),
2759            raw_reml,
2760            laml,
2761            deviance,
2762            null_dim: Some(0.0),
2763            null_space_logdet: None,
2764            effective_dim,
2765            basis_size: 4,
2766            n_obs: 20,
2767        })
2768    }
2769
2770    #[test]
2771    fn typed_lifecycle_owns_score_scaling_and_deterministic_winner() {
2772        let result = select_topology_candidate_lifecycle(
2773            vec![
2774                lifecycle_evidence("larger_raw", 5.0, Some(5.0), Some(6.0), 10.0),
2775                lifecycle_evidence("smaller_raw", 3.0, Some(3.0), Some(4.0), 2.0),
2776            ],
2777            TopologySelectionScoreKind::Reml,
2778            TopologySelectionScoreScale::PerEffectiveDim,
2779        )
2780        .expect("typed lifecycle");
2781        assert_eq!(result.winner_index, Some(0));
2782        assert_eq!(result.ranked[0].name, "larger_raw");
2783        assert!((result.ranked[0].score - 0.5).abs() < 1.0e-12);
2784        assert_eq!(result.ranked[1].name, "smaller_raw");
2785        assert!((result.ranked[1].score - 1.5).abs() < 1.0e-12);
2786    }
2787
2788    #[test]
2789    fn typed_lifecycle_converts_bad_evidence_without_losing_other_failures() {
2790        let result = select_topology_candidate_lifecycle(
2791            vec![
2792                TopologyCandidateOutcome::Failed(TopologyCandidateFailure {
2793                    name: "assembly_bad".to_string(),
2794                    stage: TopologyCandidateFailureStage::Assembly,
2795                    error_type: "ValueError".to_string(),
2796                    message: "dimension mismatch".to_string(),
2797                    evidence_at_failure: None,
2798                }),
2799                lifecycle_evidence("evidence_bad", f64::NAN, None, None, 2.0),
2800                lifecycle_evidence("winner", 2.0, None, None, 2.0),
2801            ],
2802            TopologySelectionScoreKind::Reml,
2803            TopologySelectionScoreScale::Raw,
2804        )
2805        .expect("candidate-local evidence failure");
2806        assert_eq!(result.ranked.len(), 1);
2807        assert_eq!(result.ranked[0].name, "winner");
2808        assert_eq!(result.failed.len(), 2);
2809        assert_eq!(
2810            result.failed[0].stage,
2811            TopologyCandidateFailureStage::Assembly
2812        );
2813        assert_eq!(
2814            result.failed[1].stage,
2815            TopologyCandidateFailureStage::Evidence
2816        );
2817        assert!(result.failed[1].message.contains("non-finite REML"));
2818    }
2819
2820    #[test]
2821    fn typed_lifecycle_rejects_duplicate_terminal_outcomes() {
2822        let error = select_topology_candidate_lifecycle(
2823            vec![
2824                lifecycle_evidence("circle", 1.0, None, None, 1.0),
2825                lifecycle_evidence("circle", 2.0, None, None, 1.0),
2826            ],
2827            TopologySelectionScoreKind::Reml,
2828            TopologySelectionScoreScale::Raw,
2829        )
2830        .expect_err("duplicate candidate must be structural error");
2831        assert!(error.contains("duplicate topology candidate"));
2832    }
2833
2834    // --- #944 stage-4 topology collapse tests --------------------------------
2835
2836    /// Two fixed constant-curvature forms (Euclidean + Sphere) must be fused
2837    /// into a single estimated-κ ConstantCurvature candidate, at the position of
2838    /// the first fixed form. Non-CC candidates (Circle, Torus) keep their order.
2839    #[test]
2840    fn fuse_cc_family_collapses_euclidean_and_sphere() {
2841        let input = vec![
2842            AutoTopologyKind::Circle,
2843            AutoTopologyKind::Euclidean,
2844            AutoTopologyKind::Torus,
2845            AutoTopologyKind::Sphere,
2846        ];
2847        let fused = AutoTopologyKind::fuse_constant_curvature_family(&input);
2848        assert_eq!(
2849            fused,
2850            vec![
2851                AutoTopologyKind::Circle,
2852                AutoTopologyKind::ConstantCurvature, // replaced first fixed form
2853                AutoTopologyKind::Torus,
2854                // Sphere dropped — absorbed into ConstantCurvature
2855            ],
2856            "fused candidates: {fused:?}"
2857        );
2858    }
2859
2860    /// A single fixed form alone must NOT be fused (nothing to estimate κ across).
2861    #[test]
2862    fn fuse_cc_family_leaves_single_form_intact() {
2863        let euclidean_only = vec![AutoTopologyKind::Euclidean, AutoTopologyKind::Circle];
2864        let fused = AutoTopologyKind::fuse_constant_curvature_family(&euclidean_only);
2865        assert_eq!(fused, euclidean_only, "single fixed form must not be fused");
2866
2867        let sphere_only = vec![AutoTopologyKind::Sphere];
2868        let fused2 = AutoTopologyKind::fuse_constant_curvature_family(&sphere_only);
2869        assert_eq!(fused2, sphere_only);
2870    }
2871
2872    /// An explicit ConstantCurvature candidate alongside any fixed form fuses
2873    /// by dropping the fixed forms (the explicit CC already subsumes them).
2874    #[test]
2875    fn fuse_cc_family_explicit_cc_absorbs_fixed_forms() {
2876        let input = vec![
2877            AutoTopologyKind::ConstantCurvature,
2878            AutoTopologyKind::Euclidean,
2879            AutoTopologyKind::Circle,
2880        ];
2881        let fused = AutoTopologyKind::fuse_constant_curvature_family(&input);
2882        assert_eq!(
2883            fused,
2884            vec![
2885                AutoTopologyKind::ConstantCurvature,
2886                AutoTopologyKind::Circle
2887            ],
2888            "explicit CC must absorb the fixed Euclidean form"
2889        );
2890    }
2891
2892    /// Idempotence: fusing an already-fused list leaves it unchanged.
2893    #[test]
2894    fn fuse_cc_family_is_idempotent() {
2895        let input = vec![
2896            AutoTopologyKind::Circle,
2897            AutoTopologyKind::ConstantCurvature,
2898            AutoTopologyKind::Torus,
2899        ];
2900        let once = AutoTopologyKind::fuse_constant_curvature_family(&input);
2901        let twice = AutoTopologyKind::fuse_constant_curvature_family(&once);
2902        assert_eq!(once, twice, "fuse must be idempotent");
2903        assert_eq!(once, input, "already-fused list must be unchanged");
2904    }
2905
2906    /// Non-CC lists (no Euclidean/Sphere) are returned as-is.
2907    #[test]
2908    fn fuse_cc_family_noop_for_non_cc_list() {
2909        let input = vec![
2910            AutoTopologyKind::Circle,
2911            AutoTopologyKind::Torus,
2912            AutoTopologyKind::Cylinder,
2913        ];
2914        let fused = AutoTopologyKind::fuse_constant_curvature_family(&input);
2915        assert_eq!(fused, input);
2916    }
2917}