Skip to main content

gam_solve/
evidence.rs

1//! Canonical Laplace evidence, IFT cascade, and topology selection.
2//!
3//! This module is the single canonical entry point for:
4//!
5//!   1. Laplace evidence `V(ρ, T) = F + (1/2) log|H| - (1/2) log|S(ρ)|+
6//!      - ((dim(H)-rank(S))/2) log(2π)`
7//!      evaluated at the arrow-Schur inner-loop fixed point.
8//!   2. The full IFT cascade `∂u*/∂β → ∂β*/∂ρ → ∂u*/∂ρ` through the three
9//!      continuous tiers `(u, β, ρ)`, per §2.2 / §2.4 / §2.6.
10//!   3. The per-`ρ` evidence gradient `∂V/∂ρ` via the arrow trace formula,
11//!      per §3.5 / §3.7 / §3.8.
12//!   4. Discrete topology selection across `{periodic, flat, sphere, torus}`,
13//!      per §4 (4.1 / 4.5 / 4.6).
14//!
15//! ## Crucial numerical invariants (proposal §1.7, §6.4, §6.5)
16//!
17//!   * Evidence log-determinants use **undamped** factors. The cached
18//!     `ArrowFactorCache::htt_factors_undamped` Cholesky factors of
19//!     `H_uu_i` (no `ridge_u`) are the ones that must enter
20//!     `Σ_i log|H_uu_i|`. Likewise a factored Schur log-det must be of
21//!     `A(0, 0) = H_ββ - Σ_i H_uβ_iᵀ H_uu_i⁻¹ H_uβ_i`, not the LM-damped
22//!     surrogate. Matrix-free evidence callers must provide the matching
23//!     undamped HVP so the same log-det is estimated by SLQ.
24//!   * IFT solves invert `H_uu`, not `H_uu + ridge_u I` (proposal §1.7,
25//!     §6.6). The evidence-side IFT predictor loop here uses the undamped
26//!     `htt_factors_undamped` factors for exactly this reason.
27//!   * Penalty pseudo-logdet `log|S(ρ)|+` is the prior penalty, distinct
28//!     from the arrow Schur complement (proposal §3.1, §3.6). The variable
29//!     names below preserve that distinction:
30//!       `arrow_schur_log_det`   = `log|A|` where `A` is the arrow Schur.
31//!       `penalty_log_det`       = `log|S_pen(ρ)|+` where `S_pen` is the
32//!                                 prior penalty matrix pseudo-logdet.
33//!
34//! ## Sign discipline (proposal §3.1, §4.3)
35//!
36//! `V` as written is the *negative log evidence* when `F` is the
37//! penalized negative log posterior. The maximizer of evidence is the
38//! minimizer of `V`. For the public API we expose **negative log
39//! evidence** under `laplace_evidence` and rank topologies by the
40//! **minimum** of the configured per-row or per-effective-dimension
41//! normalization (see `select_topology` below); equivalently the caller can
42//! negate and `argmax`.
43
44use faer::Side;
45use gam_runtime::warm_start::{Fingerprint, Fingerprinter};
46use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
47use serde::{Deserialize, Serialize};
48
49use crate::arrow_schur::ArrowFactorCache;
50use crate::priority_selection::{PriorityCandidate, rank_priority_candidates};
51use gam_linalg::faer_ndarray::FaerEigh;
52use gam_linalg::pairwise_reduce::{BASE_CHUNK, pairwise_sum};
53use gam_math::special::bessel_i0_log_minus_abs_and_ratio;
54
55pub const ANALYTIC_LOGDET_DENSE_DIM_THRESHOLD: usize = 1024;
56
57/// Matrix-free SPD Hessian logdet source used when the arrow Schur factor is
58/// not materialized. The callback must apply the same undamped Hessian whose
59/// determinant enters the Laplace evidence.
60#[derive(Clone, Copy)]
61pub struct EvidenceHvpLogDet<'a> {
62    pub dim: usize,
63    pub apply: &'a dyn Fn(&[f64]) -> Vec<f64>,
64}
65
66/// Source for the Hessian log determinant in `laplace_evidence`.
67#[derive(Clone, Copy)]
68pub enum EvidenceLogDetSource<'a> {
69    /// Use the exact arrow Cholesky factors, falling back to `fallback_hvp`
70    /// when the Schur factor is absent on a matrix-free solve.
71    FactoredArrow {
72        cache: &'a ArrowFactorCache,
73        fallback_hvp: Option<EvidenceHvpLogDet<'a>>,
74    },
75    /// Use an HVP callback directly. Dimensions at or below
76    /// [`ANALYTIC_LOGDET_DENSE_DIM_THRESHOLD`] are materialized exactly;
77    /// larger operators use the same Rademacher-Lanczos SLQ constants as
78    /// `FrozenAnalyticPenaltyOp`.
79    Hvp(EvidenceHvpLogDet<'a>),
80}
81
82// ---------------------------------------------------------------------------
83// Topology candidate enum and selection result
84// ---------------------------------------------------------------------------
85
86/// Discrete topology choice for the latent coordinate domain.
87///
88/// Maps directly to the set `{periodic, flat, sphere, torus}`. No additional
89/// variants — unused candidate variants are deliberately not carried
90/// alongside the four-way selector.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92pub enum TopologyKind {
93    /// `S¹` or periodic interval (cyclic B-spline / periodic Duchon).
94    Periodic,
95    /// `Rᵈ` Euclidean Duchon / Matérn / thin-plate patch.
96    Flat,
97    /// `S²` embedded in `R³`, spherical Wahba/Sobolev basis.
98    Sphere,
99    /// `S¹ × S¹` mixed-periodicity Duchon.
100    Torus,
101}
102
103/// One topology candidate together with the evidence ingredients it
104/// produced at its own fitted optimum.
105#[derive(Debug, Clone)]
106pub struct TopologyCandidate {
107    pub kind: TopologyKind,
108    /// Negative-log-evidence `V(ρ_T*, T)` evaluated at the candidate's own
109    /// fitted `(ρ_T*, β_T*, u_T*)`.
110    pub negative_log_evidence: f64,
111    /// Effective integrated dimension after rank/nullspace accounting. This
112    /// is the dimension used for per-complexity topology normalization.
113    pub effective_dim: f64,
114    /// Number of response rows used to fit this topology candidate. This is
115    /// the dimension used for per-observation topology normalization.
116    pub n_obs: usize,
117    /// `True` iff the candidate's continuous inner+outer fit converged
118    /// cleanly. Failed candidates are excluded from ranking (proposal
119    /// §4.4 item 7 and §6.11).
120    pub converged: bool,
121    /// Optional rationale string for excluded candidates (proposal
122    /// §6.11): `"sphere input not on S²"`, `"torus periods missing"`, etc.
123    pub exclusion_reason: Option<String>,
124}
125
126/// Outcome of `select_topology`.
127#[derive(Debug, Clone)]
128pub struct SelectedTopology {
129    pub winner: TopologyKind,
130    /// All candidates sorted from best (lowest negative log evidence)
131    /// to worst, with excluded candidates appended last.
132    pub ranking: Vec<TopologyCandidate>,
133    /// `True` iff the top two finite scores fall within `tie_tolerance`.
134    /// Per §4.6 we still pick one — the simpler topology — but expose
135    /// the tie so callers can warn.
136    pub tie: bool,
137}
138
139/// Tolerance options for the topology comparator.
140#[derive(Debug, Clone, Copy)]
141pub struct TopologySelectOptions {
142    /// Maximum `|V_a - V_b|` for which two candidates are treated as
143    /// numerically tied after [`TopologyScoreScale`] normalization. Default
144    /// `1e-3` per proposal §4.6 examples.
145    pub tie_tolerance: f64,
146    /// Score scale used for discrete topology comparison. Raw evidence is
147    /// intentionally not a selector because candidates may have different
148    /// row counts and basis/nullspace dimensions.
149    pub score_scale: TopologyScoreScale,
150}
151
152/// Normalization applied before ranking topology candidates.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum TopologyScoreScale {
155    /// Compare negative log evidence per observation row.
156    PerObservation,
157    /// Compare negative log evidence per effective integrated dimension.
158    PerEffectiveDim,
159}
160
161/// Convergence controls for stacking retained topology predictive densities.
162#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
163pub struct StackingConfig {
164    /// Exhaustion-escalation bound on solver iterations. It never selects the
165    /// weights: exhausting it without the KKT certificate is an error, so an
166    /// uncertified iterate can never feed model selection.
167    pub max_iter: usize,
168    /// Simplex-KKT residual the solution must certify before it is returned.
169    /// The residual is scale-free: the KKT multiplier of the stacking problem
170    /// is exactly 1, so `g_k − 1` is already a relative stationarity measure.
171    pub kkt_tol: f64,
172}
173
174impl Default for StackingConfig {
175    fn default() -> Self {
176        Self {
177            max_iter: 256,
178            kkt_tol: f64::EPSILON.sqrt(),
179        }
180    }
181}
182
183/// Auditable global-optimality certificate for a stacking solution.
184#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
185pub struct StackingCertificate {
186    /// Achieved held-out mean log predictive density.
187    pub mean_log_score: f64,
188    /// Frank-Wolfe/KKT duality gap `max_k g_k - w·g`. Concavity makes this
189    /// an upper bound on objective suboptimality.
190    pub duality_gap: f64,
191    /// Absolute simplex mass residual `|Σw - 1|`.
192    pub simplex_residual: f64,
193    /// Error in the analytic multiplier identity `w·g = 1`.
194    pub multiplier_residual: f64,
195    /// Largest complementary-slackness residual `w_k |g_k - w·g|`.
196    pub complementarity_residual: f64,
197}
198
199impl StackingCertificate {
200    pub fn residual(&self) -> f64 {
201        self.duality_gap
202            .max(self.simplex_residual)
203            .max(self.multiplier_residual)
204            .max(self.complementarity_residual)
205    }
206}
207
208/// Serializable work state carried by a stacking exhaustion error and accepted
209/// by `resume_stacking_weights`. Weights stay aligned to the original input
210/// columns; no candidate is silently dropped.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct StackingCheckpoint {
213    pub weights: Array1<f64>,
214    pub completed_iterations: usize,
215    density_fingerprint: Fingerprint,
216}
217
218/// Typed stacking failure. In particular, exhaustion carries both its
219/// certificate evidence and an exact checkpoint rather than returning weights.
220#[derive(Debug, Clone)]
221pub enum StackingError {
222    InvalidInput {
223        message: String,
224    },
225    NumericalFailure {
226        message: String,
227        certificate: Option<StackingCertificate>,
228        checkpoint: Option<StackingCheckpoint>,
229    },
230    DidNotConverge {
231        max_iterations: usize,
232        tolerance: f64,
233        certificate: StackingCertificate,
234        checkpoint: StackingCheckpoint,
235    },
236}
237
238impl std::fmt::Display for StackingError {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        match self {
241            Self::InvalidInput { message } => write!(f, "invalid stacking problem: {message}"),
242            Self::NumericalFailure {
243                message,
244                certificate,
245                checkpoint,
246            } => write!(
247                f,
248                "stacking numerical failure: {message} (certificate residual {}, checkpoint iterations {})",
249                certificate.map_or(f64::NAN, |value| value.residual()),
250                checkpoint
251                    .as_ref()
252                    .map_or(0, |value| value.completed_iterations)
253            ),
254            Self::DidNotConverge {
255                max_iterations,
256                tolerance,
257                certificate,
258                checkpoint,
259            } => write!(
260                f,
261                "stacking did not certify after {max_iterations} additional iterations (total {}): KKT residual {:.6e} exceeds tolerance {:.3e}; resume from the carried weights checkpoint",
262                checkpoint.completed_iterations,
263                certificate.residual(),
264                tolerance
265            ),
266        }
267    }
268}
269
270impl std::error::Error for StackingError {}
271
272/// Simplex weights for retained topology candidates plus their verified global
273/// optimum certificate.
274#[derive(Debug, Clone)]
275pub struct StackingWeights {
276    pub weights: Array1<f64>,
277    pub iterations: usize,
278    pub certificate: StackingCertificate,
279}
280
281impl StackingWeights {
282    pub fn mean_log_score(&self) -> f64 {
283        self.certificate.mean_log_score
284    }
285}
286
287struct StackingProblem {
288    scaled_density: Array2<f64>,
289    row_log_scale: Array1<f64>,
290}
291
292impl StackingProblem {
293    fn from_log_density(log_density: ArrayView2<'_, f64>) -> Result<Self, StackingError> {
294        let n_obs = log_density.nrows();
295        let n_cand = log_density.ncols();
296        if n_cand == 0 || n_obs == 0 {
297            return Err(StackingError::InvalidInput {
298                message: "at least one candidate and one held-out row are required".to_string(),
299            });
300        }
301        if let Some(((row, col), value)) = log_density
302            .indexed_iter()
303            .find(|(_, value)| value.is_nan() || **value == f64::INFINITY)
304        {
305            return Err(StackingError::InvalidInput {
306                message: format!(
307                    "log density at row {row}, candidate {col} is {value}; NaN and +infinity are not predictive densities"
308                ),
309            });
310        }
311        let mut scaled_density = Array2::<f64>::zeros((n_obs, n_cand));
312        let mut row_log_scale = Array1::<f64>::zeros(n_obs);
313        for row in 0..n_obs {
314            let row_max = (0..n_cand)
315                .map(|col| log_density[[row, col]])
316                .fold(f64::NEG_INFINITY, f64::max);
317            if !row_max.is_finite() {
318                return Err(StackingError::InvalidInput {
319                    message: format!(
320                        "held-out row {row} has zero density under every candidate; deleting it would change the stacking target"
321                    ),
322                });
323            }
324            row_log_scale[row] = row_max;
325            for col in 0..n_cand {
326                let value = log_density[[row, col]];
327                if value.is_finite() {
328                    scaled_density[[row, col]] = (value - row_max).exp();
329                }
330            }
331        }
332        Ok(Self {
333            scaled_density,
334            row_log_scale,
335        })
336    }
337
338    fn evaluate(
339        &self,
340        weights: ArrayView1<'_, f64>,
341    ) -> Result<(Array1<f64>, StackingCertificate, f64), String> {
342        let n = self.scaled_density.nrows();
343        let k = self.scaled_density.ncols();
344        let mass = weights.sum();
345        if weights.len() != k
346            || weights
347                .iter()
348                .any(|value| !value.is_finite() || *value < 0.0)
349            || !(mass.is_finite() && mass > 0.0)
350        {
351            return Err(
352                "checkpoint weights are not a finite nonnegative simplex vector".to_string(),
353            );
354        }
355        let mut gradient = Array1::<f64>::zeros(k);
356        let mut centered_objective = 0.0_f64;
357        let mut mean_log_score = 0.0_f64;
358        for row in 0..n {
359            let mut mixture = 0.0_f64;
360            for col in 0..k {
361                mixture += weights[col] * self.scaled_density[[row, col]];
362            }
363            if !(mixture.is_finite() && mixture > 0.0) {
364                return Err(format!(
365                    "candidate mixture lost held-out row {row} (scaled density {mixture})"
366                ));
367            }
368            let log_mixture = mixture.ln();
369            centered_objective += log_mixture / n as f64;
370            let log_score = self.row_log_scale[row] + log_mixture;
371            let count = (row + 1) as f64;
372            mean_log_score = mean_log_score * ((count - 1.0) / count) + log_score / count;
373            for col in 0..k {
374                gradient[col] += self.scaled_density[[row, col]] / mixture / n as f64;
375            }
376        }
377        if !centered_objective.is_finite()
378            || !mean_log_score.is_finite()
379            || gradient.iter().any(|value| !value.is_finite())
380        {
381            return Err("objective or analytic gradient became non-finite".to_string());
382        }
383        let multiplier = weights.dot(&gradient);
384        let max_gradient = gradient.iter().copied().fold(f64::NEG_INFINITY, f64::max);
385        let certificate = StackingCertificate {
386            mean_log_score,
387            duality_gap: (max_gradient - multiplier).max(0.0),
388            simplex_residual: (mass - 1.0).abs(),
389            multiplier_residual: (multiplier - 1.0).abs(),
390            complementarity_residual: weights
391                .iter()
392                .zip(gradient.iter())
393                .map(|(&weight, &gain)| weight * (gain - multiplier).abs())
394                .fold(0.0_f64, f64::max),
395        };
396        Ok((gradient, certificate, centered_objective))
397    }
398
399    fn centered_objective(&self, weights: ArrayView1<'_, f64>) -> Option<f64> {
400        let n = self.scaled_density.nrows();
401        let mut objective = 0.0_f64;
402        for row in 0..n {
403            let mixture = self.scaled_density.row(row).dot(&weights);
404            if !(mixture.is_finite() && mixture > 0.0) {
405                return None;
406            }
407            objective += mixture.ln() / n as f64;
408        }
409        objective.is_finite().then_some(objective)
410    }
411}
412
413/// Solve the stacking-of-predictive-distributions weight problem from a
414/// per-observation held-out log-density table `log_density[i, k] = log p_k(y_i)`.
415///
416/// This belongs on the evidence surface rather than in a separate solver: it is
417/// the topology/evidence consumer that replaces winner-take-all only when the
418/// caller has retained candidate fits and per-point held-out densities.
419///
420/// ## Optimality certificate
421///
422/// The objective `f(w) = mean_i log Σ_k w_k p_ik` is concave on the simplex,
423/// so first-order KKT conditions are necessary AND sufficient for the global
424/// optimum. With `g_k = ∂f/∂w_k = mean_i p_ik / mix_i`, the simplex multiplier
425/// is exactly `Σ_k w_k g_k = 1`, so the KKT system is `g_k ≤ 1` for every
426/// candidate with `w_k · (1 − g_k) = 0` (complementary slackness). Iterates
427/// use an analytic reduced-space Newton step on the current simplex face; an
428/// exact concave line solve toward the most violated vertex activates a
429/// candidate or globalizes a singular Newton system. The solve returns only
430/// after the Frank-Wolfe duality gap and all primal/KKT residuals are verified
431/// below `config.kkt_tol`. Exhaustion carries the full certificate and a
432/// resumable weights checkpoint; uncertified weights never reach selection.
433pub fn solve_stacking_weights(
434    log_density: ArrayView2<'_, f64>,
435    config: StackingConfig,
436) -> Result<StackingWeights, StackingError> {
437    solve_stacking_weights_impl(log_density, config, None)
438}
439
440fn solve_stacking_weights_impl(
441    log_density: ArrayView2<'_, f64>,
442    config: StackingConfig,
443    checkpoint: Option<&StackingCheckpoint>,
444) -> Result<StackingWeights, StackingError> {
445    if config.max_iter == 0 {
446        return Err(StackingError::InvalidInput {
447            message: "max_iter must be positive".to_string(),
448        });
449    }
450    let numerical_floor = f64::EPSILON.sqrt();
451    if !config.kkt_tol.is_finite() || config.kkt_tol < numerical_floor {
452        return Err(StackingError::InvalidInput {
453            message: format!(
454                "kkt_tol must be finite and at least the floating-point resolution floor {numerical_floor:.3e}"
455            ),
456        });
457    }
458    let density_fingerprint = evidence_matrix_fingerprint("stacking-log-density-v1", log_density);
459    let problem = StackingProblem::from_log_density(log_density)?;
460    let k = problem.scaled_density.ncols();
461    let (mut weights, completed_before) = if let Some(checkpoint) = checkpoint {
462        if checkpoint.density_fingerprint != density_fingerprint {
463            return Err(StackingError::InvalidInput {
464                message: "checkpoint belongs to a different held-out density table".to_string(),
465            });
466        }
467        if checkpoint.weights.len() != k {
468            return Err(StackingError::InvalidInput {
469                message: format!(
470                    "checkpoint has {} weights but the density table has {k} candidates",
471                    checkpoint.weights.len()
472                ),
473            });
474        }
475        let mut weights = checkpoint.weights.clone();
476        let mass = weights.sum();
477        if weights
478            .iter()
479            .any(|value| !value.is_finite() || *value < 0.0)
480            || !mass.is_finite()
481            || (mass - 1.0).abs() > config.kkt_tol
482        {
483            return Err(StackingError::InvalidInput {
484                message: "checkpoint weights must be a finite nonnegative simplex vector"
485                    .to_string(),
486            });
487        }
488        weights.mapv_inplace(|value| value / mass);
489        (weights, checkpoint.completed_iterations)
490    } else {
491        (Array1::<f64>::from_elem(k, 1.0 / k as f64), 0)
492    };
493
494    for additional_iterations in 0..=config.max_iter {
495        let completed_iterations = completed_before + additional_iterations;
496        let checkpoint = StackingCheckpoint {
497            weights: weights.clone(),
498            completed_iterations,
499            density_fingerprint,
500        };
501        let (gradient, certificate, objective) =
502            problem.evaluate(weights.view()).map_err(|message| {
503                StackingError::NumericalFailure {
504                    message,
505                    certificate: None,
506                    checkpoint: Some(checkpoint.clone()),
507                }
508            })?;
509        if certificate.residual() <= config.kkt_tol {
510            return Ok(StackingWeights {
511                weights,
512                iterations: completed_iterations,
513                certificate,
514            });
515        }
516        if additional_iterations == config.max_iter {
517            return Err(StackingError::DidNotConverge {
518                max_iterations: config.max_iter,
519                tolerance: config.kkt_tol,
520                certificate,
521                checkpoint,
522            });
523        }
524
525        let max_gradient_col = gradient
526            .iter()
527            .enumerate()
528            .max_by(|left, right| left.1.total_cmp(right.1))
529            .map(|(index, _)| index)
530            .expect("stacking has at least one candidate");
531        let candidate = stacking_newton_step(&problem, weights.view(), gradient.view(), objective)
532            .or_else(|| {
533                stacking_vertex_step(&problem, weights.view(), max_gradient_col, objective)
534            })
535            .ok_or_else(|| StackingError::NumericalFailure {
536                message: "positive KKT gap remained but neither the analytic Newton direction nor the exact vertex line solve produced a representable ascent step".to_string(),
537                certificate: Some(certificate),
538                checkpoint: Some(checkpoint),
539            })?;
540        weights = candidate;
541    }
542    Err(StackingError::NumericalFailure {
543        message: format!(
544            "stacking solver exhausted its inclusive iteration budget ({}) without producing a \
545             terminal verdict",
546            config.max_iter
547        ),
548        certificate: None,
549        checkpoint: None,
550    })
551}
552
553fn stacking_newton_step(
554    problem: &StackingProblem,
555    weights: ArrayView1<'_, f64>,
556    gradient: ArrayView1<'_, f64>,
557    objective: f64,
558) -> Option<Array1<f64>> {
559    let active: Vec<usize> = weights
560        .iter()
561        .enumerate()
562        .filter_map(|(index, &weight)| (weight > 0.0).then_some(index))
563        .collect();
564    if active.len() < 2 {
565        return None;
566    }
567    let reference_position = active
568        .iter()
569        .enumerate()
570        .max_by(|left, right| weights[*left.1].total_cmp(&weights[*right.1]))
571        .map(|(position, _)| position)?;
572    let reference = active[reference_position];
573    let free: Vec<usize> = active
574        .iter()
575        .copied()
576        .filter(|&index| index != reference)
577        .collect();
578    let dimension = free.len();
579    let n = problem.scaled_density.nrows();
580    let mut information = Array2::<f64>::zeros((dimension, dimension));
581    for row in 0..n {
582        let mixture = problem.scaled_density.row(row).dot(&weights);
583        if !(mixture.is_finite() && mixture > 0.0) {
584            return None;
585        }
586        let reference_density = problem.scaled_density[[row, reference]];
587        let contrasts: Vec<f64> = free
588            .iter()
589            .map(|&col| (problem.scaled_density[[row, col]] - reference_density) / mixture)
590            .collect();
591        for left in 0..dimension {
592            for right in 0..=left {
593                information[[left, right]] += contrasts[left] * contrasts[right] / n as f64;
594                information[[right, left]] = information[[left, right]];
595            }
596        }
597    }
598    let reduced_gradient =
599        Array1::from_iter(free.iter().map(|&col| gradient[col] - gradient[reference]));
600    let (eigenvalues, eigenvectors) = information.eigh(Side::Lower).ok()?;
601    let spectral_scale = eigenvalues.iter().copied().fold(0.0_f64, f64::max);
602    if !(spectral_scale.is_finite() && spectral_scale > 0.0) {
603        return None;
604    }
605    let rank_tolerance = f64::EPSILON * (dimension as f64) * spectral_scale.max(f64::MIN_POSITIVE);
606    let projected = eigenvectors.t().dot(&reduced_gradient);
607    let mut spectral_step = Array1::<f64>::zeros(dimension);
608    for index in 0..dimension {
609        if eigenvalues[index] > rank_tolerance {
610            spectral_step[index] = projected[index] / eigenvalues[index];
611        }
612    }
613    let reduced_step = eigenvectors.dot(&spectral_step);
614    let ascent = reduced_gradient.dot(&reduced_step);
615    if !(ascent.is_finite() && ascent > 0.0) {
616        return None;
617    }
618    let mut direction = Array1::<f64>::zeros(weights.len());
619    for (position, &col) in free.iter().enumerate() {
620        direction[col] = reduced_step[position];
621    }
622    direction[reference] = -reduced_step.sum();
623    let mut step = 1.0_f64;
624    let mut boundary = None;
625    for col in 0..weights.len() {
626        if direction[col] < 0.0 {
627            let candidate = -weights[col] / direction[col];
628            if candidate < step {
629                step = candidate;
630                boundary = Some(col);
631            }
632        }
633    }
634    loop {
635        let mut candidate = &weights + &(direction.mapv(|value| step * value));
636        if let Some(col) = boundary {
637            if step == -weights[col] / direction[col] {
638                candidate[col] = 0.0;
639            }
640        }
641        for value in candidate.iter_mut() {
642            if *value < 0.0 && *value >= -f64::EPSILON {
643                *value = 0.0;
644            }
645        }
646        let mass = candidate.sum();
647        if mass.is_finite() && mass > 0.0 {
648            candidate.mapv_inplace(|value| value / mass);
649            if problem
650                .centered_objective(candidate.view())
651                .is_some_and(|value| value > objective)
652            {
653                return Some(candidate);
654            }
655        }
656        let next_step = 0.5 * step;
657        if next_step == step || next_step == 0.0 {
658            return None;
659        }
660        step = next_step;
661        boundary = None;
662    }
663}
664
665fn stacking_vertex_step(
666    problem: &StackingProblem,
667    weights: ArrayView1<'_, f64>,
668    vertex: usize,
669    objective: f64,
670) -> Option<Array1<f64>> {
671    let derivative = |step: f64| -> f64 {
672        let mut value = 0.0_f64;
673        let n = problem.scaled_density.nrows();
674        for row in 0..n {
675            let current = problem.scaled_density.row(row).dot(&weights);
676            let target = problem.scaled_density[[row, vertex]];
677            let mixture = (1.0 - step) * current + step * target;
678            if mixture <= 0.0 {
679                return f64::NEG_INFINITY;
680            }
681            value += (target - current) / mixture / n as f64;
682        }
683        value
684    };
685    if derivative(0.0) <= 0.0 {
686        return None;
687    }
688    let mut step = if derivative(1.0) >= 0.0 {
689        1.0
690    } else {
691        let mut lower = 0.0_f64;
692        let mut upper = 1.0_f64;
693        while upper - lower > f64::EPSILON.sqrt() {
694            let middle = 0.5 * (lower + upper);
695            if derivative(middle) > 0.0 {
696                lower = middle;
697            } else {
698                upper = middle;
699            }
700        }
701        0.5 * (lower + upper)
702    };
703    loop {
704        let mut candidate = weights.mapv(|weight| (1.0 - step) * weight);
705        candidate[vertex] += step;
706        if problem
707            .centered_objective(candidate.view())
708            .is_some_and(|value| value > objective)
709        {
710            return Some(candidate);
711        }
712        let next_step = 0.5 * step;
713        if next_step == step || next_step == 0.0 {
714            return None;
715        }
716        step = next_step;
717    }
718}
719
720// ---------------------------------------------------------------------------
721// Discrete mixture rung (Object 3a / WP-C)
722// ---------------------------------------------------------------------------
723//
724// A `k`-component full-covariance Gaussian mixture fitted by deterministic
725// k-means++-style seeding (reusing `terms::basis` farthest-point k-means) plus
726// EM to a tolerance. It is priced by its free-parameter count with the
727// invariant BIC approximation to negative log evidence,
728//
729//     BIC/2 = -loglik + (P/2) log(n).
730//
731// BIC is intentional here. An outer product of per-observation scores is not
732// an observed Hessian and need not be full-rank even when the likelihood has
733// curvature (an exactly centered Gaussian mean is the simplest counterexample).
734// Moreover, the covariance-floor constraint can put a component on a boundary,
735// where an interior SPD Laplace expansion is mathematically invalid. Without a
736// declared parameter prior and its Jacobian, a raw Hessian determinant would
737// also change under reparameterization. The smooth parametric shape candidates
738// use this same BIC-form score, so every shape-race corroborating score now has
739// one finite, parameterization-invariant meaning.
740
741/// Convergence + ladder controls for the discrete-mixture rung. All fields are
742/// fixed (no clock randomness, no env): deterministic seeding makes the fitted
743/// mixture a pure function of the data and `k`.
744#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
745pub struct GaussianMixtureConfig {
746    /// Exhaustion-escalation bound on EM iterations. It never selects the
747    /// estimator: exhausting it without the convergence certificate
748    /// (monotone ascent + relative objective step below `loglik_tol`) is an
749    /// error, so an uncertified mixture never enters evidence comparison.
750    pub max_iter: usize,
751    /// Relative mean-log-likelihood improvement tolerance for EM stopping.
752    pub loglik_tol: f64,
753    /// Max-norm tolerance for the EM map in empirical predictive-density
754    /// coordinates: the largest absolute change in any training row's log
755    /// density. This quotients component permutations, duplicate-component
756    /// mass exchange, and non-identifiable ring factorizations while retaining
757    /// sensitivity to likelihood changes that cancel in the mean objective.
758    pub parameter_tol: f64,
759    /// Lower eigenvalue constraint for every component covariance. The M-step
760    /// solves this constrained likelihood problem exactly by spectral clipping;
761    /// it is not an additive ridge or an unmodelled prior.
762    pub covariance_floor: f64,
763    /// Maximum iterations for the deterministic k-means seeding pass.
764    pub kmeans_max_iter: usize,
765}
766
767impl Default for GaussianMixtureConfig {
768    fn default() -> Self {
769        Self {
770            max_iter: 1000,
771            loglik_tol: f64::EPSILON.sqrt(),
772            parameter_tol: f64::EPSILON.sqrt(),
773            covariance_floor: 1e-6,
774            kmeans_max_iter: 25,
775        }
776    }
777}
778
779/// Residual evidence proving that the returned mixture is a fixed point of its
780/// likelihood EM map rather than merely the iterate present at a work cap.
781#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
782pub struct GaussianMixtureCertificate {
783    /// Mean log likelihood at the exact parameter state being certified.
784    pub mean_log_likelihood: f64,
785    /// Signed gain produced by one further EM map application from that state.
786    pub mean_log_likelihood_gain: f64,
787    /// Absolute numerical uncertainty on the comparison of the two likelihoods.
788    /// This includes both the final likelihood-reduction error and the
789    /// scale-derived resolution floor of the composite EM map.
790    pub monotonicity_uncertainty: f64,
791    pub objective_residual: f64,
792    pub objective_tolerance: f64,
793    pub parameter_residual: f64,
794    pub parameter_tolerance: f64,
795    /// Measured per-iteration contraction rate `ρ` of the parameter residual
796    /// over the trailing `EM_RATE_WINDOW`, or `None` before a full window has
797    /// accumulated. `ρ < 1` is the evidence that the iterate is still
798    /// descending; `ρ ≥ 1` is the evidence that it has stalled.
799    pub contraction_rate: Option<f64>,
800    /// Iterations still required to reach `parameter_tolerance` at the measured
801    /// `contraction_rate`, or `None` when no rate is available, the rate does
802    /// not contract, or the tolerance is already met. This is what makes a
803    /// refusal PRICEABLE: a caller can see whether it was interrupted mid-
804    /// descent and by how much.
805    pub projected_iterations_to_tolerance: Option<usize>,
806}
807
808/// Trailing window, in EM updates, over which the parameter residual's
809/// contraction rate is measured.
810///
811/// DERIVATION. A single step ratio `r_t / r_{t-1}` carries the full relative
812/// noise of both residuals, and near a fixed point that noise is comparable to
813/// the step itself — one ratio cannot distinguish descent from a stall. The
814/// geometric mean over `W` steps averages `W` independent log-ratios, so its
815/// log-jitter falls as `1/√W`: `W = 64` suppresses per-step jitter eightfold
816/// while costing 6.4% of the base update budget to establish. It is also the
817/// re-validation cadence during an extension, so a stall is caught within one
818/// window of appearing rather than at the end of the projection.
819const EM_RATE_WINDOW: usize = 64;
820
821/// Geometric per-iteration contraction rate of the parameter residual across
822/// the window: `ρ = (r_last / r_first)^{1/(W)}`.
823///
824/// `None` until the window is full, or when either endpoint is not strictly
825/// positive and finite — a zero residual is convergence, not a rate, and the
826/// caller's tolerance test has already handled it.
827fn em_contraction_rate(window: &std::collections::VecDeque<f64>) -> Option<f64> {
828    if window.len() < EM_RATE_WINDOW + 1 {
829        return None;
830    }
831    let first = *window.front()?;
832    let last = *window.back()?;
833    if !(first.is_finite() && last.is_finite() && first > 0.0 && last > 0.0) {
834        return None;
835    }
836    let steps = (window.len() - 1) as f64;
837    let rate = (last / first).powf(1.0 / steps);
838    rate.is_finite().then_some(rate)
839}
840
841/// Iterations still required to bring `residual` to `tolerance` at contraction
842/// rate `rate`, i.e. the `N*` solving `residual·ρ^{N*} = tolerance`.
843///
844/// `None` when the rate does not contract (`ρ ∉ (0, 1)`), when the tolerance is
845/// already met, or when the inputs are not finite — in every one of those cases
846/// there is no projection to make, and inventing one would be the fabrication
847/// this certificate exists to prevent.
848fn em_projected_iterations(residual: f64, tolerance: f64, rate: f64) -> Option<usize> {
849    if !(residual.is_finite() && tolerance.is_finite() && rate.is_finite()) {
850        return None;
851    }
852    if !(rate > 0.0 && rate < 1.0) || !(residual > tolerance) || tolerance <= 0.0 {
853        return None;
854    }
855    let steps = (tolerance / residual).ln() / rate.ln();
856    (steps.is_finite() && steps >= 0.0).then(|| steps.ceil() as usize)
857}
858
859/// Exact parameter state carried across an EM exhaustion boundary.
860#[derive(Debug, Clone, Serialize, Deserialize)]
861pub struct GaussianMixtureCheckpoint {
862    pub weights: Array1<f64>,
863    pub means: Array2<f64>,
864    pub covariances: Vec<Array2<f64>>,
865    pub mean_log_likelihood: f64,
866    pub completed_iterations: usize,
867    data_fingerprint: Fingerprint,
868    covariance_floor: f64,
869}
870
871/// Typed Gaussian-mixture optimization failure. Exhaustion and a broken EM
872/// monotonicity invariant both carry the last internally consistent state.
873#[derive(Debug, Clone)]
874pub enum GaussianMixtureError {
875    InvalidInput {
876        message: String,
877    },
878    NumericalFailure {
879        message: String,
880        checkpoint: Option<GaussianMixtureCheckpoint>,
881    },
882    MonotonicityViolation {
883        previous_mean_log_likelihood: f64,
884        next_mean_log_likelihood: f64,
885        numerical_uncertainty: f64,
886        checkpoint: GaussianMixtureCheckpoint,
887    },
888    DidNotConverge {
889        max_iterations: usize,
890        certificate: GaussianMixtureCertificate,
891        checkpoint: GaussianMixtureCheckpoint,
892    },
893}
894
895impl std::fmt::Display for GaussianMixtureError {
896    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
897        match self {
898            Self::InvalidInput { message } => write!(f, "invalid Gaussian mixture: {message}"),
899            Self::NumericalFailure {
900                message,
901                checkpoint,
902            } => write!(
903                f,
904                "Gaussian-mixture numerical failure: {message} (checkpoint iterations {})",
905                checkpoint
906                    .as_ref()
907                    .map_or(0, |value| value.completed_iterations)
908            ),
909            Self::MonotonicityViolation {
910                previous_mean_log_likelihood,
911                next_mean_log_likelihood,
912                numerical_uncertainty,
913                checkpoint,
914            } => write!(
915                f,
916                "Gaussian-mixture EM violated monotone ascent at iteration {}: mean log likelihood {previous_mean_log_likelihood:.12e} -> {next_mean_log_likelihood:.12e} (comparison uncertainty {numerical_uncertainty:.3e}); resume from the carried checkpoint only after diagnosing the numerical failure",
917                checkpoint.completed_iterations
918            ),
919            Self::DidNotConverge {
920                max_iterations,
921                certificate,
922                checkpoint,
923            } => write!(
924                f,
925                "Gaussian-mixture EM did not certify after {max_iterations} additional iterations (total {}): signed mean-log-likelihood gain {:.6e} (numerical uncertainty {:.3e}), objective residual {:.6e}/{:.3e}, parameter-map residual {:.6e}/{:.3e}, contraction rate {} per iteration, projected iterations to tolerance {}; resume from the carried checkpoint, which is not comparable evidence",
926                checkpoint.completed_iterations,
927                certificate.mean_log_likelihood_gain,
928                certificate.monotonicity_uncertainty,
929                certificate.objective_residual,
930                certificate.objective_tolerance,
931                certificate.parameter_residual,
932                certificate.parameter_tolerance,
933                match certificate.contraction_rate {
934                    Some(rate) => format!("{rate:.6}"),
935                    None => "unmeasured".to_string(),
936                },
937                match certificate.projected_iterations_to_tolerance {
938                    Some(steps) => steps.to_string(),
939                    None => "none (not contracting)".to_string(),
940                }
941            ),
942        }
943    }
944}
945
946impl std::error::Error for GaussianMixtureError {}
947
948/// A fitted `k`-component full-covariance Gaussian mixture.
949#[derive(Debug, Clone)]
950pub struct GaussianMixtureFit {
951    /// Mixing weights, length `k`, on the simplex.
952    weights: Array1<f64>,
953    /// Component means, `k × d`.
954    means: Array2<f64>,
955    /// Component covariances, `k` matrices of shape `d × d` (SPD).
956    covariances: Vec<Array2<f64>>,
957    /// Number of mixture components.
958    k: usize,
959    /// Data dimension.
960    d: usize,
961    /// Number of rows used to fit.
962    n_obs: usize,
963    /// Maximised total log-likelihood `Σ_i log Σ_j w_j N(y_i; μ_j, Σ_j)`.
964    loglik: f64,
965    /// EM iterations taken.
966    iterations: usize,
967    certificate: GaussianMixtureCertificate,
968}
969
970impl GaussianMixtureFit {
971    pub fn weights(&self) -> ArrayView1<'_, f64> {
972        self.weights.view()
973    }
974
975    pub fn means(&self) -> ArrayView2<'_, f64> {
976        self.means.view()
977    }
978
979    pub fn iterations(&self) -> usize {
980        self.iterations
981    }
982
983    pub fn certificate(&self) -> GaussianMixtureCertificate {
984        self.certificate
985    }
986
987    /// Free-parameter count `P` of a `k`-component full-covariance mixture in
988    /// `d` dimensions: `(k − 1)` mixing weights on the simplex, `k·d` mean
989    /// coordinates, and `k · d(d+1)/2` covariance entries. This is the exact
990    /// quantity that enters the rank-aware normalizer as `dim(H) − rank(S)`.
991    pub fn num_free_parameters(&self) -> usize {
992        let cov_per = self.d * (self.d + 1) / 2;
993        (self.k - 1) + self.k * self.d + self.k * cov_per
994    }
995
996    /// Per-observation log predictive density `log p(y_i)` under the fitted
997    /// mixture, length `n`. This is the held-out-density column source for
998    /// cross-class stacking when the mixture is evaluated on a held-out fold.
999    pub fn per_point_log_density(&self, data: ArrayView2<'_, f64>) -> Result<Array1<f64>, String> {
1000        if data.ncols() != self.d {
1001            return Err(format!(
1002                "mixture log-density expects {} columns, got {}",
1003                self.d,
1004                data.ncols()
1005            ));
1006        }
1007        let n = data.nrows();
1008        let mut comp = Vec::with_capacity(self.k);
1009        for j in 0..self.k {
1010            comp.push(GaussianComponentEval::factor(
1011                self.means.row(j),
1012                &self.covariances[j],
1013            )?);
1014        }
1015        let mut out = Array1::<f64>::zeros(n);
1016        let log_w: Vec<f64> = self.weights.iter().map(|w| w.ln()).collect();
1017        for i in 0..n {
1018            let row = data.row(i);
1019            let mut log_terms = vec![f64::NEG_INFINITY; self.k];
1020            let mut max_term = f64::NEG_INFINITY;
1021            for j in 0..self.k {
1022                let lt = log_w[j] + comp[j].log_density(row);
1023                log_terms[j] = lt;
1024                if lt > max_term {
1025                    max_term = lt;
1026                }
1027            }
1028            out[i] = log_sum_exp(&log_terms, max_term);
1029        }
1030        Ok(out)
1031    }
1032
1033    /// Schwarz BIC approximation to negative log evidence, divided by two so
1034    /// it shares the ordinary negative-log-likelihood scale used by the shape
1035    /// race. Lower is better.
1036    pub fn bic(&self) -> f64 {
1037        -self.loglik + 0.5 * self.num_free_parameters() as f64 * (self.n_obs as f64).ln()
1038    }
1039}
1040
1041/// Cached per-component Gaussian evaluator: mean, precision `Σ⁻¹`, and the
1042/// log-normalizing constant `−½(d log 2π + log|Σ|)`.
1043#[derive(Debug, Clone)]
1044struct GaussianComponentEval {
1045    residual_origin: Array1<f64>,
1046    residual_scale: Array1<f64>,
1047    residual_normalized_offset: Array1<f64>,
1048    precision: Array2<f64>,
1049    log_norm: f64,
1050    d: usize,
1051}
1052
1053impl GaussianComponentEval {
1054    fn factor(mean: ArrayView1<'_, f64>, cov: &Array2<f64>) -> Result<Self, String> {
1055        let d = mean.len();
1056        if mean.iter().any(|value| !value.is_finite()) {
1057            return Err("mixture component mean must be finite".to_string());
1058        }
1059        if cov.nrows() != d || cov.ncols() != d {
1060            return Err(format!(
1061                "mixture component covariance must be {d}x{d}, got {}x{}",
1062                cov.nrows(),
1063                cov.ncols()
1064            ));
1065        }
1066        let (evals, evecs) = cov
1067            .eigh(Side::Lower)
1068            .map_err(|e| format!("mixture component covariance eigendecomposition failed: {e}"))?;
1069        let mut log_det = 0.0_f64;
1070        let mut inv_evals = Array1::<f64>::zeros(d);
1071        for (idx, &ev) in evals.iter().enumerate() {
1072            if !ev.is_finite() || ev <= 0.0 {
1073                return Err(format!(
1074                    "mixture component covariance is not SPD: eigenvalue {idx} is {ev:.3e}"
1075                ));
1076            }
1077            log_det += ev.ln();
1078            let inverse = ev.recip();
1079            if !inverse.is_finite() {
1080                return Err(format!(
1081                    "mixture component precision is not representable: eigenvalue {idx} is {ev:.3e}"
1082                ));
1083            }
1084            inv_evals[idx] = inverse;
1085        }
1086        // Σ⁻¹ = V diag(1/λ) Vᵀ.
1087        let mut precision = Array2::<f64>::zeros((d, d));
1088        for a in 0..d {
1089            for b in 0..d {
1090                let mut acc = 0.0_f64;
1091                for m in 0..d {
1092                    acc += evecs[[a, m]] * inv_evals[m] * evecs[[b, m]];
1093                }
1094                precision[[a, b]] = acc;
1095            }
1096        }
1097        let log_norm = -0.5 * (d as f64 * (2.0 * std::f64::consts::PI).ln() + log_det);
1098        if precision.iter().any(|value| !value.is_finite()) || !log_norm.is_finite() {
1099            return Err(
1100                "mixture component factorization produced non-finite precision or log normalizer"
1101                    .to_string(),
1102            );
1103        }
1104        Ok(Self {
1105            residual_origin: mean.to_owned(),
1106            residual_scale: Array1::zeros(d),
1107            residual_normalized_offset: Array1::zeros(d),
1108            precision,
1109            log_norm,
1110            d,
1111        })
1112    }
1113
1114    #[inline]
1115    fn log_density(&self, y: ArrayView1<'_, f64>) -> f64 {
1116        let residual = self.residual(y);
1117        let pv = self.precision_times_residual(&residual);
1118        let mut quad = 0.0_f64;
1119        for c in 0..self.d {
1120            quad += residual[c] * pv[c];
1121        }
1122        self.log_norm - 0.5 * quad
1123    }
1124
1125    #[inline]
1126    fn residual(&self, y: ArrayView1<'_, f64>) -> Vec<f64> {
1127        let mut residual = vec![0.0_f64; self.d];
1128        for axis in 0..self.d {
1129            residual[axis] = (-self.residual_normalized_offset[axis]).mul_add(
1130                self.residual_scale[axis],
1131                y[axis] - self.residual_origin[axis],
1132            );
1133        }
1134        residual
1135    }
1136
1137    /// `Σ⁻¹ (y − μ)`.
1138    #[inline]
1139    fn precision_times_residual(&self, residual: &[f64]) -> Vec<f64> {
1140        let mut out = vec![0.0_f64; self.d];
1141        for a in 0..self.d {
1142            let mut acc = 0.0_f64;
1143            for b in 0..self.d {
1144                acc += self.precision[[a, b]] * residual[b];
1145            }
1146            out[a] = acc;
1147        }
1148        out
1149    }
1150}
1151
1152#[inline]
1153fn log_sum_exp(terms: &[f64], max_term: f64) -> f64 {
1154    if !max_term.is_finite() {
1155        return f64::NEG_INFINITY;
1156    }
1157    let mut acc = 0.0_f64;
1158    for &t in terms {
1159        acc += (t - max_term).exp();
1160    }
1161    max_term + acc.ln()
1162}
1163
1164fn evidence_matrix_fingerprint(namespace: &str, values: ArrayView2<'_, f64>) -> Fingerprint {
1165    let mut hasher = Fingerprinter::new();
1166    hasher.write_str(namespace);
1167    hasher.write_usize(values.nrows());
1168    hasher.write_usize(values.ncols());
1169    // Hash logical row-major iteration order rather than the backing storage,
1170    // so an equivalent strided view resumes the same mathematical problem.
1171    for &value in values {
1172        hasher.write_f64(value);
1173    }
1174    hasher.finalize()
1175}
1176
1177fn mixture_data_fingerprint(data: ArrayView2<'_, f64>) -> Fingerprint {
1178    evidence_matrix_fingerprint("gaussian-mixture-em-v1", data)
1179}
1180
1181/// Fit a `k`-component full-covariance Gaussian mixture by deterministic
1182/// k-means++-style seeding (reusing the `terms::basis` farthest-point k-means,
1183/// a pure function of the data — no clock randomness) followed by EM to the
1184/// configured tolerance.
1185///
1186/// The fit is deterministic given `(data, k, config)`: the seed is the
1187/// farthest-point/k-means center selection, EM is a deterministic map, so
1188/// re-running yields the identical mixture.
1189pub fn fit_gaussian_mixture(
1190    data: ArrayView2<'_, f64>,
1191    k: usize,
1192    config: GaussianMixtureConfig,
1193) -> Result<GaussianMixtureFit, GaussianMixtureError> {
1194    validate_gaussian_mixture_problem(data, k, config)?;
1195    // Deterministic k-means++-style seeding via the shared basis k-means
1196    // (farthest-point init + Lloyd iterations).
1197    let means = gam_terms::basis::select_centers_by_strategy(
1198        data,
1199        &gam_terms::basis::CenterStrategy::KMeans {
1200            num_centers: k,
1201            max_iter: config.kmeans_max_iter,
1202        },
1203    )
1204    .map_err(|error| GaussianMixtureError::NumericalFailure {
1205        message: format!("deterministic k-means seeding failed: {error}"),
1206        checkpoint: None,
1207    })?;
1208    if means.nrows() != k || means.ncols() != data.ncols() {
1209        return Err(GaussianMixtureError::NumericalFailure {
1210            message: format!(
1211                "seeding returned {}x{} centers, expected {k}x{}",
1212                means.nrows(),
1213                means.ncols(),
1214                data.ncols()
1215            ),
1216            checkpoint: None,
1217        });
1218    }
1219    let global_covariance =
1220        constrained_data_covariance(data, config.covariance_floor).map_err(|message| {
1221            GaussianMixtureError::NumericalFailure {
1222                message,
1223                checkpoint: None,
1224            }
1225        })?;
1226    let weights = Array1::<f64>::from_elem(k, 1.0 / k as f64);
1227    let covariances = vec![global_covariance; k];
1228    let initial_e_step =
1229        mixture_e_step(data, &weights, &means, &covariances).map_err(|message| {
1230            GaussianMixtureError::NumericalFailure {
1231                message,
1232                checkpoint: None,
1233            }
1234        })?;
1235    let data_fingerprint = mixture_data_fingerprint(data);
1236    let checkpoint = GaussianMixtureCheckpoint {
1237        weights,
1238        means,
1239        covariances,
1240        mean_log_likelihood: initial_e_step.mean_log_likelihood,
1241        completed_iterations: 0,
1242        data_fingerprint,
1243        covariance_floor: config.covariance_floor,
1244    };
1245    run_gaussian_mixture_em(data, config, checkpoint)
1246}
1247
1248fn validate_gaussian_mixture_problem(
1249    data: ArrayView2<'_, f64>,
1250    k: usize,
1251    config: GaussianMixtureConfig,
1252) -> Result<(), GaussianMixtureError> {
1253    let n = data.nrows();
1254    let d = data.ncols();
1255    if k == 0 {
1256        return Err(GaussianMixtureError::InvalidInput {
1257            message: "k must be positive".to_string(),
1258        });
1259    }
1260    if d == 0 {
1261        return Err(GaussianMixtureError::InvalidInput {
1262            message: "at least one data column is required".to_string(),
1263        });
1264    }
1265    if k > n {
1266        return Err(GaussianMixtureError::InvalidInput {
1267            message: format!("requested {k} components but data has {n} rows"),
1268        });
1269    }
1270    if data.iter().any(|value| !value.is_finite()) {
1271        return Err(GaussianMixtureError::InvalidInput {
1272            message: "data must be finite".to_string(),
1273        });
1274    }
1275    if config.max_iter == 0 || config.kmeans_max_iter == 0 {
1276        return Err(GaussianMixtureError::InvalidInput {
1277            message: "max_iter and kmeans_max_iter must be positive".to_string(),
1278        });
1279    }
1280    let numerical_floor = f64::EPSILON.sqrt();
1281    if !config.loglik_tol.is_finite()
1282        || config.loglik_tol < numerical_floor
1283        || !config.parameter_tol.is_finite()
1284        || config.parameter_tol < numerical_floor
1285        || !config.covariance_floor.is_finite()
1286        || config.covariance_floor <= 0.0
1287    {
1288        return Err(GaussianMixtureError::InvalidInput {
1289            message: format!(
1290                "loglik_tol and parameter_tol must be finite and >= {numerical_floor:.3e}, and covariance_floor must be finite and positive"
1291            ),
1292        });
1293    }
1294    Ok(())
1295}
1296
1297fn validate_gaussian_mixture_checkpoint(
1298    data: ArrayView2<'_, f64>,
1299    covariance_floor: f64,
1300    checkpoint: &GaussianMixtureCheckpoint,
1301) -> Result<(), GaussianMixtureError> {
1302    let d = data.ncols();
1303    let k = checkpoint.weights.len();
1304    let mass = checkpoint.weights.sum();
1305    if k == 0
1306        || checkpoint.data_fingerprint != mixture_data_fingerprint(data)
1307        || checkpoint.covariance_floor.to_bits() != covariance_floor.to_bits()
1308        || checkpoint.means.dim() != (k, d)
1309        || checkpoint.covariances.len() != k
1310        || checkpoint
1311            .covariances
1312            .iter()
1313            .any(|covariance| covariance.dim() != (d, d))
1314        || checkpoint
1315            .weights
1316            .iter()
1317            .chain(checkpoint.means.iter())
1318            .chain(checkpoint.covariances.iter().flat_map(|value| value.iter()))
1319            .any(|value| !value.is_finite())
1320        || checkpoint.weights.iter().any(|value| *value <= 0.0)
1321        || !mass.is_finite()
1322        || (mass - 1.0).abs() > f64::EPSILON.sqrt()
1323        || !checkpoint.mean_log_likelihood.is_finite()
1324    {
1325        return Err(GaussianMixtureError::InvalidInput {
1326            message: "checkpoint problem identity, dimensions, interior parameters, likelihood, or simplex mass are invalid".to_string(),
1327        });
1328    }
1329    Ok(())
1330}
1331
1332fn run_gaussian_mixture_em(
1333    data: ArrayView2<'_, f64>,
1334    config: GaussianMixtureConfig,
1335    mut checkpoint: GaussianMixtureCheckpoint,
1336) -> Result<GaussianMixtureFit, GaussianMixtureError> {
1337    validate_gaussian_mixture_checkpoint(data, config.covariance_floor, &checkpoint)?;
1338    let k = checkpoint.weights.len();
1339    let d = data.ncols();
1340    let data_fingerprint = mixture_data_fingerprint(data);
1341
1342    // Certify the CURRENT checkpoint before accepting another EM update. The
1343    // inclusive bound permits exactly `max_iter` accepted updates and then one
1344    // final map evaluation at the resulting checkpoint. Consequently every
1345    // success and every exhaustion pairs its certificate with the exact same
1346    // parameter state; a certificate for theta_t can never be attached to
1347    // theta_{t+1} merely because the work boundary was reached.
1348    // The base cap means "give up when not progressing", not "interrupt provable
1349    // progress". `budget` therefore starts at `max_iter` and may be extended
1350    // ONCE, by the iterate's OWN projection, and only while the residual is
1351    // measurably contracting. See the gate at the bottom of the loop.
1352    let mut budget = config.max_iter;
1353    let mut extension: Option<usize> = None;
1354    // The parameter residual that PRICED the previous extension. A re-grant
1355    // requires the residual to have at least halved since then, on top of the
1356    // unchanged `rate < 1` evidence. Halving bounds the number of grants by
1357    // `ceil(log2(r_first / parameter_tol))` — finite, and read off the caller's
1358    // own tolerance rather than picked — so a slowly-contracting iterate still
1359    // terminates while a stalled one is refused at the first boundary exactly
1360    // as it is today. `INFINITY` makes the FIRST grant unconditional on this
1361    // clause, so the entry into an extension is byte-identical to before.
1362    let mut residual_at_last_grant = f64::INFINITY;
1363    let mut residual_window: std::collections::VecDeque<f64> =
1364        std::collections::VecDeque::with_capacity(EM_RATE_WINDOW + 1);
1365    let mut additional_updates = 0usize;
1366    loop {
1367        let current = mixture_e_step(
1368            data,
1369            &checkpoint.weights,
1370            &checkpoint.means,
1371            &checkpoint.covariances,
1372        )
1373        .map_err(|message| GaussianMixtureError::NumericalFailure {
1374            message,
1375            checkpoint: Some(checkpoint.clone()),
1376        })?;
1377        if (checkpoint.mean_log_likelihood - current.mean_log_likelihood).abs()
1378            > current.mean_log_likelihood_roundoff
1379        {
1380            return Err(GaussianMixtureError::InvalidInput {
1381                message: format!(
1382                    "checkpoint mean log likelihood {:.12e} disagrees with its parameters ({:.12e} +/- {:.3e})",
1383                    checkpoint.mean_log_likelihood,
1384                    current.mean_log_likelihood,
1385                    current.mean_log_likelihood_roundoff
1386                ),
1387            });
1388        }
1389        checkpoint.mean_log_likelihood = current.mean_log_likelihood;
1390
1391        let (next_weights, next_means, next_covariances) = mixture_m_step(
1392            data,
1393            current.responsibilities.view(),
1394            config.covariance_floor,
1395        )
1396        .map_err(|message| GaussianMixtureError::NumericalFailure {
1397            message,
1398            checkpoint: Some(checkpoint.clone()),
1399        })?;
1400        let next = mixture_e_step(data, &next_weights, &next_means, &next_covariances).map_err(
1401            |message| GaussianMixtureError::NumericalFailure {
1402                message,
1403                checkpoint: Some(checkpoint.clone()),
1404            },
1405        )?;
1406        let objective_scale = current
1407            .mean_log_likelihood
1408            .abs()
1409            .max(next.mean_log_likelihood.abs())
1410            .max(1.0);
1411        let objective_step = next.mean_log_likelihood - current.mean_log_likelihood;
1412        let objective_residual = objective_step.abs() / objective_scale;
1413        let parameter_residual = empirical_predictive_density_residual(
1414            &current.row_log_likelihoods,
1415            &next.row_log_likelihoods,
1416        )
1417        .map_err(|message| GaussianMixtureError::NumericalFailure {
1418            message,
1419            checkpoint: Some(checkpoint.clone()),
1420        })?;
1421        let monotonicity_uncertainty = gaussian_mixture_monotonicity_uncertainty(
1422            objective_scale,
1423            current.mean_log_likelihood_roundoff,
1424            next.mean_log_likelihood_roundoff,
1425        );
1426        residual_window.push_back(parameter_residual);
1427        if residual_window.len() > EM_RATE_WINDOW + 1 {
1428            residual_window.pop_front();
1429        }
1430        let contraction_rate = em_contraction_rate(&residual_window);
1431        let projected_iterations_to_tolerance = contraction_rate
1432            .and_then(|rate| em_projected_iterations(parameter_residual, config.parameter_tol, rate));
1433        let certificate = GaussianMixtureCertificate {
1434            mean_log_likelihood: current.mean_log_likelihood,
1435            mean_log_likelihood_gain: objective_step,
1436            monotonicity_uncertainty,
1437            objective_residual,
1438            objective_tolerance: config.loglik_tol,
1439            parameter_residual,
1440            parameter_tolerance: config.parameter_tol,
1441            contraction_rate,
1442            projected_iterations_to_tolerance,
1443        };
1444        if objective_step < -monotonicity_uncertainty {
1445            return Err(GaussianMixtureError::MonotonicityViolation {
1446                previous_mean_log_likelihood: current.mean_log_likelihood,
1447                next_mean_log_likelihood: next.mean_log_likelihood,
1448                numerical_uncertainty: monotonicity_uncertainty,
1449                checkpoint,
1450            });
1451        }
1452        if objective_residual <= config.loglik_tol && parameter_residual <= config.parameter_tol {
1453            let loglik = current.mean_log_likelihood * data.nrows() as f64;
1454            if !loglik.is_finite() {
1455                return Err(GaussianMixtureError::NumericalFailure {
1456                    message: "certified mean log likelihood overflows as a total likelihood"
1457                        .to_string(),
1458                    checkpoint: Some(checkpoint),
1459                });
1460            }
1461            return Ok(GaussianMixtureFit {
1462                weights: checkpoint.weights,
1463                means: checkpoint.means,
1464                covariances: checkpoint.covariances,
1465                k,
1466                d,
1467                n_obs: data.nrows(),
1468                loglik,
1469                iterations: checkpoint.completed_iterations,
1470                certificate,
1471            });
1472        }
1473        if additional_updates >= budget {
1474            // At the budget the question is NOT "have we run long enough?" but
1475            // "is this iterate stuck, or was it interrupted mid-descent?" — and
1476            // the residual window answers it. A rate at or above 1 is a genuine
1477            // stall and refuses exactly as before, now with the evidence
1478            // attached. A contracting rate earns ONE extension, bounded by the
1479            // iterate's own projection `N*`: if it cannot meet the deadline it
1480            // set for itself, that failure is the honest verdict, and the
1481            // certificate reports the rate and projection that priced it.
1482            // `extension.is_none()` used to gate this, which conflated "has
1483            // already been extended once" with "is not making progress". At the
1484            // second boundary the loop holds evidence IDENTICAL IN KIND to what
1485            // earned the first grant — a contracting rate and a finite
1486            // projection — and discarded it. The cost is not hypothetical: a
1487            // k=8 mixture rung reached the boundary with a parameter residual
1488            // of 1.495214e-8 against a tolerance of 1.490116e-8, over by 0.35%,
1489            // with its own projection reading ONE more iteration. Meeting the
1490            // grant's deadline to that precision means the trailing-window rate
1491            // estimate must be right to 0.0035/658 ≈ 5 ppm per step, from an
1492            // estimator whose documented log-jitter is `1/√W` = 12.5% (see
1493            // `EM_RATE_WINDOW`) — four orders of magnitude of mismatch between
1494            // what the policy demanded and what the measurement can deliver.
1495            //
1496            // The re-grant clause is therefore the residual's own progress, not
1497            // a counter: contracting AND at least halved since the last grant.
1498            // A rate at or above 1, or a residual that has not halved over a
1499            // whole extension, still refuses at exactly the same place.
1500            let extend = match (contraction_rate, projected_iterations_to_tolerance) {
1501                (Some(rate), Some(steps))
1502                    if rate < 1.0 && parameter_residual <= 0.5 * residual_at_last_grant =>
1503                {
1504                    Some(steps)
1505                }
1506                _ => None,
1507            };
1508            match extend {
1509                Some(steps) => {
1510                    // Hard secondary ceiling, derived from the caller's own
1511                    // budget rather than picked: the extension may not exceed
1512                    // the work already authorized. `max_iter` IS the caller's
1513                    // stated work tolerance, so spending at most that much
1514                    // again to finish a provably-converging descent is
1515                    // proportionate, while an iterate whose own projection
1516                    // exceeds it is not "nearly there" and its refusal is
1517                    // honest. Without this a rate of 0.9999 would project six
1518                    // figures of iterations and silently convert a refusal into
1519                    // a hang.
1520                    let steps = steps.min(config.max_iter);
1521                    budget = budget.saturating_add(steps);
1522                    extension = Some(steps);
1523                    residual_at_last_grant = parameter_residual;
1524                }
1525                None => {
1526                    return Err(GaussianMixtureError::DidNotConverge {
1527                        max_iterations: budget,
1528                        certificate,
1529                        checkpoint,
1530                    });
1531                }
1532            }
1533        } else if extension.is_some() && additional_updates.is_multiple_of(EM_RATE_WINDOW) {
1534            // Re-validate on the window cadence so a stall inside the extension
1535            // is caught within one window of appearing, not at the projection's
1536            // end. Progress that stops being progress ends the extension.
1537            if !matches!(contraction_rate, Some(rate) if rate < 1.0) {
1538                return Err(GaussianMixtureError::DidNotConverge {
1539                    max_iterations: budget,
1540                    certificate,
1541                    checkpoint,
1542                });
1543            }
1544        }
1545        checkpoint = GaussianMixtureCheckpoint {
1546            weights: next_weights,
1547            means: next_means,
1548            covariances: next_covariances,
1549            mean_log_likelihood: next.mean_log_likelihood,
1550            completed_iterations: checkpoint.completed_iterations + 1,
1551            data_fingerprint,
1552            covariance_floor: config.covariance_floor,
1553        };
1554        additional_updates += 1;
1555    }
1556}
1557
1558struct GaussianMixtureEStep {
1559    responsibilities: Array2<f64>,
1560    row_log_likelihoods: Vec<f64>,
1561    mean_log_likelihood: f64,
1562    mean_log_likelihood_roundoff: f64,
1563}
1564
1565/// Resolution of one observed EM likelihood comparison.
1566///
1567/// `pairwise_mean_with_roundoff` bounds only the final reduction of already
1568/// rounded row log likelihoods. An EM comparison also traverses covariance
1569/// eigendecompositions, precision quadratics, log-sum-exp, the M-step, and a
1570/// second E-step. Treating the reduction bound as a bound for that whole map
1571/// is false precision and turns cancellation at a stationary point into a
1572/// spurious monotonicity violation. The square root of machine epsilon is the
1573/// numerical resolution already required of every configured EM tolerance;
1574/// scaling it by the observed objective magnitude makes the invariant
1575/// independent of data units and of user-selected stopping knobs.
1576fn gaussian_mixture_monotonicity_uncertainty(
1577    objective_scale: f64,
1578    current_reduction_roundoff: f64,
1579    next_reduction_roundoff: f64,
1580) -> f64 {
1581    let reduction_roundoff = current_reduction_roundoff + next_reduction_roundoff;
1582    let composite_map_resolution = f64::EPSILON.sqrt() * objective_scale;
1583    reduction_roundoff.max(composite_map_resolution)
1584}
1585
1586fn pairwise_sum_max_depth(term_count: usize) -> usize {
1587    if term_count <= 1 {
1588        return 0;
1589    }
1590    let within_block = term_count.min(BASE_CHUNK) - 1;
1591    let blocks = term_count.div_ceil(BASE_CHUNK);
1592    let tree_levels = if blocks <= 1 {
1593        0
1594    } else {
1595        (usize::BITS - (blocks - 1).leading_zeros()) as usize
1596    };
1597    within_block.saturating_add(tree_levels)
1598}
1599
1600fn pairwise_mean_with_roundoff(values: &[f64]) -> Result<(f64, f64), String> {
1601    if values.is_empty() || values.iter().any(|value| !value.is_finite()) {
1602        return Err("mean log-likelihood terms must be nonempty and finite".to_string());
1603    }
1604    let sum = pairwise_sum(values);
1605    let magnitudes: Vec<f64> = values.iter().map(|value| value.abs()).collect();
1606    let magnitude_sum = pairwise_sum(&magnitudes);
1607    let unit_roundoff = 0.5 * f64::EPSILON;
1608    let accumulated = pairwise_sum_max_depth(values.len()) as f64 * unit_roundoff;
1609    let addition_bound = if accumulated < 1.0 {
1610        accumulated / (1.0 - accumulated) * magnitude_sum
1611    } else {
1612        f64::INFINITY
1613    };
1614    let count = values.len() as f64;
1615    let mean = sum / count;
1616    // The first term bounds the deterministic pairwise additions; the second
1617    // bounds the final division. This tolerance is derived from the actual
1618    // reduction depth and magnitudes, independently of the EM stopping knob.
1619    let roundoff = addition_bound / count + unit_roundoff * mean.abs();
1620    if !(mean.is_finite() && roundoff.is_finite()) {
1621        return Err("mean mixture log likelihood or its rounding bound is non-finite".to_string());
1622    }
1623    Ok((mean, roundoff))
1624}
1625
1626fn mixture_e_step(
1627    data: ArrayView2<'_, f64>,
1628    weights: &Array1<f64>,
1629    means: &Array2<f64>,
1630    covariances: &[Array2<f64>],
1631) -> Result<GaussianMixtureEStep, String> {
1632    let n = data.nrows();
1633    let k = weights.len();
1634    if weights
1635        .iter()
1636        .any(|weight| !weight.is_finite() || *weight <= 0.0)
1637    {
1638        return Err("mixture E-step requires strictly positive finite weights".to_string());
1639    }
1640    let mut components = Vec::with_capacity(k);
1641    for component in 0..k {
1642        components.push(GaussianComponentEval::factor(
1643            means.row(component),
1644            &covariances[component],
1645        )?);
1646    }
1647    let log_weights: Vec<f64> = weights.iter().map(|weight| weight.ln()).collect();
1648    let mut responsibilities = Array2::<f64>::zeros((n, k));
1649    let mut row_log_likelihoods = Vec::with_capacity(n);
1650    for row in 0..n {
1651        let observation = data.row(row);
1652        let mut log_terms = vec![f64::NEG_INFINITY; k];
1653        let mut max_term = f64::NEG_INFINITY;
1654        for component in 0..k {
1655            let term = log_weights[component] + components[component].log_density(observation);
1656            log_terms[component] = term;
1657            max_term = max_term.max(term);
1658        }
1659        let log_mixture = log_sum_exp(&log_terms, max_term);
1660        if !log_mixture.is_finite() {
1661            return Err(format!(
1662                "mixture density is non-finite at training row {row}"
1663            ));
1664        }
1665        row_log_likelihoods.push(log_mixture);
1666        for component in 0..k {
1667            responsibilities[[row, component]] = (log_terms[component] - log_mixture).exp();
1668        }
1669    }
1670    let (mean_log_likelihood, mean_log_likelihood_roundoff) =
1671        pairwise_mean_with_roundoff(&row_log_likelihoods)?;
1672    Ok(GaussianMixtureEStep {
1673        responsibilities,
1674        row_log_likelihoods,
1675        mean_log_likelihood,
1676        mean_log_likelihood_roundoff,
1677    })
1678}
1679
1680fn mixture_m_step(
1681    data: ArrayView2<'_, f64>,
1682    responsibilities: ArrayView2<'_, f64>,
1683    covariance_floor: f64,
1684) -> Result<(Array1<f64>, Array2<f64>, Vec<Array2<f64>>), String> {
1685    let n = data.nrows();
1686    let d = data.ncols();
1687    let k = responsibilities.ncols();
1688    let mut component_mass = Array1::<f64>::zeros(k);
1689    for component in 0..k {
1690        component_mass[component] = responsibilities.column(component).sum();
1691    }
1692    if component_mass
1693        .iter()
1694        .any(|mass| !mass.is_finite() || *mass <= 0.0)
1695    {
1696        return Err(
1697            "M-step reached a zero-mass component; the requested mixture order has no interior fitted density"
1698                .to_string(),
1699        );
1700    }
1701    let mut weights = component_mass.mapv(|mass| mass / n as f64);
1702    let total_weight = weights.sum();
1703    if !(total_weight.is_finite() && total_weight > 0.0) {
1704        return Err("M-step produced invalid mixture-weight mass".to_string());
1705    }
1706    weights.mapv_inplace(|weight| weight / total_weight);
1707    let mut means = Array2::<f64>::zeros((k, d));
1708    let mut covariances = Vec::with_capacity(k);
1709    for component in 0..k {
1710        let mass = component_mass[component];
1711        let mut mean = Array1::<f64>::zeros(d);
1712        for row in 0..n {
1713            let responsibility = responsibilities[[row, component]];
1714            for col in 0..d {
1715                mean[col] += responsibility * data[[row, col]];
1716            }
1717        }
1718        mean.mapv_inplace(|value| value / mass);
1719        means.row_mut(component).assign(&mean);
1720        let mut covariance = Array2::<f64>::zeros((d, d));
1721        for row in 0..n {
1722            let responsibility = responsibilities[[row, component]];
1723            for left in 0..d {
1724                let left_residual = data[[row, left]] - mean[left];
1725                for right in 0..d {
1726                    covariance[[left, right]] +=
1727                        responsibility * left_residual * (data[[row, right]] - mean[right]);
1728                }
1729            }
1730        }
1731        covariance.mapv_inplace(|value| value / mass);
1732        covariances.push(constrain_covariance(covariance, covariance_floor)?);
1733    }
1734    Ok((weights, means, covariances))
1735}
1736
1737fn relative_parameter_step(previous: f64, next: f64) -> f64 {
1738    (next - previous).abs() / previous.abs().max(next.abs()).max(1.0)
1739}
1740
1741/// Distance between two EM states in the quotient space the empirical
1742/// likelihood can identify.
1743///
1744/// A finite mixture density is invariant to component relabeling and to
1745/// exchanging mass among duplicate components. Ring center/radius/direction
1746/// tuples have additional factorizations of the same component means. No
1747/// component-coordinate norm can therefore be a necessary convergence
1748/// condition. The likelihood sees the vector `(log p(y_i))`; its max-norm
1749/// change is the exact empirical predictive-density residual. Taking the
1750/// maximum (rather than only the mean objective gain) detects row-wise changes
1751/// that cancel, while identical fitted densities have residual zero regardless
1752/// of their internal representation.
1753fn empirical_predictive_density_residual(
1754    previous_row_log_density: &[f64],
1755    next_row_log_density: &[f64],
1756) -> Result<f64, String> {
1757    if previous_row_log_density.is_empty()
1758        || previous_row_log_density.len() != next_row_log_density.len()
1759        || previous_row_log_density
1760            .iter()
1761            .chain(next_row_log_density)
1762            .any(|value| !value.is_finite())
1763    {
1764        return Err(
1765            "predictive-density residual requires equal, nonempty, finite log-density vectors"
1766                .to_string(),
1767        );
1768    }
1769    Ok(previous_row_log_density
1770        .iter()
1771        .zip(next_row_log_density)
1772        .map(|(&previous, &next)| (next - previous).abs())
1773        .fold(0.0_f64, f64::max))
1774}
1775
1776fn constrain_covariance(covariance: Array2<f64>, floor: f64) -> Result<Array2<f64>, String> {
1777    let (eigenvalues, eigenvectors) = covariance
1778        .eigh(Side::Lower)
1779        .map_err(|error| format!("covariance eigendecomposition failed: {error}"))?;
1780    let d = covariance.nrows();
1781    let mut constrained = Array2::<f64>::zeros((d, d));
1782    for row in 0..d {
1783        for col in 0..d {
1784            let mut value = 0.0_f64;
1785            for index in 0..d {
1786                value += eigenvectors[[row, index]]
1787                    * eigenvalues[index].max(floor)
1788                    * eigenvectors[[col, index]];
1789            }
1790            constrained[[row, col]] = value;
1791        }
1792    }
1793    if constrained.iter().any(|value| !value.is_finite()) {
1794        return Err("constrained covariance became non-finite".to_string());
1795    }
1796    Ok(constrained)
1797}
1798
1799/// Global constrained covariance used to seed EM.
1800fn constrained_data_covariance(
1801    data: ArrayView2<'_, f64>,
1802    floor: f64,
1803) -> Result<Array2<f64>, String> {
1804    let n = data.nrows();
1805    let d = data.ncols();
1806    let mut mean = Array1::<f64>::zeros(d);
1807    for i in 0..n {
1808        for c in 0..d {
1809            mean[c] += data[[i, c]];
1810        }
1811    }
1812    mean.mapv_inplace(|v| v / n.max(1) as f64);
1813    let mut cov = Array2::<f64>::zeros((d, d));
1814    for i in 0..n {
1815        for a in 0..d {
1816            let da = data[[i, a]] - mean[a];
1817            for b in 0..d {
1818                cov[[a, b]] += da * (data[[i, b]] - mean[b]);
1819            }
1820        }
1821    }
1822    let inv = 1.0 / n as f64;
1823    cov.mapv_inplace(|v| v * inv);
1824    constrain_covariance(cov, floor)
1825}
1826
1827// ---------------------------------------------------------------------------
1828// Ring-of-clusters candidate (#2262)
1829// ---------------------------------------------------------------------------
1830//
1831// A free Gaussian mixture treats the component means as unrelated points. That
1832// is the wrong null for a discrete cyclic concept: weekdays and months form
1833// tight clusters, but their component means share a low-dimensional circular
1834// constraint. `RingGaussianMixtureFit` models exactly that density,
1835//
1836//     x | z=j ~ N(c + r u_j, sigma^2 I_2),  ||u_j|| = 1,
1837//
1838// with free mixture weights, a shared center/radius, one angle per component,
1839// and a shared isotropic variance. Its `2k + 3` continuous parameters are
1840// priced by the same BIC-form criterion as the unconstrained mixture's `6k - 1`
1841// parameters in two dimensions.
1842
1843/// Certified Gaussian mixture whose component centers lie on one fitted circle.
1844#[derive(Debug, Clone)]
1845pub struct RingGaussianMixtureFit {
1846    weights: Array1<f64>,
1847    center: Array1<f64>,
1848    radius: f64,
1849    directions: Array2<f64>,
1850    variance: f64,
1851    k: usize,
1852    n_obs: usize,
1853    loglik: f64,
1854    iterations: usize,
1855    certificate: GaussianMixtureCertificate,
1856}
1857
1858impl RingGaussianMixtureFit {
1859    pub fn weights(&self) -> ArrayView1<'_, f64> {
1860        self.weights.view()
1861    }
1862
1863    pub fn center(&self) -> ArrayView1<'_, f64> {
1864        self.center.view()
1865    }
1866
1867    pub fn radius(&self) -> f64 {
1868        self.radius
1869    }
1870
1871    pub fn directions(&self) -> ArrayView2<'_, f64> {
1872        self.directions.view()
1873    }
1874
1875    pub fn variance(&self) -> f64 {
1876        self.variance
1877    }
1878
1879    pub fn iterations(&self) -> usize {
1880        self.iterations
1881    }
1882
1883    pub fn certificate(&self) -> GaussianMixtureCertificate {
1884        self.certificate
1885    }
1886
1887    /// Free parameters: `k-1` weight logits, center(2), radius(1), `k`
1888    /// component angles, and shared log standard deviation(1).
1889    pub fn num_free_parameters(&self) -> usize {
1890        2 * self.k + 3
1891    }
1892
1893    pub fn per_point_log_density(&self, data: ArrayView2<'_, f64>) -> Result<Array1<f64>, String> {
1894        if data.ncols() != 2 {
1895            return Err(format!(
1896                "ring-of-clusters density expects two columns, got {}",
1897                data.ncols()
1898            ));
1899        }
1900        ring_mixture_log_density(
1901            data,
1902            &self.weights,
1903            &self.center,
1904            self.radius,
1905            &self.directions,
1906            self.variance,
1907        )
1908    }
1909
1910    /// Schwarz BIC approximation to negative log evidence, divided by two so
1911    /// it is on the ordinary negative-log-likelihood scale. Lower is better.
1912    pub fn bic(&self) -> f64 {
1913        -self.loglik + 0.5 * self.num_free_parameters() as f64 * (self.n_obs as f64).ln()
1914    }
1915}
1916
1917#[derive(Debug, Clone)]
1918struct RingMixtureState {
1919    weights: Array1<f64>,
1920    center: Array1<f64>,
1921    radius: f64,
1922    directions: Array2<f64>,
1923    variance: f64,
1924    mean_log_likelihood: f64,
1925    completed_iterations: usize,
1926}
1927
1928fn ring_component_means(
1929    center: &Array1<f64>,
1930    radius: f64,
1931    directions: &Array2<f64>,
1932) -> Array2<f64> {
1933    let mut means = Array2::<f64>::zeros((directions.nrows(), 2));
1934    for component in 0..directions.nrows() {
1935        means[[component, 0]] = center[0] + radius * directions[[component, 0]];
1936        means[[component, 1]] = center[1] + radius * directions[[component, 1]];
1937    }
1938    means
1939}
1940
1941fn ring_mixture_log_terms(
1942    data: ArrayView2<'_, f64>,
1943    weights: &Array1<f64>,
1944    center: &Array1<f64>,
1945    radius: f64,
1946    directions: &Array2<f64>,
1947    variance: f64,
1948) -> Result<(Array2<f64>, Vec<f64>), String> {
1949    if data.ncols() != 2
1950        || center.len() != 2
1951        || directions.ncols() != 2
1952        || directions.nrows() != weights.len()
1953        || weights
1954            .iter()
1955            .any(|weight| !weight.is_finite() || *weight <= 0.0)
1956        || !(radius.is_finite() && radius > 0.0)
1957        || !(variance.is_finite() && variance > 0.0)
1958    {
1959        return Err("invalid ring-of-clusters parameter state".to_string());
1960    }
1961    let means = ring_component_means(center, radius, directions);
1962    let log_normalizer = -(std::f64::consts::TAU).ln() - variance.ln();
1963    let mut terms = Array2::<f64>::zeros((data.nrows(), weights.len()));
1964    let mut row_log_likelihoods = Vec::with_capacity(data.nrows());
1965    for row in 0..data.nrows() {
1966        let mut max_term = f64::NEG_INFINITY;
1967        for component in 0..weights.len() {
1968            let dx = data[[row, 0]] - means[[component, 0]];
1969            let dy = data[[row, 1]] - means[[component, 1]];
1970            let term =
1971                weights[component].ln() + log_normalizer - 0.5 * (dx * dx + dy * dy) / variance;
1972            terms[[row, component]] = term;
1973            max_term = max_term.max(term);
1974        }
1975        let values = terms.row(row).to_vec();
1976        let log_likelihood = log_sum_exp(&values, max_term);
1977        if !log_likelihood.is_finite() {
1978            return Err(format!(
1979                "ring-of-clusters density is non-finite at training row {row}"
1980            ));
1981        }
1982        row_log_likelihoods.push(log_likelihood);
1983    }
1984    Ok((terms, row_log_likelihoods))
1985}
1986
1987fn ring_mixture_e_step(
1988    data: ArrayView2<'_, f64>,
1989    state: &RingMixtureState,
1990) -> Result<GaussianMixtureEStep, String> {
1991    let (terms, row_log_likelihoods) = ring_mixture_log_terms(
1992        data,
1993        &state.weights,
1994        &state.center,
1995        state.radius,
1996        &state.directions,
1997        state.variance,
1998    )?;
1999    let mut responsibilities = Array2::<f64>::zeros(terms.raw_dim());
2000    for row in 0..terms.nrows() {
2001        for component in 0..terms.ncols() {
2002            responsibilities[[row, component]] =
2003                (terms[[row, component]] - row_log_likelihoods[row]).exp();
2004        }
2005    }
2006    let (mean_log_likelihood, mean_log_likelihood_roundoff) =
2007        pairwise_mean_with_roundoff(&row_log_likelihoods)?;
2008    Ok(GaussianMixtureEStep {
2009        responsibilities,
2010        row_log_likelihoods,
2011        mean_log_likelihood,
2012        mean_log_likelihood_roundoff,
2013    })
2014}
2015
2016fn ring_mixture_log_density(
2017    data: ArrayView2<'_, f64>,
2018    weights: &Array1<f64>,
2019    center: &Array1<f64>,
2020    radius: f64,
2021    directions: &Array2<f64>,
2022    variance: f64,
2023) -> Result<Array1<f64>, String> {
2024    let (_, row_log_likelihoods) =
2025        ring_mixture_log_terms(data, weights, center, radius, directions, variance)?;
2026    Ok(Array1::from_vec(row_log_likelihoods))
2027}
2028
2029fn fit_weighted_component_circle(
2030    component_means: &Array2<f64>,
2031    component_mass: &Array1<f64>,
2032    initial_center: &Array1<f64>,
2033    initial_radius: f64,
2034    parameter_tol: f64,
2035    max_iter: usize,
2036) -> Result<(Array1<f64>, f64, Array2<f64>), String> {
2037    let k = component_means.nrows();
2038    let total_mass = component_mass.sum();
2039    if component_means.ncols() != 2
2040        || component_mass.len() != k
2041        || component_mass
2042            .iter()
2043            .any(|mass| !mass.is_finite() || *mass <= 0.0)
2044        || !(total_mass.is_finite() && total_mass > 0.0)
2045    {
2046        return Err("ring M-step requires positive component masses and 2-D means".to_string());
2047    }
2048    let mut center = initial_center.clone();
2049    let mut radius = initial_radius;
2050    let mut directions = Array2::<f64>::zeros((k, 2));
2051    for _ in 0..max_iter {
2052        for component in 0..k {
2053            let dx = component_means[[component, 0]] - center[0];
2054            let dy = component_means[[component, 1]] - center[1];
2055            let norm = dx.hypot(dy);
2056            if !(norm.is_finite() && norm > 0.0) {
2057                return Err(
2058                    "ring M-step reached a component centroid at the circle center; its angle is unidentified"
2059                        .to_string(),
2060                );
2061            }
2062            directions[[component, 0]] = dx / norm;
2063            directions[[component, 1]] = dy / norm;
2064        }
2065
2066        let mut mean_point = Array1::<f64>::zeros(2);
2067        let mut mean_direction = Array1::<f64>::zeros(2);
2068        for component in 0..k {
2069            let weight = component_mass[component] / total_mass;
2070            for axis in 0..2 {
2071                mean_point[axis] += weight * component_means[[component, axis]];
2072                mean_direction[axis] += weight * directions[[component, axis]];
2073            }
2074        }
2075        let mut numerator = 0.0;
2076        let mut denominator = 0.0;
2077        for component in 0..k {
2078            let mass = component_mass[component];
2079            let dux = directions[[component, 0]] - mean_direction[0];
2080            let duy = directions[[component, 1]] - mean_direction[1];
2081            numerator += mass
2082                * (dux * (component_means[[component, 0]] - mean_point[0])
2083                    + duy * (component_means[[component, 1]] - mean_point[1]));
2084            denominator += mass * (dux * dux + duy * duy);
2085        }
2086        if !(denominator.is_finite() && denominator > 0.0) {
2087            return Err(
2088                "ring M-step component directions are identical; radius and center are unidentified"
2089                    .to_string(),
2090            );
2091        }
2092        let mut next_radius = numerator / denominator;
2093        if !next_radius.is_finite() || next_radius == 0.0 {
2094            return Err("ring M-step produced an unidentified zero radius".to_string());
2095        }
2096        if next_radius < 0.0 {
2097            next_radius = -next_radius;
2098            directions.mapv_inplace(|value| -value);
2099        }
2100        let next_center = Array1::from_vec(vec![
2101            mean_point[0] - next_radius * mean_direction[0],
2102            mean_point[1] - next_radius * mean_direction[1],
2103        ]);
2104        let residual = center
2105            .iter()
2106            .zip(next_center.iter())
2107            .map(|(&left, &right)| relative_parameter_step(left, right))
2108            .chain(std::iter::once(relative_parameter_step(
2109                radius,
2110                next_radius,
2111            )))
2112            .fold(0.0, f64::max);
2113        center = next_center;
2114        radius = next_radius;
2115        if residual <= parameter_tol {
2116            // Recompute directions at the returned center so the stored angles
2117            // are the exact angular block update belonging to that center.
2118            for component in 0..k {
2119                let dx = component_means[[component, 0]] - center[0];
2120                let dy = component_means[[component, 1]] - center[1];
2121                let norm = dx.hypot(dy);
2122                if !(norm.is_finite() && norm > 0.0) {
2123                    return Err("ring M-step terminal component angle is unidentified".to_string());
2124                }
2125                directions[[component, 0]] = dx / norm;
2126                directions[[component, 1]] = dy / norm;
2127            }
2128            return Ok((center, radius, directions));
2129        }
2130    }
2131    Err(format!(
2132        "ring M-step did not certify its constrained center/radius fixed point after {max_iter} iterations"
2133    ))
2134}
2135
2136fn ring_mixture_m_step(
2137    data: ArrayView2<'_, f64>,
2138    responsibilities: ArrayView2<'_, f64>,
2139    previous: &RingMixtureState,
2140    config: GaussianMixtureConfig,
2141) -> Result<RingMixtureState, String> {
2142    let n = data.nrows();
2143    let k = responsibilities.ncols();
2144    let mut component_mass = Array1::<f64>::zeros(k);
2145    let mut component_means = Array2::<f64>::zeros((k, 2));
2146    for component in 0..k {
2147        let mass = responsibilities.column(component).sum();
2148        if !(mass.is_finite() && mass > 0.0) {
2149            return Err(
2150                "ring M-step reached a zero-mass component; the requested order is singular"
2151                    .to_string(),
2152            );
2153        }
2154        component_mass[component] = mass;
2155        for row in 0..n {
2156            for axis in 0..2 {
2157                component_means[[component, axis]] +=
2158                    responsibilities[[row, component]] * data[[row, axis]];
2159            }
2160        }
2161        for axis in 0..2 {
2162            component_means[[component, axis]] /= mass;
2163        }
2164    }
2165    let mut weights = component_mass.mapv(|mass| mass / n as f64);
2166    let weight_sum = weights.sum();
2167    weights.mapv_inplace(|weight| weight / weight_sum);
2168    let (center, radius, directions) = fit_weighted_component_circle(
2169        &component_means,
2170        &component_mass,
2171        &previous.center,
2172        previous.radius,
2173        config.parameter_tol,
2174        config.max_iter,
2175    )?;
2176    let means = ring_component_means(&center, radius, &directions);
2177    let mut expected_squared_error = 0.0;
2178    for row in 0..n {
2179        for component in 0..k {
2180            let dx = data[[row, 0]] - means[[component, 0]];
2181            let dy = data[[row, 1]] - means[[component, 1]];
2182            expected_squared_error += responsibilities[[row, component]] * (dx * dx + dy * dy);
2183        }
2184    }
2185    let variance = (expected_squared_error / (2 * n) as f64).max(config.covariance_floor);
2186    if !variance.is_finite() {
2187        return Err("ring M-step produced non-finite shared variance".to_string());
2188    }
2189    Ok(RingMixtureState {
2190        weights,
2191        center,
2192        radius,
2193        directions,
2194        variance,
2195        mean_log_likelihood: f64::NAN,
2196        completed_iterations: previous.completed_iterations + 1,
2197    })
2198}
2199
2200/// Fit a deterministic, certified `k`-component isotropic Gaussian mixture
2201/// whose component centers are constrained to a common circle.
2202pub fn fit_ring_gaussian_mixture(
2203    data: ArrayView2<'_, f64>,
2204    k: usize,
2205    config: GaussianMixtureConfig,
2206) -> Result<RingGaussianMixtureFit, String> {
2207    validate_gaussian_mixture_problem(data, k, config).map_err(|error| error.to_string())?;
2208    if data.ncols() != 2 {
2209        return Err(format!(
2210            "ring-of-clusters fitting requires exactly two columns, got {}",
2211            data.ncols()
2212        ));
2213    }
2214    if k < 3 {
2215        return Err(format!(
2216            "ring-of-clusters fitting requires at least three component centers, got {k}"
2217        ));
2218    }
2219    let seeded_means = gam_terms::basis::select_centers_by_strategy(
2220        data,
2221        &gam_terms::basis::CenterStrategy::KMeans {
2222            num_centers: k,
2223            max_iter: config.kmeans_max_iter,
2224        },
2225    )
2226    .map_err(|error| format!("ring-of-clusters deterministic seeding failed: {error}"))?;
2227    let component_mass = Array1::<f64>::ones(k);
2228    let mut initial_center = Array1::<f64>::zeros(2);
2229    for component in 0..k {
2230        initial_center[0] += seeded_means[[component, 0]] / k as f64;
2231        initial_center[1] += seeded_means[[component, 1]] / k as f64;
2232    }
2233    let mut initial_radius = 0.0;
2234    for component in 0..k {
2235        initial_radius += (seeded_means[[component, 0]] - initial_center[0])
2236            .hypot(seeded_means[[component, 1]] - initial_center[1])
2237            / k as f64;
2238    }
2239    if !(initial_radius.is_finite() && initial_radius > 0.0) {
2240        return Err("ring-of-clusters seed has an unidentified zero radius".to_string());
2241    }
2242    let (center, radius, directions) = fit_weighted_component_circle(
2243        &seeded_means,
2244        &component_mass,
2245        &initial_center,
2246        initial_radius,
2247        config.parameter_tol,
2248        config.max_iter,
2249    )?;
2250    let means = ring_component_means(&center, radius, &directions);
2251    let mut squared_error = 0.0;
2252    for row in 0..data.nrows() {
2253        let mut nearest = f64::INFINITY;
2254        for component in 0..k {
2255            let dx = data[[row, 0]] - means[[component, 0]];
2256            let dy = data[[row, 1]] - means[[component, 1]];
2257            nearest = nearest.min(dx * dx + dy * dy);
2258        }
2259        squared_error += nearest;
2260    }
2261    let variance = (squared_error / (2 * data.nrows()) as f64).max(config.covariance_floor);
2262    let mut state = RingMixtureState {
2263        weights: Array1::from_elem(k, 1.0 / k as f64),
2264        center,
2265        radius,
2266        directions,
2267        variance,
2268        mean_log_likelihood: f64::NAN,
2269        completed_iterations: 0,
2270    };
2271    for additional_updates in 0..=config.max_iter {
2272        let current = ring_mixture_e_step(data, &state)?;
2273        state.mean_log_likelihood = current.mean_log_likelihood;
2274        let mut next =
2275            ring_mixture_m_step(data, current.responsibilities.view(), &state, config)?;
2276        let next_e_step = ring_mixture_e_step(data, &next)?;
2277        next.mean_log_likelihood = next_e_step.mean_log_likelihood;
2278        let current_mean = current.mean_log_likelihood;
2279        let next_mean = next_e_step.mean_log_likelihood;
2280        let objective_scale = current_mean.abs().max(next_mean.abs()).max(1.0);
2281        let objective_step = next_mean - current_mean;
2282        let objective_residual = objective_step.abs() / objective_scale;
2283        let parameter_residual = empirical_predictive_density_residual(
2284            &current.row_log_likelihoods,
2285            &next_e_step.row_log_likelihoods,
2286        )?;
2287        let monotonicity_uncertainty = gaussian_mixture_monotonicity_uncertainty(
2288            objective_scale,
2289            current.mean_log_likelihood_roundoff,
2290            next_e_step.mean_log_likelihood_roundoff,
2291        );
2292        let certificate = GaussianMixtureCertificate {
2293            mean_log_likelihood: current_mean,
2294            mean_log_likelihood_gain: objective_step,
2295            monotonicity_uncertainty,
2296            objective_residual,
2297            objective_tolerance: config.loglik_tol,
2298            parameter_residual,
2299            parameter_tolerance: config.parameter_tol,
2300            // The ring-of-clusters rung does not yet measure a contraction rate,
2301            // so its exhaustion stays un-priced. Reporting `None` says exactly
2302            // that; fabricating a rate here would be the invention the rest of
2303            // this certificate exists to prevent.
2304            contraction_rate: None,
2305            projected_iterations_to_tolerance: None,
2306        };
2307        if objective_step < -monotonicity_uncertainty {
2308            return Err(format!(
2309                "ring-of-clusters generalized EM violated monotone ascent at iteration {}: {current_mean:.12e} -> {next_mean:.12e} (comparison uncertainty {monotonicity_uncertainty:.3e})",
2310                state.completed_iterations
2311            ));
2312        }
2313        if objective_residual <= config.loglik_tol && parameter_residual <= config.parameter_tol {
2314            let loglik = current_mean * data.nrows() as f64;
2315            if !loglik.is_finite() {
2316                return Err("ring-of-clusters total log likelihood overflowed".to_string());
2317            }
2318            return Ok(RingGaussianMixtureFit {
2319                weights: state.weights,
2320                center: state.center,
2321                radius: state.radius,
2322                directions: state.directions,
2323                variance: state.variance,
2324                k,
2325                n_obs: data.nrows(),
2326                loglik,
2327                iterations: state.completed_iterations,
2328                certificate,
2329            });
2330        }
2331        if additional_updates == config.max_iter {
2332            return Err(format!(
2333                "ring-of-clusters generalized EM did not certify after {} iterations: objective residual {:.6e}/{:.3e}, parameter-map residual {:.6e}/{:.3e}",
2334                config.max_iter,
2335                objective_residual,
2336                config.loglik_tol,
2337                parameter_residual,
2338                config.parameter_tol,
2339            ));
2340        }
2341        state = next;
2342    }
2343    Err("ring-of-clusters generalized EM exhausted without a terminal certificate".to_string())
2344}
2345
2346// ---------------------------------------------------------------------------
2347// Circular Gaussian density and structured-union candidates (#907)
2348// ---------------------------------------------------------------------------
2349
2350/// Maximum-likelihood fit of a Gaussian-blurred circle in two dimensions.
2351///
2352/// The generative model is
2353///
2354/// `X = center + radius * U + epsilon`,
2355///
2356/// where `U` is uniform on the unit circle and
2357/// `epsilon ~ N(0, noise_variance * I_2)`. Integrating out `U` gives the proper
2358/// Cartesian density
2359///
2360/// `p(x) = exp(-(r^2 + R^2)/(2s)) I0(Rr/s) / (2 pi s)`.
2361///
2362/// Unlike a Gaussian density assigned directly to the nonnegative radius, this
2363/// density is normalized on the plane, remains finite at the center, and has no
2364/// artificial `1/r` singularity. The center is fitted jointly with `(R, s)` by
2365/// latent-angle EM instead of being frozen at the coordinate mean.
2366#[derive(Debug, Clone, Copy)]
2367pub struct CircularGaussianFit2d {
2368    center: [f64; 2],
2369    radius: f64,
2370    noise_variance: f64,
2371}
2372
2373impl CircularGaussianFit2d {
2374    /// Two center coordinates, one radius, and one isotropic noise variance.
2375    pub const NUM_FREE_PARAMETERS: usize = 4;
2376
2377    /// Construct a circular Gaussian from validated model parameters.
2378    pub fn from_parameters(
2379        center: [f64; 2],
2380        radius: f64,
2381        noise_variance: f64,
2382    ) -> Result<Self, String> {
2383        if !center.iter().all(|value| value.is_finite()) {
2384            return Err("circular Gaussian center must be finite".to_string());
2385        }
2386        if !(radius.is_finite() && radius >= 0.0) {
2387            return Err("circular Gaussian radius must be finite and nonnegative".to_string());
2388        }
2389        if !(noise_variance.is_finite() && noise_variance > 0.0) {
2390            return Err("circular Gaussian noise variance must be finite and positive".to_string());
2391        }
2392        Ok(Self {
2393            center,
2394            radius,
2395            noise_variance,
2396        })
2397    }
2398
2399    /// Fit selected rows of a finite two-column coordinate matrix.
2400    pub fn fit(coords: ArrayView2<'_, f64>, rows: &[usize]) -> Result<Self, String> {
2401        if coords.ncols() != 2 {
2402            return Err(format!(
2403                "circular Gaussian requires 2-D data, got {} columns",
2404                coords.ncols()
2405            ));
2406        }
2407        if rows.is_empty() {
2408            return Err("circular Gaussian requires a nonempty training set".to_string());
2409        }
2410        if rows.iter().any(|&row| row >= coords.nrows()) {
2411            return Err("circular Gaussian row index is out of bounds".to_string());
2412        }
2413        if rows
2414            .iter()
2415            .any(|&row| !coords[[row, 0]].is_finite() || !coords[[row, 1]].is_finite())
2416        {
2417            return Err("circular Gaussian requires finite training coordinates".to_string());
2418        }
2419
2420        // Work in a dimensionless chart relative to one observed point. This
2421        // preserves the low-order bits of a small translated circle and makes
2422        // the stopping rule and variance floor scale equivariant.
2423        let anchor_row = rows[0];
2424        let anchor = [coords[[anchor_row, 0]], coords[[anchor_row, 1]]];
2425        let mut scale = 0.0_f64;
2426        for &row in rows {
2427            let dx = coords[[row, 0]] - anchor[0];
2428            let dy = coords[[row, 1]] - anchor[1];
2429            if !(dx.is_finite() && dy.is_finite()) {
2430                return Err("circular Gaussian coordinate range exceeds f64".to_string());
2431            }
2432            scale = scale.max(dx.hypot(dy));
2433        }
2434        if !(scale.is_finite() && scale > 0.0) {
2435            return Err("circular Gaussian requires nonzero spatial extent".to_string());
2436        }
2437
2438        let mut points = Vec::with_capacity(rows.len());
2439        let mut mean = [0.0_f64; 2];
2440        for &row in rows {
2441            let point = [
2442                (coords[[row, 0]] - anchor[0]) / scale,
2443                (coords[[row, 1]] - anchor[1]) / scale,
2444            ];
2445            points.push(point);
2446            mean[0] += point[0];
2447            mean[1] += point[1];
2448        }
2449        let count = rows.len() as f64;
2450        mean[0] /= count;
2451        mean[1] /= count;
2452
2453        // Moment initialization is exact at the population level. For
2454        // q = ||X-E X||^2,
2455        //   E[q] = R^2 + 2s,  Var(q) = 4s(R^2+s),
2456        // hence R^4 = E[q]^2-Var(q) and s=(E[q]-R^2)/2.
2457        let mut squared_radii = Vec::with_capacity(rows.len());
2458        let mut mean_squared_radius = 0.0_f64;
2459        for point in &points {
2460            let dx = point[0] - mean[0];
2461            let dy = point[1] - mean[1];
2462            let squared_radius = dx * dx + dy * dy;
2463            squared_radii.push(squared_radius);
2464            mean_squared_radius += squared_radius;
2465        }
2466        mean_squared_radius /= count;
2467        let mut squared_radius_variance = 0.0_f64;
2468        for squared_radius in squared_radii {
2469            squared_radius_variance += (squared_radius - mean_squared_radius).powi(2);
2470        }
2471        squared_radius_variance /= count;
2472
2473        // A noiseless observed circle is an unbounded-likelihood boundary.
2474        // Keep the numerical optimizer in a scale-relative interior whose
2475        // width is roundoff, rather than imposing a floor in data units.
2476        let variance_floor = (64.0 * f64::EPSILON * mean_squared_radius).max(f64::MIN_POSITIVE);
2477        let radius_squared = (mean_squared_radius * mean_squared_radius - squared_radius_variance)
2478            .max(0.0)
2479            .sqrt();
2480        let mut radius = radius_squared.sqrt();
2481        let mut noise_variance = (0.5 * (mean_squared_radius - radius_squared)).max(variance_floor);
2482        let mut center = mean;
2483
2484        // Exact EM for the latent circle angle. Given current parameters, the
2485        // conditional mean of U is A(kappa) * (x-c)/||x-c|| with
2486        // A=I1/I0 and kappa=R||x-c||/s. Solving the joint quadratic M-step for
2487        // center and radius avoids the biased `center = sample mean` plug-in.
2488        const MAX_EM_ITERATIONS: usize = 4096;
2489        const EM_TOLERANCE: f64 = 2.0e-12;
2490        let mut posterior_means = vec![[0.0_f64; 2]; points.len()];
2491        let mut converged = false;
2492        for _ in 0..MAX_EM_ITERATIONS {
2493            let mut posterior_mean = [0.0_f64; 2];
2494            for (point, latent_mean) in points.iter().zip(&mut posterior_means) {
2495                let dx = point[0] - center[0];
2496                let dy = point[1] - center[1];
2497                let observed_radius = dx.hypot(dy);
2498                if observed_radius == 0.0 || radius == 0.0 {
2499                    *latent_mean = [0.0, 0.0];
2500                } else {
2501                    let (_, bessel_ratio) =
2502                        circular_gaussian_bessel_terms(radius, observed_radius, noise_variance);
2503                    if !(bessel_ratio.is_finite() && (0.0..=1.0).contains(&bessel_ratio)) {
2504                        return Err("circular Gaussian Bessel ratio left [0, 1]".to_string());
2505                    }
2506                    let multiplier = bessel_ratio / observed_radius;
2507                    *latent_mean = [multiplier * dx, multiplier * dy];
2508                }
2509                posterior_mean[0] += latent_mean[0];
2510                posterior_mean[1] += latent_mean[1];
2511            }
2512            posterior_mean[0] /= count;
2513            posterior_mean[1] /= count;
2514
2515            let denominator =
2516                1.0 - posterior_mean[0] * posterior_mean[0] - posterior_mean[1] * posterior_mean[1];
2517            if !(denominator.is_finite() && denominator > 0.0) {
2518                return Err("circular Gaussian EM radius update is singular".to_string());
2519            }
2520            let mut radius_numerator = 0.0_f64;
2521            for (point, latent_mean) in points.iter().zip(&posterior_means) {
2522                radius_numerator +=
2523                    latent_mean[0] * (point[0] - mean[0]) + latent_mean[1] * (point[1] - mean[1]);
2524            }
2525            let next_radius = (radius_numerator / (count * denominator)).max(0.0);
2526            let next_center = [
2527                mean[0] - next_radius * posterior_mean[0],
2528                mean[1] - next_radius * posterior_mean[1],
2529            ];
2530
2531            // Evaluate E||X-c-RU||^2 in an explicitly nonnegative form to
2532            // avoid catastrophic cancellation on a very thin ring.
2533            let mut residual_sum = 0.0_f64;
2534            for (point, latent_mean) in points.iter().zip(&posterior_means) {
2535                let dx = point[0] - next_center[0];
2536                let dy = point[1] - next_center[1];
2537                let ex = dx - next_radius * latent_mean[0];
2538                let ey = dy - next_radius * latent_mean[1];
2539                let latent_norm_squared =
2540                    latent_mean[0] * latent_mean[0] + latent_mean[1] * latent_mean[1];
2541                residual_sum += ex * ex
2542                    + ey * ey
2543                    + next_radius * next_radius * (1.0 - latent_norm_squared).max(0.0);
2544            }
2545            let next_noise_variance = (residual_sum / (2.0 * count)).max(variance_floor);
2546
2547            let parameter_change = (next_center[0] - center[0])
2548                .hypot(next_center[1] - center[1])
2549                .max((next_radius - radius).abs())
2550                .max(
2551                    (next_noise_variance - noise_variance).abs()
2552                        / (next_noise_variance + noise_variance),
2553                );
2554            center = next_center;
2555            radius = next_radius;
2556            noise_variance = next_noise_variance;
2557            if parameter_change <= EM_TOLERANCE {
2558                converged = true;
2559                break;
2560            }
2561        }
2562        if !converged {
2563            return Err("circular Gaussian maximum-likelihood fit did not converge".to_string());
2564        }
2565
2566        let fitted_noise_sd = scale * noise_variance.sqrt();
2567        Self::from_parameters(
2568            [anchor[0] + scale * center[0], anchor[1] + scale * center[1]],
2569            scale * radius,
2570            fitted_noise_sd * fitted_noise_sd,
2571        )
2572        .map_err(|error| format!("circular Gaussian fit produced invalid parameters: {error}"))
2573    }
2574
2575    /// Fitted circle center.
2576    pub const fn center(self) -> [f64; 2] {
2577        self.center
2578    }
2579
2580    /// Fitted latent-circle radius.
2581    pub const fn radius(self) -> f64 {
2582        self.radius
2583    }
2584
2585    /// Fitted isotropic Cartesian noise variance per coordinate.
2586    pub const fn noise_variance(self) -> f64 {
2587        self.noise_variance
2588    }
2589
2590    /// Proper Cartesian log density at `(x, y)`.
2591    pub fn log_density(self, x: f64, y: f64) -> f64 {
2592        let observed_radius = (x - self.center[0]).hypot(y - self.center[1]);
2593        let (log_i0_minus_kappa, _) =
2594            circular_gaussian_bessel_terms(self.radius, observed_radius, self.noise_variance);
2595        let standardized_radial_residual =
2596            (observed_radius - self.radius) / self.noise_variance.sqrt();
2597        // Algebraically this is -(r^2+R^2)/(2s)+log I0(kappa), but this
2598        // rearrangement preserves log I0(kappa) ~= kappa cancellation.
2599        -std::f64::consts::TAU.ln()
2600            - self.noise_variance.ln()
2601            - 0.5 * standardized_radial_residual.powi(2)
2602            + log_i0_minus_kappa
2603    }
2604
2605    /// Sum the fitted log density over selected rows.
2606    pub fn log_likelihood(
2607        self,
2608        coords: ArrayView2<'_, f64>,
2609        rows: &[usize],
2610    ) -> Result<f64, String> {
2611        if coords.ncols() != 2 || rows.iter().any(|&row| row >= coords.nrows()) {
2612            return Err(
2613                "circular Gaussian likelihood received invalid coordinates or rows".to_string(),
2614            );
2615        }
2616        let mut log_densities = Vec::with_capacity(rows.len());
2617        for &row in rows {
2618            let value = self.log_density(coords[[row, 0]], coords[[row, 1]]);
2619            if !value.is_finite() {
2620                return Err("circular Gaussian likelihood is not finite".to_string());
2621            }
2622            log_densities.push(value);
2623        }
2624        let log_likelihood = pairwise_sum(&log_densities);
2625        if !log_likelihood.is_finite() {
2626            return Err("circular Gaussian likelihood sum is not finite".to_string());
2627        }
2628        Ok(log_likelihood)
2629    }
2630
2631    /// Fit selected rows and return both the fit and its BIC/2 (lower is
2632    /// better). Keeping fitting and evidence evaluation in one operation makes
2633    /// it impossible to label a likelihood on unrelated data as fitted BIC.
2634    pub fn fit_with_bic(
2635        coords: ArrayView2<'_, f64>,
2636        rows: &[usize],
2637    ) -> Result<(Self, f64), String> {
2638        let fit = Self::fit(coords, rows)?;
2639        let log_likelihood = fit.log_likelihood(coords, rows)?;
2640        let bic =
2641            -log_likelihood + 0.5 * Self::NUM_FREE_PARAMETERS as f64 * (rows.len() as f64).ln();
2642        if !bic.is_finite() {
2643            return Err("circular Gaussian BIC is not finite".to_string());
2644        }
2645        Ok((fit, bic))
2646    }
2647}
2648
2649/// Stable Bessel terms for `kappa = radius * observed_radius / variance`.
2650/// The ordinary finite branch retains the shared approximation exactly. If the
2651/// product itself overflows, only the leading asymptotic
2652/// `log I0(kappa)-kappa = -log(2 pi kappa)/2 + O(1/kappa)` is representable;
2653/// `I1/I0` rounds to one at that scale.
2654fn circular_gaussian_bessel_terms(
2655    radius: f64,
2656    observed_radius: f64,
2657    noise_variance: f64,
2658) -> (f64, f64) {
2659    if radius == 0.0 || observed_radius == 0.0 {
2660        return (0.0, 0.0);
2661    }
2662    let kappa = radius * observed_radius / noise_variance;
2663    if kappa.is_finite() {
2664        return bessel_i0_log_minus_abs_and_ratio(kappa);
2665    }
2666    let log_kappa = radius.ln() + observed_radius.ln() - noise_variance.ln();
2667    if log_kappa <= f64::MAX.ln() {
2668        // The left-to-right product overflowed before a large variance divided
2669        // it back into range. Reconstruct the representable ratio from its log.
2670        return bessel_i0_log_minus_abs_and_ratio(log_kappa.exp());
2671    }
2672    (-0.5 * (std::f64::consts::TAU.ln() + log_kappa), 1.0)
2673}
2674//
2675// A *union* candidate is a small FIXED composite of named component structures
2676// joined by a hard row-responsibility split. Unlike the discrete-mixture rung
2677// (which is one free k-component Gaussian density), a union pins each component
2678// to a specific generative STRUCTURE (a circle, a line, a point cluster) and
2679// asks whether the data is better explained as the disjoint sum of those
2680// structures than by any single pure rung.
2681//
2682// The hard responsibility groups only determine which rows fit each component.
2683// The resulting candidate is one normalized, unlabeled soft-mixture density
2684// `p(y) = Σ_c π_c p_c(y)`, with `π_c = n_c / n`. It is scored on every
2685// training row by that same mixture density used for held-out evaluation. Its
2686// BIC/2 complexity price is `½(Σ_c P_c + m - 1) log(n)`: component
2687// parameters plus the `m - 1` free mixing weights, all on the common sample
2688// scale. This makes the union directly comparable to the other normalized
2689// parametric candidates in the topology race.
2690
2691/// The fixed ladder of structured-union composites. Deterministic and closed:
2692/// open-ended structure search stays owned by #976's move set; these three are
2693/// the only composites the topology race may select.
2694#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2695pub enum UnionStructure {
2696    /// Two circles (two well-separated periodic loops).
2697    CircleCircle,
2698    /// One circle plus one isolated point cluster (a loop with an outlier blob).
2699    CirclePointCluster,
2700    /// One line (anisotropic cluster) plus one isolated point cluster.
2701    LineCluster,
2702}
2703
2704/// The fixed structured-union ladder, in stable order.
2705pub const UNION_STRUCTURE_LADDER: &[UnionStructure] = &[
2706    UnionStructure::CircleCircle,
2707    UnionStructure::CirclePointCluster,
2708    UnionStructure::LineCluster,
2709];
2710
2711/// The per-component generative structure a union pins each responsibility group
2712/// to. `Line` is a full-covariance Gaussian, while `PointCluster` is the nested
2713/// isotropic Gaussian with `d + 1` parameters. The covariance constraint makes
2714/// line+cluster a genuine structured alternative to a generic two-component
2715/// full-covariance mixture instead of a duplicate candidate. `Circle` is the
2716/// proper Cartesian density of a uniform latent circle convolved with isotropic
2717/// Gaussian noise.
2718#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2719pub enum UnionComponentKind {
2720    Circle,
2721    Line,
2722    PointCluster,
2723}
2724
2725impl UnionStructure {
2726    /// Stable display name, e.g. `"union_circle+circle"`.
2727    pub const fn as_str(self) -> &'static str {
2728        match self {
2729            UnionStructure::CircleCircle => "union_circle+circle",
2730            UnionStructure::CirclePointCluster => "union_circle+cluster",
2731            UnionStructure::LineCluster => "union_line+cluster",
2732        }
2733    }
2734
2735    /// The fixed ordered component structures of this union.
2736    pub const fn components(self) -> &'static [UnionComponentKind] {
2737        match self {
2738            UnionStructure::CircleCircle => {
2739                &[UnionComponentKind::Circle, UnionComponentKind::Circle]
2740            }
2741            UnionStructure::CirclePointCluster => {
2742                &[UnionComponentKind::Circle, UnionComponentKind::PointCluster]
2743            }
2744            UnionStructure::LineCluster => {
2745                &[UnionComponentKind::Line, UnionComponentKind::PointCluster]
2746            }
2747        }
2748    }
2749
2750}
2751
2752/// One fitted component of a union: its pinned structure, the rows used to fit
2753/// it after the hard responsibility split, its free-parameter count, and its
2754/// normalized soft-mixture weight. A component has no standalone BIC inside a
2755/// union: the likelihood is the indivisible `log Σ_c π_c p_c(y)` scored on
2756/// every row.
2757#[derive(Debug, Clone)]
2758pub struct UnionComponentFit {
2759    pub kind: UnionComponentKind,
2760    pub row_count: usize,
2761    pub num_parameters: usize,
2762    pub mixing_weight: f64,
2763}
2764
2765/// A fitted structured-union candidate: the composite kind, the per-component
2766/// fits, its normalized soft-mixture training likelihood, the corresponding
2767/// BIC-form negative-log-evidence, and the complete free-parameter count.
2768#[derive(Debug, Clone)]
2769pub struct UnionStructureFit {
2770    pub structure: UnionStructure,
2771    pub components: Vec<UnionComponentFit>,
2772    /// `Σ_i log(Σ_c π_c p_c(y_i))` over all training rows.
2773    pub log_likelihood: f64,
2774    /// `-log_likelihood + ½ total_parameters log(n)` (lower wins).
2775    pub bic: f64,
2776    /// `Σ_c P_c + (m - 1)`, including the free mixing weights.
2777    pub total_parameters: usize,
2778}
2779
2780/// One fitted model in a REML/LAML evidence comparison.
2781#[derive(Clone, Debug)]
2782pub struct RemlCandidate {
2783    pub index: usize,
2784    pub name: String,
2785    /// Minimised REML/LAML cost, kept verbatim in the diagnostic score table.
2786    /// This is not the conditional-AIC cost that ranks candidates.
2787    pub score: f64,
2788    /// Effective degrees of freedom consumed by the fitted mean. Required
2789    /// because conditional AIC has no definition without its complexity term.
2790    pub edf: f64,
2791    /// Ordinary log-likelihood at the converged mode. Required because a raw
2792    /// REML/LAML objective is a different estimand, not a ranking fallback.
2793    pub log_lik: f64,
2794    /// Response-family tag (e.g. "gaussian", "gamma", "binomial"). Carried so
2795    /// `compare_reml_fits` can REFUSE to rank fits whose REML/LAML scores are on
2796    /// incomparable base measures (a cross-family comparison is meaningless;
2797    /// #1384). `None` for legacy payloads that did not record it — those are not
2798    /// guarded (back-compatible), but every current FFI candidate carries it.
2799    pub family: Option<String>,
2800    /// Number of observations the fit was trained on. Carried so
2801    /// `compare_reml_fits` can REFUSE to rank fits made on a different number of
2802    /// observations (hence different data): `−2·loglik` and the REML/LAML
2803    /// evidence grow with `n`, so a score difference between two fits with
2804    /// different `n` is not a Bayes factor — the same incomparability the family
2805    /// guard already rejects. `None` for payloads that do not record it (legacy /
2806    /// O(n) scan smoothers), which the guard treats as unconstrained.
2807    pub n_obs: Option<usize>,
2808}
2809
2810impl RemlCandidate {
2811    /// Cost used to RANK candidates and pick the winner.
2812    ///
2813    /// The REML/LAML marginal-likelihood evidence headline (`score`) does NOT
2814    /// reliably Occam-penalise an added pure-noise smooth: on `y ~ s(x)` vs
2815    /// `y ~ s(x) + s(z)` with `z ⟂ y`, the augmented model's evidence is
2816    /// *lower* (apparently better) by a few nats on essentially every dataset,
2817    /// because the Gaussian REML Occam pair `½(log|H| − log|S|₊)` collapses
2818    /// toward zero for a finite-`λ̂` null term while that term still spends a
2819    /// few effective degrees of freedom fitting noise (issue #1362).
2820    ///
2821    /// The conditional AIC `−2ℓ + 2·edf` prices exactly those spent degrees of
2822    /// freedom and discriminates correctly: it penalises the noise smooth
2823    /// (Δ ≈ +15 nats) yet rewards a genuinely relevant smooth (Δ ≈ −650),
2824    /// preserving power. Ranking therefore requires both quantities and refuses
2825    /// an invalid candidate rather than switching to the incomparable raw
2826    /// evidence headline. The reported `score_table` still carries that raw
2827    /// diagnostic unchanged.
2828    pub fn ranking_score(&self) -> Result<f64, String> {
2829        if !self.score.is_finite() {
2830            return Err(format!(
2831                "compare_models: candidate '{}' has non-finite raw REML/LAML score {}",
2832                self.name, self.score
2833            ));
2834        }
2835        if !(self.edf.is_finite() && self.edf >= 0.0) {
2836            return Err(format!(
2837                "compare_models: candidate '{}' requires finite non-negative edf_total, got {}",
2838                self.name, self.edf
2839            ));
2840        }
2841        if !self.log_lik.is_finite() {
2842            return Err(format!(
2843                "compare_models: candidate '{}' requires finite log_likelihood; \
2844                 raw REML/LAML is not a substitute ranking estimand",
2845                self.name
2846            ));
2847        }
2848        let score = -2.0 * self.log_lik + 2.0 * self.edf;
2849        if !score.is_finite() {
2850            return Err(format!(
2851                "compare_models: candidate '{}' conditional AIC is outside f64 range",
2852                self.name
2853            ));
2854        }
2855        Ok(score)
2856    }
2857}
2858
2859#[derive(Clone, Debug)]
2860pub struct RemlComparison {
2861    pub ranking: Vec<RankedRow>,
2862    pub winner: String,
2863    pub evidence_summary: String,
2864    pub score_table: Vec<ScoreRow>,
2865}
2866
2867#[derive(Clone, Debug)]
2868pub struct RankedRow {
2869    pub name: String,
2870    pub score: f64,
2871    /// Cost gap from the winning model on the SAME scale used to order the
2872    /// ranking (`ranking_score`, the Occam-penalised conditional AIC,
2873    /// issue #1362). The winner is `argmin ranking_score`, so this
2874    /// is `>= 0` for every row by construction — it never contradicts the
2875    /// declared winner (issue #1465). `score` still carries the raw REML/LAML
2876    /// diagnostic on its own explicitly labelled scale.
2877    pub delta: f64,
2878    /// Akaike evidence ratio of the winner over this row on the ranking scale.
2879    /// `delta` is a conditional-AIC gap (a −2·log / deviance-scale quantity), so
2880    /// the evidence ratio is `exp(½·delta) >= 1` (Burnham & Anderson), NOT
2881    /// `exp(delta)` — the latter squares the intended ratio (issues #1465, #2124).
2882    ///
2883    /// This is NOT a Bayes factor and was renamed away from that word: a Bayes
2884    /// factor is a ratio of prior-integrated marginal likelihoods, whereas this
2885    /// is the relative likelihood `exp(−ΔAIC/2)`, which integrates over no
2886    /// prior and must not be read against Jeffreys / Kass–Raftery thresholds.
2887    /// The raw REML/LAML `score_table` keeps the Laplace-approximate
2888    /// marginal-likelihood diagnostic on its own labelled scale.
2889    pub evidence_ratio: f64,
2890    pub edf: f64,
2891}
2892
2893#[derive(Clone, Debug)]
2894pub struct ScoreRow {
2895    pub name: String,
2896    pub reml_score: f64,
2897    pub delta_reml: f64,
2898    pub bayes_factor_best_over_model: f64,
2899    pub effective_dof: f64,
2900}
2901
2902/// Log Bayes factor of model `a` over model `b` from minimised REML/LAML costs.
2903#[inline]
2904pub fn log_bayes_factor(reml_score_a: f64, reml_score_b: f64) -> f64 {
2905    reml_score_b - reml_score_a
2906}
2907
2908/// Compare fitted models by the single evidence ordering contract used by
2909/// topology ranking and seed screening: lower finite cost wins, with stable
2910/// original-order tie handling.
2911pub fn compare_reml_fits(mut candidates: Vec<RemlCandidate>) -> Result<RemlComparison, String> {
2912    if candidates.is_empty() {
2913        return Err("compare_models requires at least one fit".to_string());
2914    }
2915    // Fail-loud comparability guard (#1384): REML/LAML evidence scores are only
2916    // comparable across fits of the SAME response family — a Gaussian score and
2917    // a Gamma score live on different log-density base measures, so their
2918    // difference is not a Bayes factor. Ranking them anyway returns a confident
2919    // but meaningless winner. Refuse when two candidates carry DIFFERENT family
2920    // tags. Candidates with no family tag (`None`, legacy payloads) are not
2921    // constrained, so this never spuriously rejects an older saved model.
2922    {
2923        let mut seen_family: Option<&str> = None;
2924        for cand in &candidates {
2925            if let Some(fam) = cand.family.as_deref() {
2926                match seen_family {
2927                    None => seen_family = Some(fam),
2928                    Some(prev) if prev != fam => {
2929                        return Err(format!(
2930                            "compare_models: cannot compare fits of different response families                              ('{prev}' vs '{fam}'); their REML/LAML evidence scores are on                              incomparable base measures. Compare models fit to the same response                              under the same family."
2931                        ));
2932                    }
2933                    Some(_) => {}
2934                }
2935            }
2936        }
2937    }
2938    // Fail-loud comparability guard (#1384 sibling): AIC / REML-LAML evidence are
2939    // only comparable across fits of the SAME response on the SAME observations.
2940    // `−2·loglik` (and the marginal-likelihood headline) grow with the number of
2941    // observations `n`, so two fits with different `n` live on incomparable
2942    // scales and their score gap is not a Bayes factor — comparing an n=500 and
2943    // an n=100 fit of the same DGP otherwise declares the n=100 model the winner
2944    // purely because fewer points give a less-negative total log-likelihood.
2945    // Refuse when two candidates carry DIFFERENT observation counts. Candidates
2946    // with no count (`None`, legacy / O(n) scan payloads) are unconstrained, so
2947    // this never spuriously rejects a fit that simply did not record `n`.
2948    {
2949        let mut seen_n: Option<usize> = None;
2950        for cand in &candidates {
2951            if let Some(n) = cand.n_obs {
2952                match seen_n {
2953                    None => seen_n = Some(n),
2954                    Some(prev) if prev != n => {
2955                        return Err(format!(
2956                            "compare_models: cannot compare fits made on a different number of \
2957                             observations (n={prev} vs n={n}); AIC / REML-LAML evidence scales \
2958                             with the sample size, so their score difference is not a Bayes \
2959                             factor. Compare models fit to the same response on the same data."
2960                        ));
2961                    }
2962                    Some(_) => {}
2963                }
2964            }
2965        }
2966    }
2967    let priority_candidates = candidates
2968        .into_iter()
2969        .enumerate()
2970        .map(|(idx, row)| {
2971            let ranking = row.ranking_score()?;
2972            Ok(PriorityCandidate::new(row, idx, ranking, 0))
2973        })
2974        .collect::<Result<Vec<_>, String>>()?;
2975    candidates = rank_priority_candidates(priority_candidates)
2976        .into_iter()
2977        .map(|row| row.item)
2978        .collect();
2979
2980    let winner = candidates[0].name.clone();
2981    // The ranking `delta` / `evidence_ratio` must be measured on the SAME scale
2982    // that orders the table — the `ranking_score` (Occam-penalised conditional
2983    // AIC, issue #1362). `candidates[0]` is the winner =
2984    // `argmin ranking_score`, so its ranking score IS the minimum; every row's
2985    // ranking-scale gap is then `>= 0` and its evidence ratio `>= 1`, never
2986    // contradicting the declared winner (issue #1465). Computing these against
2987    // the AIC winner's *raw REML* — which is not the minimum raw REML once AIC
2988    // and REML disagree — produced negative deltas and evidence ratios < 1 for
2989    // non-winner rows.
2990    let best_ranking_score = candidates[0].ranking_score()?;
2991    // The raw-REML `score_table` stays on its explicitly labelled diagnostic
2992    // scale, but is referenced to the genuine minimum raw REML so its factors are
2993    // coherent (`>= 1`), rather than to whichever row happens to sit at index 0.
2994    let best_raw_score = candidates
2995        .iter()
2996        .map(|c| c.score)
2997        .fold(f64::INFINITY, f64::min);
2998    let mut ranking = Vec::with_capacity(candidates.len());
2999    let mut score_table = Vec::with_capacity(candidates.len());
3000    for row in &candidates {
3001        let delta = log_bayes_factor(best_ranking_score, row.ranking_score()?);
3002        // `ranking_score` is the conditional AIC (`−2·loglik + 2·edf`), a −2·log /
3003        // deviance-scale cost, so `delta` is a full ΔAIC gap. The Akaike evidence
3004        // ratio for an AIC gap Δ is `exp(−½Δ)` (Burnham & Anderson evidence ratio),
3005        // hence the winner-over-row evidence ratio is `exp(½·delta)`. Reporting
3006        // `delta.exp()` squared the intended ratio (issue #2124). `delta` itself is
3007        // left on the AIC scale on purpose — only its exp() conversion is halved.
3008        let evidence_ratio = (0.5 * delta).exp();
3009        let delta_reml = log_bayes_factor(best_raw_score, row.score);
3010        ranking.push(RankedRow {
3011            name: row.name.clone(),
3012            score: row.score,
3013            delta,
3014            evidence_ratio,
3015            edf: row.edf,
3016        });
3017        score_table.push(ScoreRow {
3018            name: row.name.clone(),
3019            reml_score: row.score,
3020            delta_reml,
3021            bayes_factor_best_over_model: delta_reml.exp(),
3022            effective_dof: row.edf,
3023        });
3024    }
3025    // The winner is decided by `ranking_score` (the Occam-penalised conditional
3026    // AIC, issue #1362), which can disagree in sign with the raw
3027    // evidence Bayes factor for a noise-augmented model. Summarise the actual
3028    // decision margin so the headline never contradicts the chosen winner.
3029    let evidence_summary = if let Some(runner_up) = candidates.get(1) {
3030        let margin = runner_up.ranking_score()? - candidates[0].ranking_score()?;
3031        // `margin` is a conditional-AIC gap (−2·log scale), so the Akaike evidence
3032        // ratio is `exp(−½·margin)`; `format_bayes_factor` formats `exp()` of its
3033        // argument, so pass the halved margin to headline `exp(½·margin)` rather
3034        // than the squared `exp(margin)` (issue #2124).
3035        format!(
3036            "{} wins by evidence ratio {} over {}",
3037            winner,
3038            format_bayes_factor(0.5 * margin),
3039            runner_up.name
3040        )
3041    } else {
3042        format!("{winner} (single fit; no comparison)")
3043    };
3044    Ok(RemlComparison {
3045        ranking,
3046        winner,
3047        evidence_summary,
3048        score_table,
3049    })
3050}
3051
3052pub fn format_bayes_factor(log_bf: f64) -> String {
3053    if !log_bf.is_finite() {
3054        return "inf".to_string();
3055    }
3056    if log_bf.abs() >= std::f64::consts::LN_10 * 3.0 {
3057        return format!("1e{:+.1}", log_bf / std::f64::consts::LN_10);
3058    }
3059    format_three_significant(log_bf.exp())
3060}
3061
3062pub fn format_three_significant(value: f64) -> String {
3063    if value == 0.0 {
3064        return "0".to_string();
3065    }
3066    if !value.is_finite() {
3067        return format!("{value}");
3068    }
3069    let exponent = value.abs().log10().floor() as i32;
3070    if exponent >= 3 {
3071        return format!("{value:.2e}");
3072    }
3073    let decimals = (2 - exponent).max(0) as usize;
3074    let scale = 10f64.powi(decimals as i32);
3075    let rounded = (value * scale).abs().round() / scale * value.signum();
3076    format!("{rounded:.decimals$}")
3077}
3078
3079impl Default for TopologySelectOptions {
3080    fn default() -> Self {
3081        Self {
3082            tie_tolerance: 1e-3,
3083            score_scale: TopologyScoreScale::PerObservation,
3084        }
3085    }
3086}
3087
3088// ---------------------------------------------------------------------------
3089// Laplace evidence
3090// ---------------------------------------------------------------------------
3091
3092// ---------------------------------------------------------------------------
3093// IFT cascade: ∂u*/∂β → ∂β*/∂ρ → ∂u*/∂ρ
3094// ---------------------------------------------------------------------------
3095
3096/// Coupling components of a symmetric coefficient Hessian: the connected
3097/// components of the graph whose vertices are coefficient indices `0..p` and
3098/// whose edges are the structurally nonzero off-diagonal entries of `H` (#779).
3099///
3100/// Returns a length-`p` vector of component labels in `0..num_components`,
3101/// where two indices share a label iff they are connected through a chain of
3102/// nonzero `H[i,j]` couplings. This is the exact structural partition the
3103/// cone-of-influence sensitivity reuse is keyed on: a smoothing-parameter move
3104/// whose stationarity-gradient derivative `∂g/∂ρ` is supported only inside one
3105/// component can change `β = -H⁻¹ ∂g/∂ρ` only inside that same component, so
3106/// the sensitivity of every *other* component is provably unchanged and may be
3107/// reused unrecomputed (lazy/local propagation).
3108///
3109/// The nonzero test is exact (`!= 0.0`), matching the structural-coupling gate
3110/// used elsewhere for the joint inner Hessian: a tolerance would risk dropping a
3111/// genuine (small) coupling edge and silently biasing the propagated sensitivity
3112/// — the failure mode #779/#740 explicitly guard against. A block-diagonal `H`
3113/// yields the all-singletons partition (one component per block-decoupled
3114/// coordinate); a fully coupled `H` yields a single component (no shortcut, the
3115/// full joint solve is required — and is what the non-coned path performs).
3116pub fn coupling_components(hessian: ArrayView2<'_, f64>) -> Vec<usize> {
3117    let p = hessian.nrows();
3118    if p == 0 || hessian.ncols() != p {
3119        return Vec::new();
3120    }
3121    // Union-find with path compression and union by size.
3122    let mut parent: Vec<usize> = (0..p).collect();
3123    let mut size: Vec<usize> = vec![1; p];
3124
3125    fn find(parent: &mut [usize], mut x: usize) -> usize {
3126        while parent[x] != x {
3127            parent[x] = parent[parent[x]];
3128            x = parent[x];
3129        }
3130        x
3131    }
3132
3133    for i in 0..p {
3134        for j in (i + 1)..p {
3135            // Symmetric structure: an edge exists if either triangle is nonzero,
3136            // so a numerically one-sided fill still couples the two indices.
3137            if hessian[[i, j]] != 0.0 || hessian[[j, i]] != 0.0 {
3138                let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
3139                if ri != rj {
3140                    let (small, large) = if size[ri] < size[rj] {
3141                        (ri, rj)
3142                    } else {
3143                        (rj, ri)
3144                    };
3145                    parent[small] = large;
3146                    size[large] += size[small];
3147                }
3148            }
3149        }
3150    }
3151
3152    // Relabel roots to a dense `0..num_components` range, preserving
3153    // first-seen order so labels are deterministic.
3154    let mut label_of_root: Vec<Option<usize>> = vec![None; p];
3155    let mut next_label = 0usize;
3156    let mut labels = vec![0usize; p];
3157    for idx in 0..p {
3158        let root = find(&mut parent, idx);
3159        let label = match label_of_root[root] {
3160            Some(l) => l,
3161            None => {
3162                let l = next_label;
3163                label_of_root[root] = Some(l);
3164                next_label += 1;
3165                l
3166            }
3167        };
3168        labels[idx] = label;
3169    }
3170    labels
3171}
3172
3173/// The cone of influence of a single stationarity-gradient derivative column
3174/// whose support (the coefficient indices where `∂g/∂ρ_k` is nonzero) lies in
3175/// `support`: the set of coefficient indices in the same coupling component(s)
3176/// as that support, given precomputed `labels` from [`coupling_components`].
3177///
3178/// `β_k = -H⁻¹ ∂g/∂ρ_k` is exactly zero outside this cone, so a confined solve
3179/// (or reuse of a cached zero) is exact, not an approximation. An empty support
3180/// (a structurally inactive `ρ_k`, e.g. a rank-0 or out-of-range penalty block)
3181/// yields an empty cone: the sensitivity is identically zero and no solve is
3182/// needed at all.
3183pub fn cone_of_influence(labels: &[usize], support: &[usize]) -> Vec<usize> {
3184    if support.is_empty() {
3185        return Vec::new();
3186    }
3187    let mut in_cone_labels: Vec<usize> = support
3188        .iter()
3189        .filter_map(|&idx| labels.get(idx).copied())
3190        .collect();
3191    in_cone_labels.sort_unstable();
3192    in_cone_labels.dedup();
3193    if in_cone_labels.is_empty() {
3194        return Vec::new();
3195    }
3196    (0..labels.len())
3197        .filter(|idx| in_cone_labels.binary_search(&labels[*idx]).is_ok())
3198        .collect()
3199}
3200
3201// ---------------------------------------------------------------------------
3202// ∂V/∂ρ — analytic optimized-evidence gradient via IFT mode response
3203// ---------------------------------------------------------------------------
3204
3205/// IFT terms needed to differentiate the optimized Laplace evidence through
3206/// the fitted mode `(β*(ρ), u*(ρ))`.
3207///
3208/// For each hyperparameter `ρ_a`, the correction added to the direct trace is
3209///
3210/// ```text
3211/// F_β · β_a + F_u · u_a
3212/// + 0.5 (∂_β log|H| · β_a + ∂_u log|H| · u_a).
3213/// ```
3214///
3215/// At an exact KKT point the value-gradient pieces are zero, but they are
3216/// explicit here so the exported gradient matches the optimized objective
3217/// whenever callers carry a certified nonzero residual correction.
3218#[derive(Clone)]
3219pub struct EvidenceIftGradientTerms<'a> {
3220    pub dbeta_drho: ArrayView2<'a, f64>,
3221    pub du_drho: ArrayView2<'a, f64>,
3222    pub value_beta: ArrayView1<'a, f64>,
3223    pub value_u: ArrayView1<'a, f64>,
3224    pub logdet_h_beta: ArrayView1<'a, f64>,
3225    pub logdet_h_u: ArrayView1<'a, f64>,
3226}
3227
3228// ---------------------------------------------------------------------------
3229// Topology selection
3230// ---------------------------------------------------------------------------
3231
3232// ---------------------------------------------------------------------------
3233// Cache verification helpers
3234// ---------------------------------------------------------------------------
3235
3236// ---------------------------------------------------------------------------
3237// #1026 hybrid curved + linear-tail dictionary split-selection
3238// ---------------------------------------------------------------------------
3239//
3240// COMMON-EVIDENCE NOTE (#1202): the candidates BOTH fit the same data — the
3241// atom's leave-this-atom-out response residual `y_resp` (the response with every
3242// other atom's contribution removed). The curved candidate predicts the atom's
3243// actual mass-scaled contribution `a_k·γ_k`, the linear candidate the best
3244// mass-weighted straight line fit to `y_resp`. Because the curved family's
3245// `Θ = 0` member reproduces the linear prediction exactly, linear IS the nested
3246// `Θ = 0` sub-model on common data, so the "match-or-beat" statements below are a
3247// genuine data-level comparison: the curved candidate wins only when fitting the
3248// response residual better than its own straight projection pays for its extra
3249// parameters. See `crate::terms::sae::hybrid_split` for the residual assembly.
3250//
3251// The per-slot adjudication uses the SAME rank-aware Laplace evidence criterion
3252// the union/mixture rungs use (`−V = NLE`, lower wins), comparing the data-fit +
3253// complexity cost of the curved contribution against that of the straight line.
3254//
3255// ## The turning floor (Θ → 0) and the curved ceiling (Θ large)
3256//
3257// Per slot, the curved candidate fits the response residual with its actual
3258// mass-scaled contribution `a_k·γ_k` (data-fit `½·curved_rss`) and pays a larger
3259// free-parameter price `P_curved > P_linear`; the linear candidate fits the same
3260// residual with its best straight line (data-fit `½·linear_rss ≥ ½·curved_rss`
3261// whenever the curve beats its own straight projection) at a smaller price,
3262// charged with its genuine weighted Gram logdet `p·(log w_sum + log s_tt)`
3263// (#1203). Hence:
3264//
3265//   * Θ → 0 (the residual is straight): the curve and the line fit it equally, so
3266//     the cheaper LINEAR candidate wins — the turning floor / nested dominance. A
3267//     curved parameterization "buys nothing" on an already-straight residual.
3268//   * Θ large (a genuinely turning residual): the line's data-fit residual
3269//     exceeds the curved atom's extra parameter price, so CURVED wins. (Whether
3270//     curved wins also depends on the coordinate spread `s_tt` and amplitude, via
3271//     the honest logdet — a tightly-spread, mildly-curved residual can still
3272//     prefer the cheaper line.)
3273//
3274// The crossover is governed by the documented shatter law: a linear SAE shatters
3275// a feature of total turning Θ into `N(ε) ≈ Θ/(2√(2ε))` rank-1 directions at
3276// relative reconstruction error ε, so the curved advantage scales as `Θ/√ε`. We
3277// use the fitted turning Θ (`sae::chart_canonicalization::d1_atom_fitted_turning`)
3278// as the decision FEATURE: it both (a) sharpens the evidence comparison into a
3279// falsifiable per-atom prediction and (b) provides the exact-zero dominance
3280// guard — when an atom's fitted turning is identically zero, the curved fit has
3281// no curvature to price and the linear special case is selected by construction,
3282// independent of finite-sample evidence noise.
3283
3284/// Which atom parameterization a hybrid-dictionary slot selects: a CURVED atom
3285/// (a `latent_dim ≥ 1` curved basis whose decoded image may turn) or its LINEAR
3286/// special case (the euclidean-d=1-linear atom — one straight decoder direction,
3287/// `γ(t) = t·b`, fitted turning `Θ = 0`).
3288#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3289pub enum HybridAtomParam {
3290    /// The curved atom (`latent_dim ≥ 1`), priced at its full coefficient count.
3291    Curved { latent_dim: usize },
3292    /// The linear special case: one decoder direction, zero turning.
3293    Linear,
3294}
3295
3296impl HybridAtomParam {
3297    /// Stable display name for logs and tests.
3298    pub const fn as_str(self) -> &'static str {
3299        match self {
3300            HybridAtomParam::Curved { .. } => "curved",
3301            HybridAtomParam::Linear => "linear",
3302        }
3303    }
3304
3305    /// `true` iff this is the linear special case (the linear tail).
3306    pub const fn is_linear(self) -> bool {
3307        matches!(self, HybridAtomParam::Linear)
3308    }
3309}
3310
3311/// One fitted candidate parameterization for a single hybrid-dictionary atom
3312/// slot, scored on the COMMON rank-aware Laplace scale (`−V = NLE`, lower wins,
3313/// identical to the union/mixture rungs). The curved and linear candidates for
3314/// the SAME slot are fit on the same rows AND the same data (the atom's response
3315/// residual, #1202), so their NLEs are directly comparable; the structural
3316/// difference is the curved candidate's larger free-parameter price and whatever
3317/// data-fit it buys with its curvature.
3318#[derive(Debug, Clone, Copy)]
3319pub struct HybridAtomCandidate {
3320    pub param: HybridAtomParam,
3321    /// Rank-aware Laplace negative-log-evidence on the common scale (lower wins).
3322    pub negative_log_evidence: f64,
3323    /// Free-parameter count this candidate is charged for (the complexity price).
3324    pub num_parameters: usize,
3325    /// The candidate's fitted total turning `Θ = ∫κ ds` of its decoded curve, if
3326    /// the basis admits an analytic second jet. `Some(0.0)` for a linear atom (a
3327    /// straight image has no turning); `None` when the turning is honestly
3328    /// unavailable (no second jet / degenerate curve) — never fabricated.
3329    pub fitted_turning: Option<f64>,
3330}
3331
3332impl HybridAtomCandidate {
3333    /// A linear special-case candidate: exact zero turning by construction.
3334    pub fn linear(negative_log_evidence: f64, num_parameters: usize) -> Self {
3335        Self {
3336            param: HybridAtomParam::Linear,
3337            negative_log_evidence,
3338            num_parameters,
3339            fitted_turning: Some(0.0),
3340        }
3341    }
3342
3343    /// A curved candidate of the given latent dimension, with its fitted turning.
3344    pub fn curved(
3345        latent_dim: usize,
3346        negative_log_evidence: f64,
3347        num_parameters: usize,
3348        fitted_turning: Option<f64>,
3349    ) -> Self {
3350        Self {
3351            param: HybridAtomParam::Curved { latent_dim },
3352            negative_log_evidence,
3353            num_parameters,
3354            fitted_turning,
3355        }
3356    }
3357}
3358
3359/// The evidence-selected parameterization for one hybrid-dictionary atom slot:
3360/// the winning candidate, plus the curved/linear NLEs that decided it (for the
3361/// EV-vs-Θ diagnostic and the tie-break audit trail).
3362#[derive(Debug, Clone, Copy)]
3363pub struct HybridAtomChoice {
3364    pub param: HybridAtomParam,
3365    /// The winning candidate's NLE.
3366    pub negative_log_evidence: f64,
3367    /// The winning candidate's free-parameter price.
3368    pub num_parameters: usize,
3369    /// The curved candidate's fitted turning `Θ` (the decision feature). `None`
3370    /// when no curved candidate offered an analytic turning.
3371    pub curved_turning: Option<f64>,
3372    /// `NLE_linear − NLE_curved`: the evidence margin the curved fit won (or lost,
3373    /// if negative) over the linear special case at this slot. Positive ⇒ curved
3374    /// bought more evidence than its parameter price; ≤ 0 ⇒ the dominance floor
3375    /// keeps the linear tail.
3376    pub curved_evidence_margin: f64,
3377}
3378
3379/// Below this fitted turning the curved candidate is treated as straight: its
3380/// curvature is numerically indistinguishable from zero, so the dominance floor
3381/// (the linear special case is cheaper at equal likelihood) is enforced by
3382/// construction rather than left to finite-sample evidence noise. This is the
3383/// exact-zero guard from the `Θ → 0 ⇒ N(ε) → 0` limit of the shatter law, not a
3384/// tunable knob: it is the curvature scale below which `‖γ' ∧ γ''‖` is at the
3385/// floor of the Simpson quadrature for a genuinely straight image.
3386pub const HYBRID_LINEAR_TURNING_FLOOR: f64 = 1e-9;
3387
3388/// Adjudicate the curved-vs-linear parameterization for ONE hybrid-dictionary
3389/// atom slot by the common rank-aware Laplace evidence criterion.
3390///
3391/// Selection rule (all on the single `NLE = −V` scale, lower wins):
3392///
3393///  1. **Dominance floor (Θ → 0).** If the curved candidate's fitted turning is
3394///     `Some(Θ)` with `Θ ≤ HYBRID_LINEAR_TURNING_FLOOR` and a linear candidate
3395///     exists, select LINEAR. A straight curved fit recovers no likelihood the
3396///     linear special case does not, and the linear atom is strictly cheaper, so
3397///     it cannot lose — we enforce that exactly instead of trusting evidence
3398///     noise at the floor.
3399///  2. **Evidence comparison.** Otherwise select the candidate with the smaller
3400///     `NLE`. The curved candidate wins only when its extra curvature lowers the
3401///     NLE by MORE than its extra parameter price — the `Θ/√ε` crossover, decided
3402///     here by the evidence numbers themselves, not by fiat. This is a
3403///     common-data comparison (both candidates fit the atom's response residual,
3404///     see `crate::terms::sae::hybrid_split`) in which linear is the curved
3405///     family's nested `Θ = 0` sub-model (#1202): the curved candidate cannot be
3406///     charged its extra parameters to fit the residual no better than its own
3407///     straight projection, and a tightly-spread, mildly-curved residual can
3408///     still prefer the cheaper line.
3409///  3. **Tie-break.** Exact NLE ties go to the cheaper (fewer-parameter)
3410///     candidate — i.e. linear — preserving the strict-generalization guarantee
3411///     that the hybrid never pays for curvature it does not need.
3412///
3413/// `candidates` must contain at most one linear and at most one curved candidate
3414/// for the slot; returns `None` only if `candidates` is empty.
3415pub fn select_hybrid_atom(candidates: &[HybridAtomCandidate]) -> Option<HybridAtomChoice> {
3416    if candidates.is_empty() {
3417        return None;
3418    }
3419    let linear = candidates.iter().find(|c| c.param.is_linear());
3420    let curved = candidates.iter().find(|c| !c.param.is_linear());
3421    let curved_turning = curved.and_then(|c| c.fitted_turning);
3422    let curved_evidence_margin = match (linear, curved) {
3423        (Some(l), Some(c)) => l.negative_log_evidence - c.negative_log_evidence,
3424        _ => 0.0,
3425    };
3426
3427    // (1) Exact-zero dominance floor: a straight curved fit yields to the linear
3428    // special case by construction.
3429    if let (Some(l), Some(turning)) = (linear, curved_turning)
3430        && turning <= HYBRID_LINEAR_TURNING_FLOOR
3431    {
3432        return Some(HybridAtomChoice {
3433            param: l.param,
3434            negative_log_evidence: l.negative_log_evidence,
3435            num_parameters: l.num_parameters,
3436            curved_turning,
3437            curved_evidence_margin,
3438        });
3439    }
3440
3441    // (2)+(3) Evidence argmin with the cheaper candidate winning exact ties.
3442    let mut best = candidates[0];
3443    for cand in &candidates[1..] {
3444        let better_evidence = cand.negative_log_evidence < best.negative_log_evidence;
3445        let tied = cand.negative_log_evidence == best.negative_log_evidence;
3446        let cheaper_on_tie = tied && cand.num_parameters < best.num_parameters;
3447        if better_evidence || cheaper_on_tie {
3448            best = *cand;
3449        }
3450    }
3451    Some(HybridAtomChoice {
3452        param: best.param,
3453        negative_log_evidence: best.negative_log_evidence,
3454        num_parameters: best.num_parameters,
3455        curved_turning,
3456        curved_evidence_margin,
3457    })
3458}
3459
3460/// The evidence-selected split for a whole hybrid dictionary: the per-atom
3461/// curved-vs-linear choices and the dictionary-level aggregates the EV-vs-Θ
3462/// frontier reports against.
3463#[derive(Debug, Clone)]
3464pub struct HybridSplitSelection {
3465    /// One adjudicated choice per atom slot, in slot order.
3466    pub atoms: Vec<HybridAtomChoice>,
3467    /// `Σ NLE` across the selected per-atom parameterizations — the dictionary's
3468    /// summed rank-aware Laplace negative-log-evidence (lower wins). Because each
3469    /// slot picks the argmin over {curved contribution, best straight line to the
3470    /// response residual}, this is ≤ the sum of the per-slot LINEAR-candidate
3471    /// NLEs. The linear baseline is the best straight line fit to each atom's
3472    /// leave-this-atom-out RESPONSE residual (#1202), the curved family's nested
3473    /// `Θ = 0` member on common data — so this is a genuine data-level
3474    /// match-or-beat dominance, not a post-hoc curve-simplification one.
3475    pub total_negative_log_evidence: f64,
3476    /// `Σ P` across the selected parameterizations — the dictionary's total
3477    /// free-parameter price (the matched-active-budget accounting).
3478    pub total_parameters: usize,
3479    /// Count of slots that selected the curved parameterization.
3480    pub curved_atom_count: usize,
3481}
3482
3483impl HybridSplitSelection {
3484    /// Count of slots that selected the linear special case (the linear tail).
3485    pub fn linear_atom_count(&self) -> usize {
3486        self.atoms.len() - self.curved_atom_count
3487    }
3488
3489    /// `true` iff every slot selected linear — the pure-linear limit, reached
3490    /// when every feature is straight (all `Θ → 0`).
3491    pub fn is_pure_linear(&self) -> bool {
3492        self.curved_atom_count == 0 && !self.atoms.is_empty()
3493    }
3494
3495    /// `true` iff every slot selected curved — the pure-curved limit, reached
3496    /// when every feature turns enough to pay for curvature.
3497    pub fn is_pure_curved(&self) -> bool {
3498        self.curved_atom_count == self.atoms.len() && !self.atoms.is_empty()
3499    }
3500}
3501
3502/// Adjudicate the curved-vs-linear split across a whole hybrid dictionary by the
3503/// common evidence criterion. `slots[i]` holds the curved/linear candidates for
3504/// atom slot `i` (each scored on the same rows, on the common Laplace scale).
3505///
3506/// The result reduces EXACTLY to pure-linear when every slot's curved candidate
3507/// has `Θ → 0` (the turning floor fires everywhere) and to pure-curved when
3508/// every slot's curved candidate wins the evidence comparison. (Common-data
3509/// criterion, #1202 — both candidates fit the atom's response residual, with
3510/// linear nested as the curved family's `Θ = 0` sub-model; see the module header
3511/// above and `crate::terms::sae::hybrid_split`.)
3512///
3513/// Returns an error only if some slot has no candidates to adjudicate (an empty
3514/// dictionary slot is a caller bug, not a silent skip).
3515pub fn select_hybrid_split(
3516    slots: &[Vec<HybridAtomCandidate>],
3517) -> Result<HybridSplitSelection, String> {
3518    let mut atoms = Vec::with_capacity(slots.len());
3519    let mut total_nle = 0.0_f64;
3520    let mut total_parameters = 0usize;
3521    let mut curved_atom_count = 0usize;
3522    for (i, slot) in slots.iter().enumerate() {
3523        let choice = select_hybrid_atom(slot)
3524            .ok_or_else(|| format!("hybrid split slot {i} has no candidate parameterizations"))?;
3525        if !choice.negative_log_evidence.is_finite() {
3526            return Err(format!(
3527                "hybrid split slot {i} selected a non-finite evidence ({})",
3528                choice.negative_log_evidence
3529            ));
3530        }
3531        if !choice.param.is_linear() {
3532            curved_atom_count += 1;
3533        }
3534        total_nle += choice.negative_log_evidence;
3535        total_parameters += choice.num_parameters;
3536        atoms.push(choice);
3537    }
3538    Ok(HybridSplitSelection {
3539        atoms,
3540        total_negative_log_evidence: total_nle,
3541        total_parameters,
3542        curved_atom_count,
3543    })
3544}
3545
3546// ---------------------------------------------------------------------------
3547// Tests
3548//
3549// These are type-level / structural tests: per the task contract we do
3550// not compile or run them in this session. They document the expected
3551// shapes and degenerate-case behavior so a future maintainer running
3552// `cargo test` sees the contract written down.
3553// ---------------------------------------------------------------------------
3554
3555#[cfg(test)]
3556mod tests {
3557    use super::*;
3558    use ndarray::array;
3559
3560    // Dense `H⁻¹` apply via explicit inverse (test-only reference solver).
3561    fn dense_inverse(h: &Array2<f64>) -> Array2<f64> {
3562        let p = h.nrows();
3563        let mut aug = Array2::<f64>::zeros((p, 2 * p));
3564        for i in 0..p {
3565            for j in 0..p {
3566                aug[[i, j]] = h[[i, j]];
3567            }
3568            aug[[i, p + i]] = 1.0;
3569        }
3570        for col in 0..p {
3571            let mut pivot = col;
3572            for row in (col + 1)..p {
3573                if aug[[row, col]].abs() > aug[[pivot, col]].abs() {
3574                    pivot = row;
3575                }
3576            }
3577            if pivot != col {
3578                for j in 0..(2 * p) {
3579                    aug.swap([col, j], [pivot, j]);
3580                }
3581            }
3582            let d = aug[[col, col]];
3583            for j in 0..(2 * p) {
3584                aug[[col, j]] /= d;
3585            }
3586            for row in 0..p {
3587                if row == col {
3588                    continue;
3589                }
3590                let f = aug[[row, col]];
3591                if f != 0.0 {
3592                    for j in 0..(2 * p) {
3593                        aug[[row, j]] -= f * aug[[col, j]];
3594                    }
3595                }
3596            }
3597        }
3598        let mut inv = Array2::<f64>::zeros((p, p));
3599        for i in 0..p {
3600            for j in 0..p {
3601                inv[[i, j]] = aug[[i, p + j]];
3602            }
3603        }
3604        inv
3605    }
3606
3607    /// The rate is what separates "interrupted mid-descent" from "stuck", so it
3608    /// must read a clean geometric decay exactly and refuse to speak before it
3609    /// has a full window.
3610    #[test]
3611    fn em_contraction_rate_recovers_a_planted_geometric_decay() {
3612        let planted = 0.98_f64;
3613        let mut window = std::collections::VecDeque::new();
3614        let mut residual = 1.0_f64;
3615        for _ in 0..EM_RATE_WINDOW {
3616            window.push_back(residual);
3617            residual *= planted;
3618        }
3619        // One short of a full window: no rate may be claimed yet.
3620        assert_eq!(em_contraction_rate(&window), None);
3621        window.push_back(residual);
3622        let measured = em_contraction_rate(&window).expect("a full window yields a rate");
3623        assert!(
3624            (measured - planted).abs() < 1e-12,
3625            "measured {measured} should recover the planted {planted}"
3626        );
3627    }
3628
3629    /// A residual that is flat or growing must NOT produce a rate below 1, or a
3630    /// stalled iterate would earn an extension it cannot use.
3631    #[test]
3632    fn em_contraction_rate_does_not_contract_on_a_flat_or_growing_residual() {
3633        let flat: std::collections::VecDeque<f64> =
3634            std::iter::repeat_n(1e-6, EM_RATE_WINDOW + 1).collect();
3635        let rate = em_contraction_rate(&flat).expect("a full window yields a rate");
3636        assert!(rate >= 1.0, "a flat residual must not look like contraction");
3637        let growing: std::collections::VecDeque<f64> = (0..=EM_RATE_WINDOW)
3638            .map(|i| 1e-6 * 1.01_f64.powi(i as i32))
3639            .collect();
3640        let rate = em_contraction_rate(&growing).expect("a full window yields a rate");
3641        assert!(rate > 1.0, "a growing residual must not look like contraction");
3642    }
3643
3644    /// The projection is the deadline an extension is held to, so it must invert
3645    /// the decay exactly and decline to exist when there is nothing to project.
3646    #[test]
3647    fn em_projected_iterations_inverts_the_decay_and_declines_otherwise() {
3648        // 1.0 -> 1e-8 at rate 0.98 needs ln(1e-8)/ln(0.98) = 911.6 -> 912.
3649        let steps = em_projected_iterations(1.0, 1e-8, 0.98).expect("a contracting rate projects");
3650        assert_eq!(steps, 912);
3651        // Applying the rate for that many steps must actually reach tolerance.
3652        assert!(0.98_f64.powi(steps as i32) <= 1e-8);
3653        // No projection without contraction, or when already inside tolerance.
3654        assert_eq!(em_projected_iterations(1.0, 1e-8, 1.0), None);
3655        assert_eq!(em_projected_iterations(1.0, 1e-8, 1.05), None);
3656        assert_eq!(em_projected_iterations(1e-9, 1e-8, 0.98), None);
3657    }
3658
3659    #[test]
3660    fn coupling_components_block_diagonal_is_all_singletons_by_block() {
3661        // Two decoupled 2x2 blocks: {0,1} and {2,3}.
3662        let mut h = Array2::<f64>::eye(4);
3663        h[[0, 1]] = 0.3;
3664        h[[1, 0]] = 0.3;
3665        h[[2, 3]] = 0.7;
3666        h[[3, 2]] = 0.7;
3667        let labels = coupling_components(h.view());
3668        assert_eq!(labels[0], labels[1]);
3669        assert_eq!(labels[2], labels[3]);
3670        assert_ne!(labels[0], labels[2]);
3671        // Exactly two components.
3672        let mut uniq = labels.clone();
3673        uniq.sort_unstable();
3674        uniq.dedup();
3675        assert_eq!(uniq.len(), 2);
3676    }
3677
3678    #[test]
3679    fn coupling_components_fully_coupled_is_one_component() {
3680        let mut h = Array2::<f64>::eye(3);
3681        for i in 0..3 {
3682            for j in 0..3 {
3683                if i != j {
3684                    h[[i, j]] = 0.1;
3685                }
3686            }
3687        }
3688        let labels = coupling_components(h.view());
3689        assert!(labels.iter().all(|&l| l == labels[0]));
3690    }
3691
3692    #[test]
3693    fn coupling_components_transitive_chain_merges() {
3694        // 0-1 and 1-2 coupled (but no direct 0-2 edge) must form one component.
3695        let mut h = Array2::<f64>::eye(3);
3696        h[[0, 1]] = 0.5;
3697        h[[1, 0]] = 0.5;
3698        h[[1, 2]] = 0.5;
3699        h[[2, 1]] = 0.5;
3700        let labels = coupling_components(h.view());
3701        assert_eq!(labels[0], labels[1]);
3702        assert_eq!(labels[1], labels[2]);
3703    }
3704
3705    #[test]
3706    fn compare_reml_fits_delta_and_evidence_ratio_never_contradict_winner_gh1465() {
3707        // Regression for #1465: the ranking `delta` / `evidence_ratio` must be
3708        // measured on the SAME scale that orders the table (the Occam-penalised
3709        // conditional AIC `ranking_score`), so every row's delta is >= 0 and its
3710        // evidence ratio >= 1 — the table must never claim a non-winner beats the
3711        // declared winner. The scenario is exactly the case the comparison
3712        // exists to handle: AIC and raw REML DISAGREE. `m1` is the AIC winner
3713        // but does NOT carry the minimum raw REML (`m2` does) — the noise
3714        // extra-term case from the issue.
3715        //
3716        // `ranking_score` = -2*log_lik + 2*edf; with log_lik = 0 it is `2*edf`,
3717        // so the AIC order is m1 < m2 < m3 while the raw-REML order has m2 lowest.
3718        let cand = |name: &str, score: f64, edf: f64| RemlCandidate {
3719            index: 0,
3720            name: name.to_string(),
3721            score,
3722            edf,
3723            log_lik: 0.0,
3724            family: Some("gaussian".to_string()),
3725            n_obs: Some(100),
3726        };
3727        // raw REML : m2 (41.605) < m1 (53.748) < m3 (120.011)
3728        // AIC=2*edf: m1 (100)    < m2 (102)    < m3 (130)
3729        let candidates = vec![
3730            cand("m1", 53.748, 50.0),
3731            cand("m2", 41.605, 51.0),
3732            cand("m3", 120.011, 65.0),
3733        ];
3734        let cmp = compare_reml_fits(candidates).expect("comparison");
3735
3736        assert_eq!(cmp.winner, "m1", "AIC winner");
3737        // No ranking row may contradict the declared winner.
3738        for row in &cmp.ranking {
3739            assert!(
3740                row.delta >= 0.0,
3741                "ranking delta for {} must be >= 0, got {}",
3742                row.name,
3743                row.delta
3744            );
3745            assert!(
3746                row.evidence_ratio >= 1.0 - 1e-12,
3747                "ranking evidence_ratio for {} must be >= 1, got {}",
3748                row.name,
3749                row.evidence_ratio
3750            );
3751        }
3752        let winner_row = cmp.ranking.iter().find(|r| r.name == "m1").unwrap();
3753        assert!(winner_row.delta.abs() < 1e-12, "winner delta == 0");
3754        assert!(
3755            (winner_row.evidence_ratio - 1.0).abs() < 1e-9,
3756            "winner evidence_ratio == 1"
3757        );
3758
3759        // The raw-REML score table is referenced to the genuine minimum raw REML
3760        // (m2), so its best-over-model Bayes factors are also coherent (>= 1).
3761        for row in &cmp.score_table {
3762            assert!(
3763                row.delta_reml >= 0.0,
3764                "score-table delta_reml for {} must be >= 0, got {}",
3765                row.name,
3766                row.delta_reml
3767            );
3768            assert!(
3769                row.bayes_factor_best_over_model >= 1.0 - 1e-12,
3770                "score-table bayes_factor for {} must be >= 1, got {}",
3771                row.name,
3772                row.bayes_factor_best_over_model
3773            );
3774        }
3775        // m2 carries the minimum raw REML, so its raw delta is exactly 0.
3776        let m2 = cmp.score_table.iter().find(|r| r.name == "m2").unwrap();
3777        assert!(
3778            m2.delta_reml.abs() < 1e-12,
3779            "the minimum-raw-REML row has delta_reml 0"
3780        );
3781    }
3782
3783    #[test]
3784    fn cone_of_influence_empty_support_is_empty() {
3785        let labels = vec![0usize, 0, 1, 1];
3786        assert!(cone_of_influence(&labels, &[]).is_empty());
3787    }
3788
3789    #[test]
3790    fn cone_of_influence_returns_full_component() {
3791        let labels = vec![0usize, 0, 1, 1];
3792        // Support in component 0 -> cone is {0,1}.
3793        assert_eq!(cone_of_influence(&labels, &[0]), vec![0, 1]);
3794        // Support spanning both -> cone is everything.
3795        assert_eq!(cone_of_influence(&labels, &[1, 2]), vec![0, 1, 2, 3]);
3796    }
3797
3798    #[test]
3799    fn coned_matches_full_solve_on_fully_coupled_hessian() {
3800        // Fully coupled SPD H: cone is the whole space, result must equal the
3801        // unconfined sensitivity-operator mode response bit-for-bit.
3802        let h = Array2::from_shape_vec((3, 3), vec![4.0, 1.0, 0.5, 1.0, 3.0, 0.8, 0.5, 0.8, 2.5])
3803            .unwrap();
3804        let inv = dense_inverse(&h);
3805        // Two ρ-columns, each supported on a single coefficient.
3806        let mut dg = Array2::<f64>::zeros((3, 2));
3807        dg[[0, 0]] = 1.3;
3808        dg[[2, 1]] = -0.7;
3809        let supports = vec![0..1usize, 2..3usize];
3810
3811        let eye: Array2<f64> = Array2::eye(3);
3812        let op = crate::sensitivity::FitSensitivity::from_projected(&eye, &inv);
3813        let full = op.mode_response(dg.view()).unwrap();
3814        let coned = op
3815            .mode_response_coned(h.view(), dg.view(), &supports)
3816            .unwrap();
3817        for i in 0..3 {
3818            for a in 0..2 {
3819                assert!(
3820                    (full[[i, a]] - coned[[i, a]]).abs() < 1e-12,
3821                    "fully-coupled mismatch at ({i},{a}): {} vs {}",
3822                    full[[i, a]],
3823                    coned[[i, a]]
3824                );
3825            }
3826        }
3827    }
3828
3829    #[test]
3830    fn coned_confines_to_component_on_decoupled_hessian() {
3831        // Block-decoupled H: blocks {0,1} and {2,3}. A column supported only in
3832        // block {0,1} must produce sensitivity zero in block {2,3}, and match
3833        // the exact solution within its own block.
3834        let mut h = Array2::<f64>::zeros((4, 4));
3835        // Block A.
3836        h[[0, 0]] = 4.0;
3837        h[[1, 1]] = 3.0;
3838        h[[0, 1]] = 1.0;
3839        h[[1, 0]] = 1.0;
3840        // Block B.
3841        h[[2, 2]] = 2.0;
3842        h[[3, 3]] = 5.0;
3843        h[[2, 3]] = 0.6;
3844        h[[3, 2]] = 0.6;
3845        let inv = dense_inverse(&h);
3846
3847        let mut dg = Array2::<f64>::zeros((4, 1));
3848        dg[[0, 0]] = 0.9;
3849        dg[[1, 0]] = -0.4;
3850        let support_range = 0..2usize;
3851        let supports = std::slice::from_ref(&support_range);
3852
3853        let eye: Array2<f64> = Array2::eye(4);
3854        let coned = crate::sensitivity::FitSensitivity::from_projected(&eye, &inv)
3855            .mode_response_coned(h.view(), dg.view(), supports)
3856            .unwrap();
3857        // Exact reference: -H⁻¹ q. Off-block entries are exactly zero already
3858        // (decoupled inverse), and the cone must preserve the in-block ones.
3859        let q = dg.column(0).to_owned();
3860        let exact = inv.dot(&q).mapv(|v| -v);
3861        for i in 0..4 {
3862            assert!(
3863                (coned[[i, 0]] - exact[[i]]).abs() < 1e-12,
3864                "decoupled mismatch at {i}: {} vs {}",
3865                coned[[i, 0]],
3866                exact[[i]]
3867            );
3868        }
3869        // Block B is outside the cone -> exactly zero.
3870        assert_eq!(coned[[2, 0]], 0.0);
3871        assert_eq!(coned[[3, 0]], 0.0);
3872    }
3873
3874    #[test]
3875    fn coned_skips_inactive_column_with_empty_support() {
3876        let h = Array2::<f64>::eye(2);
3877        let dg = Array2::<f64>::zeros((2, 1));
3878        // Inactive ρ: empty support, must be skipped without solving.
3879        let empty_support = 0..0usize;
3880        let supports = std::slice::from_ref(&empty_support);
3881        // A NaN inverse: an empty-support column must be skipped WITHOUT
3882        // solving, so the operator's finite-check never sees the NaN and the
3883        // result is `Some(zeros)`. Were the inactive column ever solved, the
3884        // NaN would propagate and `mode_response_coned` would return `None`.
3885        let eye: Array2<f64> = Array2::eye(2);
3886        let nan_inv = Array2::<f64>::from_elem((2, 2), f64::NAN);
3887        let coned = crate::sensitivity::FitSensitivity::from_projected(&eye, &nan_inv)
3888            .mode_response_coned(h.view(), dg.view(), supports)
3889            .unwrap();
3890        assert_eq!(coned[[0, 0]], 0.0);
3891        assert_eq!(coned[[1, 0]], 0.0);
3892    }
3893
3894    fn gaussian_logpdf(y: f64, mean: f64, sd: f64) -> f64 {
3895        let z = (y - mean) / sd;
3896        -0.5 * (2.0 * std::f64::consts::PI).ln() - sd.ln() - 0.5 * z * z
3897    }
3898
3899    #[test]
3900    fn stacking_single_candidate_gets_full_weight() {
3901        let log_density = Array2::from_shape_vec((3, 1), vec![-1.0, -2.0, -0.5]).unwrap();
3902        let out = solve_stacking_weights(log_density.view(), StackingConfig::default()).unwrap();
3903        assert!((out.weights[0] - 1.0).abs() < 1e-12);
3904        assert_eq!(out.weights.len(), 1);
3905    }
3906
3907    #[test]
3908    fn stacking_dominant_candidate_attracts_nearly_all_weight() {
3909        let mut log_density = Array2::<f64>::zeros((50, 2));
3910        for i in 0..50 {
3911            log_density[[i, 0]] = -0.1;
3912            log_density[[i, 1]] = -5.0;
3913        }
3914        let out = solve_stacking_weights(log_density.view(), StackingConfig::default()).unwrap();
3915        assert!(out.weights[0] > 0.99, "w0 = {}", out.weights[0]);
3916        assert!(out.weights[1] < 0.01, "w1 = {}", out.weights[1]);
3917    }
3918
3919    #[test]
3920    fn stacking_complementary_candidates_share_weight() {
3921        // Each candidate is the better predictor on its own half of the data;
3922        // stacking keeps both, unlike winner-take-all.
3923        let n = 40;
3924        let mut log_density = Array2::<f64>::zeros((n, 2));
3925        for i in 0..n {
3926            if i < n / 2 {
3927                log_density[[i, 0]] = gaussian_logpdf(0.0, 0.0, 0.5);
3928                log_density[[i, 1]] = gaussian_logpdf(0.0, 1.5, 0.5);
3929            } else {
3930                log_density[[i, 0]] = gaussian_logpdf(0.0, 1.5, 0.5);
3931                log_density[[i, 1]] = gaussian_logpdf(0.0, 0.0, 0.5);
3932            }
3933        }
3934        let out = solve_stacking_weights(log_density.view(), StackingConfig::default()).unwrap();
3935        assert!(
3936            out.weights[0] > 0.2 && out.weights[0] < 0.8,
3937            "w0 = {}",
3938            out.weights[0]
3939        );
3940        assert!((out.weights.sum() - 1.0).abs() < 1e-9);
3941    }
3942
3943    #[test]
3944    fn stacking_weights_stay_on_the_simplex() {
3945        let log_density = Array2::from_shape_vec(
3946            (3, 3),
3947            vec![-1.0, -2.0, -3.0, -2.5, -1.0, -2.0, -3.0, -2.0, -1.0],
3948        )
3949        .unwrap();
3950        let out = solve_stacking_weights(log_density.view(), StackingConfig::default()).unwrap();
3951        assert!((out.weights.sum() - 1.0).abs() < 1e-9);
3952        assert!(out.weights.iter().all(|&w| w >= -1e-12));
3953    }
3954
3955    #[test]
3956    fn stacking_solution_satisfies_the_simplex_kkt_certificate() {
3957        // Recompute the KKT residual from scratch at the returned weights:
3958        // g_k = mean_i p_ik / mix_i must satisfy g_k <= 1 (+tol) everywhere
3959        // and w_k * |g_k - 1| <= tol. The objective is concave, so this
3960        // certifies the GLOBAL optimum, not merely a stationary iterate.
3961        let log_density = Array2::from_shape_vec(
3962            (5, 2),
3963            vec![-0.2, -3.0, -3.0, -0.2, -0.5, -1.5, -1.5, -0.5, -0.1, -2.0],
3964        )
3965        .unwrap();
3966        let config = StackingConfig::default();
3967        let out = solve_stacking_weights(log_density.view(), config).unwrap();
3968        assert!(out.certificate.residual() <= config.kkt_tol);
3969        let n = log_density.nrows();
3970        for k in 0..2 {
3971            let mut g = 0.0_f64;
3972            for i in 0..n {
3973                let mix: f64 = (0..2)
3974                    .map(|c| out.weights[c] * log_density[[i, c]].exp())
3975                    .sum();
3976                g += log_density[[i, k]].exp() / mix;
3977            }
3978            g /= n as f64;
3979            assert!(
3980                g <= 1.0 + config.kkt_tol,
3981                "stationarity violated for candidate {k}: g = {g}"
3982            );
3983            assert!(
3984                out.weights[k] * (g - 1.0).abs() <= config.kkt_tol * (1.0 + 1e-6),
3985                "complementary slackness violated for candidate {k}: w = {}, g = {g}",
3986                out.weights[k]
3987            );
3988        }
3989    }
3990
3991    #[test]
3992    fn stacking_near_tied_boundary_uses_newton_not_millions_of_em_steps() {
3993        let log_density =
3994            Array2::from_shape_fn(
3995                (64, 2),
3996                |(_, candidate)| {
3997                    if candidate == 0 { 0.0 } else { -1.0e-6 }
3998                },
3999            );
4000        let out = solve_stacking_weights(log_density.view(), StackingConfig::default()).unwrap();
4001        assert!(out.weights[0] >= 1.0 - StackingConfig::default().kkt_tol);
4002        assert!(out.iterations < 8, "iterations = {}", out.iterations);
4003    }
4004
4005    #[test]
4006    fn stacking_dead_candidate_column_gets_zero_weight() {
4007        let log_density = Array2::from_shape_vec(
4008            (3, 2),
4009            vec![
4010                -1.0,
4011                f64::NEG_INFINITY,
4012                -2.0,
4013                f64::NEG_INFINITY,
4014                -0.5,
4015                f64::NEG_INFINITY,
4016            ],
4017        )
4018        .unwrap();
4019        let out = solve_stacking_weights(log_density.view(), StackingConfig::default()).unwrap();
4020        assert_eq!(out.weights[1], 0.0);
4021        assert!((out.weights[0] - 1.0).abs() < 1e-12);
4022    }
4023
4024    #[test]
4025    fn stacking_rejects_invalid_and_unscorable_rows() {
4026        let log_density = Array2::from_shape_vec(
4027            (3, 2),
4028            vec![-1.0, -2.0, f64::NAN, f64::NEG_INFINITY, -2.0, -1.0],
4029        )
4030        .unwrap();
4031        assert!(matches!(
4032            solve_stacking_weights(log_density.view(), StackingConfig::default()),
4033            Err(StackingError::InvalidInput { .. })
4034        ));
4035        let unscorable = Array2::from_shape_vec(
4036            (2, 2),
4037            vec![-1.0, -2.0, f64::NEG_INFINITY, f64::NEG_INFINITY],
4038        )
4039        .unwrap();
4040        assert!(matches!(
4041            solve_stacking_weights(unscorable.view(), StackingConfig::default()),
4042            Err(StackingError::InvalidInput { .. })
4043        ));
4044    }
4045
4046    fn two_cluster_mixture_data() -> Array2<f64> {
4047        Array2::from_shape_vec(
4048            (12, 1),
4049            vec![
4050                -2.2, -2.0, -1.9, -2.1, -1.8, -2.05, 1.8, 2.0, 2.2, 1.9, 2.1, 2.05,
4051            ],
4052        )
4053        .unwrap()
4054    }
4055
4056    #[test]
4057    fn gaussian_mixture_monotonicity_resolves_composite_map_noise_2264() {
4058        let objective_scale = 1.0;
4059        let composite_resolution = f64::EPSILON.sqrt() * objective_scale;
4060        let uncertainty = gaussian_mixture_monotonicity_uncertainty(objective_scale, 0.0, 0.0);
4061        assert_eq!(uncertainty, composite_resolution);
4062
4063        let noise_scale_decrease = -0.5 * composite_resolution;
4064        assert!(noise_scale_decrease >= -uncertainty);
4065        let resolved_decrease = -2.0 * composite_resolution;
4066        assert!(resolved_decrease < -uncertainty);
4067
4068        let larger_reduction_bound = 2.0 * composite_resolution;
4069        assert_eq!(
4070            gaussian_mixture_monotonicity_uncertainty(objective_scale, larger_reduction_bound, 0.0),
4071            larger_reduction_bound,
4072        );
4073    }
4074
4075    #[test]
4076    fn gaussian_mixture_issue_scale_negative_step_is_within_computed_uncertainty_2264() {
4077        // Recorded issue mechanism: a signed mean-log-likelihood step of
4078        // -1.4e-13 was rejected even though it is unresolved at the scale of
4079        // the composite EM map. The admissible decrease below is derived only
4080        // from that map's observed objective scale and arithmetic reduction
4081        // bounds; the recorded step is an input to the decision, not a new
4082        // tolerance.
4083        let objective_scale = 1.0;
4084        let recorded_step = -1.4e-13;
4085        let uncertainty = gaussian_mixture_monotonicity_uncertainty(objective_scale, 0.0, 0.0);
4086        let certificate = GaussianMixtureCertificate {
4087            mean_log_likelihood: -objective_scale,
4088            mean_log_likelihood_gain: recorded_step,
4089            monotonicity_uncertainty: uncertainty,
4090            objective_residual: recorded_step.abs() / objective_scale,
4091            objective_tolerance: f64::EPSILON.sqrt(),
4092            parameter_residual: 0.0,
4093            parameter_tolerance: f64::EPSILON.sqrt(),
4094            contraction_rate: None,
4095            projected_iterations_to_tolerance: None,
4096        };
4097
4098        assert_eq!(
4099            certificate.monotonicity_uncertainty,
4100            f64::EPSILON.sqrt() * objective_scale,
4101            "reported uncertainty must be the computed composite-map resolution"
4102        );
4103        assert!(
4104            certificate.mean_log_likelihood_gain >= -certificate.monotonicity_uncertainty,
4105            "the recorded noise-scale decrease must not be a monotonicity violation"
4106        );
4107    }
4108
4109    #[test]
4110    fn gaussian_mixture_below_roundoff_positive_gain_can_certify_2264() {
4111        // Recorded issue mechanism: a +6.6e-15 gain with a 1.5e-14 reduction
4112        // bound exhausted instead of certifying. A below-resolution gain is a
4113        // valid objective fixed point only when the independent parameter-map
4114        // residual also clears its configured tolerance.
4115        let objective_scale = 1.0;
4116        let recorded_gain = 6.6e-15;
4117        let recorded_reduction_bound = 1.5e-14;
4118        let objective_tolerance = f64::EPSILON.sqrt();
4119        let parameter_tolerance = f64::EPSILON.sqrt();
4120        let uncertainty = gaussian_mixture_monotonicity_uncertainty(
4121            objective_scale,
4122            recorded_reduction_bound,
4123            0.0,
4124        );
4125        let certificate = GaussianMixtureCertificate {
4126            mean_log_likelihood: -objective_scale,
4127            mean_log_likelihood_gain: recorded_gain,
4128            monotonicity_uncertainty: uncertainty,
4129            objective_residual: recorded_gain / objective_scale,
4130            objective_tolerance,
4131            parameter_residual: 0.5 * parameter_tolerance,
4132            parameter_tolerance,
4133            contraction_rate: None,
4134            projected_iterations_to_tolerance: None,
4135        };
4136
4137        assert_eq!(
4138            certificate.monotonicity_uncertainty,
4139            (f64::EPSILON.sqrt() * objective_scale).max(recorded_reduction_bound),
4140            "reported uncertainty must come from the composite-map and reduction bounds"
4141        );
4142        assert!(certificate.mean_log_likelihood_gain >= -certificate.monotonicity_uncertainty);
4143        assert!(certificate.objective_residual <= certificate.objective_tolerance);
4144        assert!(certificate.parameter_residual <= certificate.parameter_tolerance);
4145    }
4146
4147    #[test]
4148    fn gaussian_mixture_certificate_quotients_duplicate_component_mass_exchange_2324() {
4149        // Two identical components can exchange arbitrary mass without
4150        // changing the mixture density. Labeled component measures are
4151        // therefore not identifiable even when every component has positive
4152        // mass; the empirical predictive-density certificate must quotient
4153        // this singular direction exactly.
4154        let data = array![[-1.0], [0.0], [2.0]];
4155        let means = array![[0.0], [0.0]];
4156        let covariance = vec![array![[1.0]], array![[1.0]]];
4157        let weights = array![0.25, 0.75];
4158        let redistributed_weights = array![0.5, 0.5];
4159        let previous = mixture_e_step(data.view(), &weights, &means, &covariance).unwrap();
4160        let redistributed =
4161            mixture_e_step(data.view(), &redistributed_weights, &means, &covariance).unwrap();
4162        let residual = empirical_predictive_density_residual(
4163            &previous.row_log_likelihoods,
4164            &redistributed.row_log_likelihoods,
4165        )
4166        .unwrap();
4167        assert!(residual <= 4.0 * f64::EPSILON);
4168
4169        // A resolved change in the represented density remains visible.
4170        let shifted_means = array![[0.01], [0.0]];
4171        let shifted =
4172            mixture_e_step(data.view(), &weights, &shifted_means, &covariance).unwrap();
4173        let shifted_residual = empirical_predictive_density_residual(
4174            &previous.row_log_likelihoods,
4175            &shifted.row_log_likelihoods,
4176        )
4177        .unwrap();
4178        assert!(shifted_residual > f64::EPSILON.sqrt());
4179    }
4180
4181    #[test]
4182    fn gaussian_mixture_fit_certificate_describes_the_exact_returned_iterate() {
4183        let data = two_cluster_mixture_data();
4184        let config = GaussianMixtureConfig::default();
4185        let fit = fit_gaussian_mixture(data.view(), 2, config).unwrap();
4186        let certificate = fit.certificate();
4187        assert!(certificate.objective_residual <= certificate.objective_tolerance);
4188        assert!(certificate.parameter_residual <= certificate.parameter_tolerance);
4189
4190        let checkpoint = GaussianMixtureCheckpoint {
4191            weights: fit.weights.clone(),
4192            means: fit.means.clone(),
4193            covariances: fit.covariances.clone(),
4194            mean_log_likelihood: certificate.mean_log_likelihood,
4195            completed_iterations: fit.iterations,
4196            data_fingerprint: mixture_data_fingerprint(data.view()),
4197            covariance_floor: config.covariance_floor,
4198        };
4199        let current = mixture_e_step(
4200            data.view(),
4201            &checkpoint.weights,
4202            &checkpoint.means,
4203            &checkpoint.covariances,
4204        )
4205        .unwrap();
4206        let (weights, means, covariances) = mixture_m_step(
4207            data.view(),
4208            current.responsibilities.view(),
4209            config.covariance_floor,
4210        )
4211        .unwrap();
4212        let next = mixture_e_step(data.view(), &weights, &means, &covariances).unwrap();
4213        let residual = empirical_predictive_density_residual(
4214            &current.row_log_likelihoods,
4215            &next.row_log_likelihoods,
4216        )
4217        .unwrap();
4218        assert!(residual <= config.parameter_tol);
4219        assert_eq!(certificate.mean_log_likelihood, current.mean_log_likelihood);
4220        assert_eq!(
4221            certificate.mean_log_likelihood_gain,
4222            next.mean_log_likelihood - current.mean_log_likelihood
4223        );
4224        assert_eq!(
4225            certificate.monotonicity_uncertainty,
4226            gaussian_mixture_monotonicity_uncertainty(
4227                current
4228                    .mean_log_likelihood
4229                    .abs()
4230                    .max(next.mean_log_likelihood.abs())
4231                    .max(1.0),
4232                current.mean_log_likelihood_roundoff,
4233                next.mean_log_likelihood_roundoff,
4234            )
4235        );
4236        assert_eq!(certificate.parameter_residual, residual);
4237        assert!(
4238            (next.mean_log_likelihood - current.mean_log_likelihood).abs()
4239                / current
4240                    .mean_log_likelihood
4241                    .abs()
4242                    .max(next.mean_log_likelihood.abs())
4243                    .max(1.0)
4244                <= config.loglik_tol
4245        );
4246    }
4247
4248    #[test]
4249    fn gaussian_mixture_bic_is_finite_with_an_active_covariance_floor() {
4250        // Component zero is exactly one-dimensional: its x coordinate never
4251        // changes, so the constrained MLE has one covariance eigenvalue at the
4252        // configured floor. The old BHHH determinant had identically-zero
4253        // mean-x and covariance-xy score columns and therefore rejected this
4254        // perfectly valid constrained predictive density as non-SPD.
4255        let per_cluster = 45usize;
4256        let mut data = Array2::<f64>::zeros((2 * per_cluster, 2));
4257        for sample in 0..per_cluster {
4258            let phase = std::f64::consts::TAU * sample as f64 / per_cluster as f64;
4259            data[[2 * sample, 0]] = -2.0;
4260            data[[2 * sample, 1]] = 0.08 * phase.sin();
4261            data[[2 * sample + 1, 0]] = 2.0 + 0.12 * phase.cos();
4262            data[[2 * sample + 1, 1]] = 0.08 * phase.sin();
4263        }
4264        let fit = fit_gaussian_mixture(data.view(), 2, GaussianMixtureConfig::default())
4265            .expect("the covariance floor defines a valid constrained mixture fit");
4266        let bic = fit.bic();
4267        assert!(bic.is_finite());
4268        assert_eq!(
4269            bic,
4270            -fit.loglik + 0.5 * fit.num_free_parameters() as f64 * (data.nrows() as f64).ln()
4271        );
4272    }
4273
4274    fn seven_clusters_on_a_circle_2262() -> Array2<f64> {
4275        let clusters = 7usize;
4276        let per_cluster = 32usize;
4277        let mut data = Array2::<f64>::zeros((clusters * per_cluster, 2));
4278        for cluster in 0..clusters {
4279            let angle = std::f64::consts::TAU * cluster as f64 / clusters as f64;
4280            let (sin_angle, cos_angle) = angle.sin_cos();
4281            for sample in 0..per_cluster {
4282                let phase = std::f64::consts::TAU * sample as f64 / per_cluster as f64;
4283                // Vary the within-cluster radius while preserving its angular
4284                // symmetry. A literal constant-radius micro-circle makes the
4285                // Gaussian scale score identically zero and its empirical
4286                // Fisher singular, which is not a Gaussian-cluster fixture.
4287                let local_radius = 0.035 * (1.0 + 0.3 * (3.0 * phase).cos());
4288                let radial_noise = local_radius * phase.cos();
4289                let tangent_noise = local_radius * phase.sin();
4290                let radius = 2.0 + radial_noise;
4291                let row = cluster * per_cluster + sample;
4292                data[[row, 0]] = 0.4 + radius * cos_angle - tangent_noise * sin_angle;
4293                data[[row, 1]] = -0.3 + radius * sin_angle + tangent_noise * cos_angle;
4294            }
4295        }
4296        data
4297    }
4298
4299    #[test]
4300    fn circular_gaussian_density_avoids_extreme_scale_intermediate_overflow() {
4301        let noise_variance = f64::MAX / 2.0;
4302        let fit =
4303            CircularGaussianFit2d::from_parameters([0.0, 0.0], 1.1e154, noise_variance).unwrap();
4304        // Both `2πs` and `Rr` overflow if formed directly, although their log
4305        // and the ratio `Rr/s` are representable.
4306        let center_log_density = fit.log_density(0.0, 0.0);
4307        let off_center_log_density = fit.log_density(1.7e154, 0.0);
4308        assert!(center_log_density.is_finite());
4309        assert!(off_center_log_density.is_finite());
4310        let expected_center = -std::f64::consts::TAU.ln()
4311            - noise_variance.ln()
4312            - 0.5 * (fit.radius() / noise_variance.sqrt()).powi(2);
4313        assert_eq!(center_log_density, expected_center);
4314    }
4315
4316    #[test]
4317    fn ring_of_clusters_fit_is_stationary_and_complexity_priced_2262() {
4318        let data = seven_clusters_on_a_circle_2262();
4319        let config = GaussianMixtureConfig::default();
4320        let fit = fit_ring_gaussian_mixture(data.view(), 7, config).unwrap();
4321        let certificate = fit.certificate();
4322        assert!(certificate.objective_residual <= certificate.objective_tolerance);
4323        assert!(certificate.parameter_residual <= certificate.parameter_tolerance);
4324        assert_eq!(fit.num_free_parameters(), 17);
4325        assert!((fit.center()[0] - 0.4).abs() < 0.05);
4326        assert!((fit.center()[1] + 0.3).abs() < 0.05);
4327        assert!((fit.radius() - 2.0).abs() < 0.05);
4328        assert!(fit.variance().is_finite() && fit.variance() > 0.0);
4329        assert!(
4330            fit.per_point_log_density(data.view())
4331                .unwrap()
4332                .iter()
4333                .all(|value| value.is_finite())
4334        );
4335        assert!(fit.bic().is_finite());
4336
4337        let free = fit_gaussian_mixture(data.view(), 7, config).unwrap();
4338        assert_eq!(free.num_free_parameters(), 41);
4339        assert!(fit.num_free_parameters() < free.num_free_parameters());
4340    }
4341
4342    #[test]
4343    fn ring_certificate_uses_identifiable_component_means() {
4344        // The points (.3, ±sqrt(.91)) lie on both unit circles centered at
4345        // (0, 0) and (.6, 0). Repeating one point gives three labelled
4346        // components. Thus center and directions move by O(1) while every
4347        // component mean—and therefore the represented mixture density—is
4348        // bit-identical.
4349        let y = 0.91_f64.sqrt();
4350        let weights = Array1::from_vec(vec![0.2, 0.3, 0.5]);
4351        let previous = RingMixtureState {
4352            weights: weights.clone(),
4353            center: Array1::from_vec(vec![0.0, 0.0]),
4354            radius: 1.0,
4355            directions: Array2::from_shape_vec((3, 2), vec![0.3, y, 0.3, -y, 0.3, y]).unwrap(),
4356            variance: 0.25,
4357            mean_log_likelihood: -1.0,
4358            completed_iterations: 10,
4359        };
4360        let next = RingMixtureState {
4361            weights,
4362            center: Array1::from_vec(vec![0.6, 0.0]),
4363            radius: 1.0,
4364            directions: Array2::from_shape_vec((3, 2), vec![-0.3, y, -0.3, -y, -0.3, y]).unwrap(),
4365            variance: 0.25,
4366            mean_log_likelihood: -1.0,
4367            completed_iterations: 11,
4368        };
4369        assert!(relative_parameter_step(previous.center[0], next.center[0]) > 0.5);
4370        let data = array![[0.3, y], [0.3, -y], [1.0, 0.0]];
4371        let previous_e_step = ring_mixture_e_step(data.view(), &previous).unwrap();
4372        let next_e_step = ring_mixture_e_step(data.view(), &next).unwrap();
4373        let residual = empirical_predictive_density_residual(
4374            &previous_e_step.row_log_likelihoods,
4375            &next_e_step.row_log_likelihoods,
4376        )
4377        .unwrap();
4378        assert_eq!(residual, 0.0);
4379    }
4380
4381    #[test]
4382    fn ring_certificate_quotients_duplicate_component_mass_exchange_2324() {
4383        let previous = RingMixtureState {
4384            weights: array![0.2, 0.3, 0.5],
4385            center: array![0.0, 0.0],
4386            radius: 1.0,
4387            directions: array![[1.0, 0.0], [1.0, 0.0], [0.0, 1.0]],
4388            variance: 1.0,
4389            mean_log_likelihood: -1.0,
4390            completed_iterations: 10,
4391        };
4392        let next = RingMixtureState {
4393            weights: array![0.4, 0.1, 0.5],
4394            center: previous.center.clone(),
4395            radius: previous.radius,
4396            directions: previous.directions.clone(),
4397            variance: previous.variance,
4398            mean_log_likelihood: -1.0,
4399            completed_iterations: 11,
4400        };
4401        let data = array![[1.0, 0.0], [0.0, 1.0], [-1.0, 0.0]];
4402        let previous_e_step = ring_mixture_e_step(data.view(), &previous).unwrap();
4403        let next_e_step = ring_mixture_e_step(data.view(), &next).unwrap();
4404        let residual = empirical_predictive_density_residual(
4405            &previous_e_step.row_log_likelihoods,
4406            &next_e_step.row_log_likelihoods,
4407        )
4408        .unwrap();
4409        assert!(residual <= 4.0 * f64::EPSILON);
4410    }
4411
4412    // -----------------------------------------------------------------------
4413    // #1026 hybrid curved + linear-tail split-selection
4414    // -----------------------------------------------------------------------
4415
4416    /// Build the two candidate parameterizations for one atom slot the way the
4417    /// fit would: the linear special case (one decoder direction, `Θ = 0`,
4418    /// `P_linear` params) and the curved candidate (`latent_dim` ≥ 1, more
4419    /// params, fitted turning `theta`). The curved candidate's likelihood is the
4420    /// linear likelihood MINUS `curved_loglik_gain` of NLE (curvature it captures
4421    /// the secant cannot), so the nesting invariant `curved_loglik ≥ linear` is
4422    /// honored: a straight feature has zero gain, a turning feature a positive
4423    /// gain that grows with Θ. The rank-aware Laplace normalizer charges the
4424    /// extra `½(P_curved − P_linear)·log(2π)` for the curved parameters, so the
4425    /// evidence comparison is the real `Θ/√ε` crossover.
4426    fn hybrid_slot(
4427        linear_nle: f64,
4428        p_linear: usize,
4429        latent_dim: usize,
4430        p_curved: usize,
4431        theta: f64,
4432        curved_loglik_gain: f64,
4433    ) -> Vec<HybridAtomCandidate> {
4434        let param_price =
4435            0.5 * (p_curved as f64 - p_linear as f64) * (2.0 * std::f64::consts::PI).ln();
4436        let curved_nle = linear_nle - curved_loglik_gain + param_price;
4437        vec![
4438            HybridAtomCandidate::linear(linear_nle, p_linear),
4439            HybridAtomCandidate::curved(latent_dim, curved_nle, p_curved, Some(theta)),
4440        ]
4441    }
4442
4443    #[test]
4444    fn hybrid_dominance_floor_selects_linear_when_turning_is_zero() {
4445        // A perfectly straight curved fit (Θ = 0) gains no likelihood over its
4446        // linear sub-model but pays more parameters → linear must win, by
4447        // construction, even if finite-sample evidence noise nudged the curved
4448        // NLE slightly below linear.
4449        let slot = hybrid_slot(100.0, 2, 1, 5, 0.0, 0.0);
4450        let choice = select_hybrid_atom(&slot).unwrap();
4451        assert!(choice.param.is_linear());
4452        assert_eq!(choice.param, HybridAtomParam::Linear);
4453        // The exact-zero guard fires regardless of the evidence margin sign.
4454        assert!(choice.curved_turning.unwrap() <= HYBRID_LINEAR_TURNING_FLOOR);
4455    }
4456
4457    #[test]
4458    fn hybrid_selects_curved_when_turning_pays_for_itself() {
4459        // A genuinely turning feature (Θ = 2π, a full loop): the curved fit
4460        // captures enough curvature that, even charged the extra-parameter price,
4461        // its NLE drops below the linear secant's → curved wins.
4462        let slot = hybrid_slot(100.0, 2, 1, 5, 2.0 * std::f64::consts::PI, 30.0);
4463        let choice = select_hybrid_atom(&slot).unwrap();
4464        assert_eq!(choice.param, HybridAtomParam::Curved { latent_dim: 1 });
4465        // The curved fit won a strictly positive evidence margin.
4466        assert!(choice.curved_evidence_margin > 0.0);
4467    }
4468
4469    #[test]
4470    fn hybrid_keeps_linear_when_curvature_doesnt_pay_its_price() {
4471        // A barely-curved feature (small Θ): the curved fit recovers only a sliver
4472        // of likelihood, not enough to cover the extra-parameter price → the
4473        // dominance floor keeps the linear tail.
4474        let slot = hybrid_slot(100.0, 2, 1, 5, 0.05, 0.1);
4475        let choice = select_hybrid_atom(&slot).unwrap();
4476        assert!(choice.param.is_linear());
4477        assert!(choice.curved_evidence_margin <= 0.0);
4478    }
4479
4480    #[test]
4481    fn hybrid_tie_breaks_to_the_cheaper_linear_atom() {
4482        // Exact NLE tie (above the turning floor so the evidence path decides):
4483        // the cheaper linear atom wins, preserving strict generalization — the
4484        // hybrid never pays for curvature it does not need.
4485        let theta = 0.5; // above the floor → evidence path, not the exact guard
4486        let nle = 42.0;
4487        let slot = vec![
4488            HybridAtomCandidate::linear(nle, 2),
4489            HybridAtomCandidate::curved(1, nle, 5, Some(theta)),
4490        ];
4491        let choice = select_hybrid_atom(&slot).unwrap();
4492        assert!(choice.param.is_linear());
4493        assert_eq!(choice.num_parameters, 2);
4494    }
4495
4496    #[test]
4497    fn hybrid_split_reduces_to_pure_linear_when_all_features_are_straight() {
4498        // Every slot's curved candidate has Θ → 0 (flat features everywhere): the
4499        // dominance floor fires at every slot → the hybrid recovers the pure-
4500        // linear dictionary exactly. This is the `all Θ → 0` limit (3).
4501        let slots: Vec<Vec<HybridAtomCandidate>> = (0..6)
4502            .map(|i| hybrid_slot(50.0 + i as f64, 2, 1, 5, 0.0, 0.0))
4503            .collect();
4504        let split = select_hybrid_split(&slots).unwrap();
4505        assert!(split.is_pure_linear());
4506        assert_eq!(split.curved_atom_count, 0);
4507        assert_eq!(split.linear_atom_count(), 6);
4508        // Summed NLE equals the pure-linear baseline (every slot chose linear).
4509        let pure_linear: f64 = (0..6).map(|i| 50.0 + i as f64).sum();
4510        assert!((split.total_negative_log_evidence - pure_linear).abs() < 1e-12);
4511    }
4512
4513    #[test]
4514    fn hybrid_split_reduces_to_pure_curved_when_every_feature_curves() {
4515        // Every slot's feature turns enough (Θ = 2π, large likelihood gain) that
4516        // curved beats linear everywhere → the pure-curved limit (3).
4517        let slots: Vec<Vec<HybridAtomCandidate>> = (0..5)
4518            .map(|i| hybrid_slot(80.0 + i as f64, 2, 1, 5, 2.0 * std::f64::consts::PI, 40.0))
4519            .collect();
4520        let split = select_hybrid_split(&slots).unwrap();
4521        assert!(split.is_pure_curved());
4522        assert_eq!(split.curved_atom_count, 5);
4523        assert_eq!(split.linear_atom_count(), 0);
4524    }
4525
4526    #[test]
4527    fn hybrid_split_on_mixed_dictionary_picks_curved_for_circles_linear_for_directions() {
4528        // Mixed synthetic: slots 0..3 are CIRCLE features (high turning Θ = 2π,
4529        // the curved fit captures the loop), slots 3..7 are LINEAR DIRECTIONS
4530        // (straight, Θ = 0). The evidence split must select curved for the
4531        // circles and linear for the directions — and the hybrid's summed
4532        // evidence must be ≤ the summed per-slot LINEAR-candidate NLE (each
4533        // slot's best straight line fit to its response residual). This is a
4534        // data-level match-or-beat dominance (#1202: linear is the curved
4535        // family's nested Θ = 0 sub-model on common data), and holds because each
4536        // slot picks the argmin of its two common-data candidates.
4537        let mut slots: Vec<Vec<HybridAtomCandidate>> = Vec::new();
4538        let mut pure_linear_baseline = 0.0_f64;
4539        // Three circle features: a curved atom replaces ~10-30 linear secants, so
4540        // the curved fit buys a large likelihood gain that dwarfs its param price.
4541        for i in 0..3 {
4542            let linear_nle = 120.0 + 3.0 * i as f64;
4543            pure_linear_baseline += linear_nle;
4544            slots.push(hybrid_slot(
4545                linear_nle,
4546                2,
4547                1,
4548                5,
4549                2.0 * std::f64::consts::PI,
4550                35.0,
4551            ));
4552        }
4553        // Four straight linear directions: zero turning, the linear special case
4554        // is optimal — a curved atom buys nothing and only costs parameters.
4555        for i in 0..4 {
4556            let linear_nle = 90.0 + 2.0 * i as f64;
4557            pure_linear_baseline += linear_nle;
4558            slots.push(hybrid_slot(linear_nle, 2, 1, 5, 0.0, 0.0));
4559        }
4560
4561        let split = select_hybrid_split(&slots).unwrap();
4562
4563        // The first three (circles) chose curved; the last four (directions) chose
4564        // linear.
4565        for (idx, choice) in split.atoms.iter().enumerate() {
4566            if idx < 3 {
4567                assert_eq!(
4568                    choice.param,
4569                    HybridAtomParam::Curved { latent_dim: 1 },
4570                    "circle slot {idx} should select curved"
4571                );
4572            } else {
4573                assert!(
4574                    choice.param.is_linear(),
4575                    "direction slot {idx} should select linear"
4576                );
4577            }
4578        }
4579        assert_eq!(split.curved_atom_count, 3);
4580        assert_eq!(split.linear_atom_count(), 4);
4581
4582        // The hybrid's summed negative-log-evidence is ≤ the summed per-slot
4583        // LINEAR-candidate NLE (each slot's best straight line fit to its response
4584        // residual): the per-slot argmin can only lower the sum. This is a
4585        // data-level match-or-beat dominance (#1202): linear is the curved
4586        // family's nested Θ = 0 sub-model on common data.
4587        assert!(
4588            split.total_negative_log_evidence <= pure_linear_baseline + 1e-9,
4589            "hybrid NLE {} must be <= summed linear-candidate NLE {}",
4590            split.total_negative_log_evidence,
4591            pure_linear_baseline
4592        );
4593        // And strictly better, because the curved circle slots paid off.
4594        assert!(split.total_negative_log_evidence < pure_linear_baseline);
4595    }
4596
4597    #[test]
4598    fn hybrid_split_rejects_empty_slot() {
4599        let slots = vec![hybrid_slot(10.0, 2, 1, 5, 0.0, 0.0), Vec::new()];
4600        assert!(select_hybrid_split(&slots).is_err());
4601    }
4602
4603    // ── #1362: compare_models must Occam-penalise a pure-noise smooth ────────
4604    //
4605    // These tests pin the ranking contract directly on `compare_reml_fits` with
4606    // controlled (score, edf, log_lik) inputs taken from the actual #1362
4607    // reproduction (Rust `reml_score` of `y ~ s(x)` vs `y ~ s(x) + s(z)` at
4608    // n=700). They do not need a fitted GAM or a Python wheel.
4609
4610    fn cand(name: &str, score: f64, edf: f64, log_lik: f64) -> RemlCandidate {
4611        RemlCandidate {
4612            index: 0,
4613            name: name.to_string(),
4614            score,
4615            edf,
4616            log_lik,
4617            family: None,
4618            n_obs: None,
4619        }
4620    }
4621
4622    #[test]
4623    fn ranking_score_is_conditional_aic_when_loglik_and_edf_present() {
4624        // AIC = -2ℓ + 2·edf.
4625        let c = cand("m", /*score (ignored)*/ 999.0, 6.748, -32.0866);
4626        let expected = -2.0 * -32.0866 + 2.0 * 6.748;
4627        assert!((c.ranking_score().expect("finite AIC") - expected).abs() < 1e-9);
4628    }
4629
4630    #[test]
4631    fn ranking_score_refuses_non_finite_log_likelihood_instead_of_using_reml() {
4632        let c = RemlCandidate {
4633            index: 0,
4634            name: "m".to_string(),
4635            score: 151.28,
4636            edf: 6.0,
4637            log_lik: f64::NAN,
4638            family: None,
4639            n_obs: None,
4640        };
4641        let error = c
4642            .ranking_score()
4643            .expect_err("raw REML must not replace a missing likelihood");
4644        assert!(error.contains("requires finite log_likelihood"));
4645        assert!(error.contains("not a substitute ranking estimand"));
4646    }
4647
4648    #[test]
4649    fn compare_models_rejects_pure_noise_smooth_despite_lower_evidence() {
4650        // Seed-3000 numbers from the #1362 Rust reproduction:
4651        //   small (y ~ s(x)):      reml=180.526, edf=6.748,  loglik=-32.0866
4652        //   big   (y ~ s(x)+s(z)): reml=177.404, edf=14.250, loglik=-32.1212
4653        // The big (noise-augmented) model has the LOWER (apparently better) raw
4654        // REML evidence, yet it spends ~7.5 extra EDF fitting noise without
4655        // improving the likelihood. The winner must be the SMALL model.
4656        let small = cand("small", 180.526, 6.748, -32.0866);
4657        let big = cand("big", 177.404, 14.250, -32.1212);
4658
4659        // Sanity: raw evidence (the broken headline) prefers big.
4660        assert!(big.score < small.score);
4661
4662        let cmp = compare_reml_fits(vec![small, big]).expect("compare");
4663        assert_eq!(
4664            cmp.winner, "small",
4665            "compare_models must Occam-penalise the pure-noise smooth and pick the smaller model"
4666        );
4667        // The score table still reports the raw evidence headline unchanged, so
4668        // Model.evidence / evidence_ratio_vs stay consistent with the table.
4669        let small_row = cmp
4670            .score_table
4671            .iter()
4672            .find(|r| r.name == "small")
4673            .expect("small row");
4674        let big_row = cmp
4675            .score_table
4676            .iter()
4677            .find(|r| r.name == "big")
4678            .expect("big row");
4679        assert!((small_row.reml_score - 180.526).abs() < 1e-9);
4680        assert!((big_row.reml_score - 177.404).abs() < 1e-9);
4681    }
4682
4683    #[test]
4684    fn ranking_evidence_ratio_is_akaike_evidence_ratio_not_its_square() {
4685        // Issue #2124: `ranking_score` is the conditional AIC (`−2ℓ + 2·edf`), a
4686        // −2·log / deviance-scale cost. For an AIC gap Δ the Akaike evidence ratio
4687        // (Burnham & Anderson) is `exp(−½Δ)`, so the winner-over-loser
4688        // `evidence_ratio` must be `exp(½Δ)` — NOT `exp(Δ)`, which squares it.
4689        //
4690        // Winner: AIC 0 (loglik 0, edf 0). Loser: AIC = 27.68 (loglik −13.84,
4691        // edf 0), matching the ΔAIC in the issue repro. Raw REML scores are set
4692        // distinct (100 vs 110) to lock the scoping: the raw score_table path
4693        // must stay `exp(Δreml)` with NO halving.
4694        let delta_aic = 27.68_f64;
4695        let winner = cand("winner", 100.0, 0.0, 0.0);
4696        let loser = cand("loser", 110.0, 0.0, -delta_aic / 2.0);
4697
4698        let cmp = compare_reml_fits(vec![winner, loser]).expect("compare");
4699        assert_eq!(cmp.winner, "winner");
4700
4701        let loser_row = cmp
4702            .ranking
4703            .iter()
4704            .find(|r| r.name == "loser")
4705            .expect("loser ranking row");
4706
4707        // The AIC gap FIELD stays on the AIC scale, unchanged (issue #2124).
4708        assert!((loser_row.delta - delta_aic).abs() < 1e-9);
4709
4710        // The evidence ratio is the Akaike ratio exp(½·ΔAIC) = exp(13.84)
4711        // ≈ 1.03e6 — NOT the squared exp(27.68) ≈ 1.05e12 the bug reported.
4712        let expected = (0.5 * delta_aic).exp();
4713        assert!(
4714            (loser_row.evidence_ratio / expected - 1.0).abs() < 1e-9,
4715            "ranking evidence_ratio {} should be exp(½ΔAIC)={}, not exp(ΔAIC)={}",
4716            loser_row.evidence_ratio,
4717            expected,
4718            delta_aic.exp()
4719        );
4720        // Explicit anti-regression: it must not be the squared ratio.
4721        assert!(loser_row.evidence_ratio < delta_aic.exp() * 0.5);
4722
4723        // Scoping lock (issue #2124): the RAW-REML score_table path is untouched —
4724        // its best-over-model Bayes factor is `exp(Δreml)` with NO halving. Raw
4725        // scores 100 (winner) vs 110 (loser) give Δreml = 10, so the loser's raw
4726        // Bayes factor is exp(10), not exp(5).
4727        let loser_score_row = cmp
4728            .score_table
4729            .iter()
4730            .find(|r| r.name == "loser")
4731            .expect("loser score row");
4732        let expected_reml_bf = 10.0_f64.exp();
4733        assert!(
4734            (loser_score_row.bayes_factor_best_over_model / expected_reml_bf - 1.0).abs() < 1e-9,
4735            "raw-REML bayes_factor_best_over_model must stay exp(Δreml)=exp(10), got {}",
4736            loser_score_row.bayes_factor_best_over_model
4737        );
4738    }
4739
4740    #[test]
4741    fn compare_models_keeps_power_for_a_relevant_smooth() {
4742        // Seed-3000 relevant-z numbers from the same reproduction:
4743        //   small: reml=1025.067, edf≈6.75,  loglik≈-368.99 (aic≈751.5)
4744        //   big:   reml=199.509,  edf≈14.25, loglik≈-33.16  (aic≈94.8)
4745        // A genuinely relevant smooth lowers BOTH the evidence and the AIC, so
4746        // the bigger model must still win — a fix cannot just always pick small.
4747        let small = cand("small", 1025.067, 6.75, -368.985);
4748        let big = cand("big", 199.509, 14.25, -33.165);
4749        let cmp = compare_reml_fits(vec![small, big]).expect("compare");
4750        assert_eq!(
4751            cmp.winner, "big",
4752            "compare_models must retain power: the relevant smooth's model must win"
4753        );
4754    }
4755
4756    #[test]
4757    fn compare_models_rejects_mismatched_observation_counts() {
4758        // Two same-family fits on different-sized data are not comparable by
4759        // AIC / evidence; the comparison must fail loud, mirroring the family
4760        // guard, rather than declare a sample-size-driven winner.
4761        let with_n = |name: &str, n: usize| RemlCandidate {
4762            index: 0,
4763            name: name.to_string(),
4764            score: 100.0,
4765            edf: 5.0,
4766            log_lik: -40.0,
4767            family: Some("gaussian".to_string()),
4768            n_obs: Some(n),
4769        };
4770        let err = compare_reml_fits(vec![with_n("big", 500), with_n("small", 100)])
4771            .expect_err("cross-n comparison must be rejected");
4772        assert!(
4773            err.contains("number of observations") && err.contains("500") && err.contains("100"),
4774            "n-guard error should name the incomparable counts, got: {err}"
4775        );
4776
4777        // Same n is comparable.
4778        compare_reml_fits(vec![with_n("a", 250), with_n("b", 250)])
4779            .expect("same-n comparison must succeed");
4780
4781        // A missing count (`None`) is unconstrained: it must not block a
4782        // comparison against a fit that does carry one (legacy / scan payloads).
4783        let without_n = RemlCandidate {
4784            index: 0,
4785            name: "legacy".to_string(),
4786            score: 90.0,
4787            edf: 4.0,
4788            log_lik: -35.0,
4789            family: Some("gaussian".to_string()),
4790            n_obs: None,
4791        };
4792        compare_reml_fits(vec![with_n("counted", 500), without_n])
4793            .expect("an unconstrained (None) count must not trip the guard");
4794    }
4795}