Skip to main content

antecedent_model/
registry.rs

1//! Mechanism registry and auto-assignment.
2//!
3//! Assignment returns candidates and scores; there is no silent default family.
4//!
5//! SPDX-License-Identifier: MIT OR Apache-2.0
6
7#![allow(
8    clippy::cast_possible_truncation,
9    clippy::cast_precision_loss,
10    clippy::cast_sign_loss,
11    clippy::field_reassign_with_default,
12    clippy::float_cmp,
13    clippy::manual_let_else,
14    clippy::many_single_char_names,
15    clippy::needless_range_loop,
16    clippy::too_many_lines
17)]
18
19use std::sync::Arc;
20
21use antecedent_core::{RoleHint, VariableId};
22use antecedent_data::{TableView, TabularData};
23use antecedent_graph::DenseNodeId;
24use antecedent_stats::{
25    DenseLinearAlgebra, FaerBackend, GlmDesignRef, GlmFamily, GlmOptions, LeastSquaresWorkspace,
26    MultinomialDesignRef, fit_glm_ridge, fit_multinomial_logit,
27};
28#[cfg(feature = "gaussian-process")]
29use antecedent_stats::{chol_log_det, chol_solve, cholesky_spd};
30
31use crate::batch::ParentBatch;
32use crate::compile::{
33    CompiledCausalModel, CompiledMechanismStore, MechanismSlot, ParentGatherPlan,
34};
35use crate::error::ModelError;
36use crate::mechanism::{gp_predictive_mean_column, log_prob_column};
37
38/// Candidate mechanism family known to the registry.
39#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
40pub enum MechanismFamily {
41    /// Linear Gaussian additive noise (invertible).
42    LinearGaussian,
43    /// Constant (root or intercept-only).
44    Constant,
45    /// Discrete categorical (unconditional root or parent-conditional softmax).
46    Discrete,
47    /// Hierarchical linear Gaussian (EB / group partial pooling).
48    HierarchicalLinear,
49    /// Hierarchical Bernoulli-logit GLM (EB / group shrinkage) → [`MechanismSlot::Discrete`].
50    HierarchicalGlm,
51    /// Single-equation Bayesian VAR (Minnesota prior).
52    Bvar,
53    /// Linear Gaussian state-space observation mechanism (Kalman 1960 filter, EM fit).
54    LinearGaussianStateSpace,
55    /// Gaussian-process regression mechanism (feature `gaussian-process`).
56    GaussianProcess,
57}
58
59impl MechanismFamily {
60    /// Registry id string.
61    #[must_use]
62    pub const fn id(self) -> &'static str {
63        mechanism_family_id(self)
64    }
65}
66
67/// Scored candidate for one node.
68#[derive(Clone, Debug)]
69pub struct MechanismCandidate {
70    /// Family.
71    pub family: MechanismFamily,
72    /// Validation score (higher is better; e.g. negative MSE or log-lik).
73    pub score: f64,
74    /// Estimated fit cost (relative).
75    pub fit_cost: f64,
76    /// Estimated evaluation cost (relative).
77    pub eval_cost: f64,
78}
79
80/// Result of auto-assignment for one node.
81#[derive(Clone, Debug)]
82pub struct MechanismAssignment {
83    /// Dense node.
84    pub node: DenseNodeId,
85    /// Variable.
86    pub variable: VariableId,
87    /// All scored candidates (sorted descending by score).
88    pub candidates: Arc<[MechanismCandidate]>,
89    /// Selected family (must be chosen explicitly from candidates).
90    pub selected: MechanismFamily,
91    /// Fitted slot.
92    pub fitted: MechanismSlot,
93    /// Families that failed to score/fit, with error messages.
94    pub failed_families: Arc<[(MechanismFamily, String)]>,
95}
96
97/// Registry of mechanism families.
98#[derive(Clone, Debug)]
99pub struct MechanismRegistry {
100    /// Families considered for continuous nodes.
101    pub continuous: Arc<[MechanismFamily]>,
102    /// Families considered for discrete / low-cardinality nodes.
103    pub discrete: Arc<[MechanismFamily]>,
104}
105
106impl Default for MechanismRegistry {
107    fn default() -> Self {
108        Self::standard()
109    }
110}
111
112/// Registry id string per family, kept next to the family preset lists
113/// ([`MechanismRegistry::standard`] / [`MechanismRegistry::with_bayesian_families`]) so
114/// adding a family updates the preset(s) and this table in the same place.
115///
116/// Purely descriptive — [`score_family`] and [`fit_family`] stay exhaustive `match`es
117/// (each family's fit is a distinct statistical procedure) and are not table-driven.
118const fn mechanism_family_id(family: MechanismFamily) -> &'static str {
119    match family {
120        MechanismFamily::LinearGaussian => "linear_gaussian",
121        MechanismFamily::Constant => "constant",
122        MechanismFamily::Discrete => "discrete",
123        MechanismFamily::HierarchicalLinear => "hierarchical_linear",
124        MechanismFamily::HierarchicalGlm => "hierarchical_glm",
125        MechanismFamily::Bvar => "bvar",
126        MechanismFamily::LinearGaussianStateSpace => "lgssm",
127        MechanismFamily::GaussianProcess => "gaussian_process",
128    }
129}
130
131impl MechanismRegistry {
132    /// Standard registry (core families).
133    #[must_use]
134    pub fn standard() -> Self {
135        Self {
136            continuous: Arc::from(vec![MechanismFamily::LinearGaussian, MechanismFamily::Constant]),
137            discrete: Arc::from(vec![MechanismFamily::Discrete, MechanismFamily::Constant]),
138        }
139    }
140
141    /// Extended continuous registry including hierarchical / BVAR / LGSSM / GP.
142    #[must_use]
143    pub fn with_bayesian_families() -> Self {
144        #[cfg(feature = "gaussian-process")]
145        let continuous = {
146            let mut continuous = vec![
147                MechanismFamily::LinearGaussian,
148                MechanismFamily::HierarchicalLinear,
149                MechanismFamily::Bvar,
150                MechanismFamily::LinearGaussianStateSpace,
151                MechanismFamily::Constant,
152            ];
153            continuous.insert(continuous.len() - 1, MechanismFamily::GaussianProcess);
154            continuous
155        };
156        #[cfg(not(feature = "gaussian-process"))]
157        let continuous = vec![
158            MechanismFamily::LinearGaussian,
159            MechanismFamily::HierarchicalLinear,
160            MechanismFamily::Bvar,
161            MechanismFamily::LinearGaussianStateSpace,
162            MechanismFamily::Constant,
163        ];
164        let discrete = vec![
165            MechanismFamily::Discrete,
166            MechanismFamily::HierarchicalGlm,
167            MechanismFamily::Constant,
168        ];
169        Self { continuous: Arc::from(continuous), discrete: Arc::from(discrete) }
170    }
171
172    /// Assign and fit all nodes. Requires an explicit selection policy.
173    ///
174    /// # Errors
175    ///
176    /// Data / fit failures, or empty candidate sets.
177    pub fn assign_and_fit(
178        &self,
179        model: &CompiledCausalModel,
180        data: &TabularData,
181        policy: SelectionPolicy,
182    ) -> Result<(CompiledMechanismStore, Vec<MechanismAssignment>), ModelError> {
183        let n = model.n_nodes();
184        let nrows = data.row_count();
185        if nrows == 0 {
186            return Err(ModelError::Shape { message: "empty data for mechanism fit".into() });
187        }
188        let mut slots = vec![MechanismSlot::Vacant; n];
189        let mut assignments = Vec::with_capacity(n);
190        let backend = FaerBackend;
191        let mut ls_ws = LeastSquaresWorkspace::default();
192
193        for gather in model.parent_gathers.iter() {
194            let node = gather.child;
195            let var = model.output_layout.variables[node.as_usize()];
196            let y = data.float64_cow(var).map_err(ModelError::from)?;
197            let is_discrete = is_low_cardinality(&y, 8);
198            let families: &[MechanismFamily] =
199                if is_discrete { &self.discrete } else { &self.continuous };
200
201            let mut candidates = Vec::new();
202            let mut fits: Vec<(MechanismFamily, MechanismSlot)> = Vec::new();
203            let mut failed = Vec::new();
204            for &family in families {
205                match score_family(family, gather, model, data, &y, backend, &mut ls_ws) {
206                    Ok((c, slot)) => {
207                        candidates.push(c);
208                        fits.push((family, slot));
209                    }
210                    Err(e) => failed.push((family, e.to_string())),
211                }
212            }
213            if candidates.is_empty() {
214                let detail = failed
215                    .iter()
216                    .map(|(f, e)| format!("{f:?}: {e}"))
217                    .collect::<Vec<_>>()
218                    .join("; ");
219                return Err(ModelError::Unsupported {
220                    message: format!(
221                        "no mechanism candidates for variable {var} (failures: {detail})"
222                    ),
223                });
224            }
225            candidates
226                .sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
227            let selected = policy.select(&candidates).ok_or_else(|| ModelError::Unsupported {
228                message: "selection policy produced no family".into(),
229            })?;
230            // The scoring fit for the selected family is the fit (same inputs,
231            // deterministic solvers); reuse it instead of refitting from scratch.
232            let fitted = fits
233                .into_iter()
234                .find_map(|(family, slot)| (family == selected).then_some(slot))
235                .ok_or_else(|| ModelError::Unsupported {
236                    message: "selected family missing a retained fit".into(),
237                })?;
238            slots[node.as_usize()] = fitted.clone();
239            assignments.push(MechanismAssignment {
240                node,
241                variable: var,
242                candidates: Arc::from(candidates),
243                selected,
244                fitted,
245                failed_families: Arc::from(failed),
246            });
247        }
248
249        Ok((CompiledMechanismStore { slots: Arc::from(slots) }, assignments))
250    }
251}
252
253/// How to pick among scored candidates (no silent fallback).
254#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
255pub enum SelectionPolicy {
256    /// Highest validation score.
257    BestScore,
258    /// Require the named family to appear; error if missing.
259    RequireFamily(MechanismFamily),
260}
261
262impl SelectionPolicy {
263    /// Select a family.
264    #[must_use]
265    pub fn select(self, candidates: &[MechanismCandidate]) -> Option<MechanismFamily> {
266        match self {
267            Self::BestScore => candidates.first().map(|c| c.family),
268            Self::RequireFamily(fam) => {
269                candidates.iter().find(|c| c.family == fam).map(|c| c.family)
270            }
271        }
272    }
273}
274
275fn is_low_cardinality(y: &[f64], max_levels: usize) -> bool {
276    // Distinct-value scan with early exit: at most `max_levels` keys are ever
277    // retained, and the (max_levels + 1)-th distinct key bails out immediately.
278    // Equivalent to the historical sort+dedup of a full column copy (same 1e-6
279    // quantization, non-finite values skipped) without the O(n log n) copy.
280    let mut seen: Vec<i64> = Vec::with_capacity(max_levels.min(64));
281    for v in y.iter().filter(|v| v.is_finite()) {
282        let key = (v * 1e6).round() as i64;
283        if !seen.contains(&key) {
284            if seen.len() == max_levels {
285                return false;
286            }
287            seen.push(key);
288        }
289    }
290    !seen.is_empty()
291}
292
293/// Residual variance above which [`MechanismFamily::Constant`] is inadmissible.
294///
295/// `Constant` claims the variable is deterministic. Scored on conditional-mean fit alone it
296/// ties — and then beats, on the `sigma` tie-break — the correct marginal for any parentless
297/// node, which silently strips every root of its distribution. Anything above numerical noise
298/// means the claim is false.
299const CONSTANT_FAMILY_MAX_VARIANCE: f64 = 1e-12;
300
301fn score_family(
302    family: MechanismFamily,
303    gather: &ParentGatherPlan,
304    model: &CompiledCausalModel,
305    data: &TabularData,
306    y: &[f64],
307    backend: FaerBackend,
308    ls_ws: &mut LeastSquaresWorkspace,
309) -> Result<(MechanismCandidate, MechanismSlot), ModelError> {
310    let fitted = fit_family(family, gather, model, data, y, backend, ls_ws)?;
311    let score = match &fitted {
312        MechanismSlot::LinearGaussian { intercept, coeffs, sigma }
313        | MechanismSlot::HierarchicalLinear { intercept, coeffs, sigma, .. }
314        | MechanismSlot::Bvar { intercept, coeffs, sigma } => {
315            let mse = residual_mse(gather, model, data, y, *intercept, coeffs)?;
316            -mse - sigma.ln().abs() * 0.01
317        }
318        MechanismSlot::Constant { value } => {
319            let mse = y.iter().map(|yi| (yi - value).powi(2)).sum::<f64>() / y.len().max(1) as f64;
320            // A `Constant` mechanism asserts the variable is *deterministic*: it samples the
321            // same value every draw and carries no distribution at all. Every other family is
322            // scored on conditional-mean fit, and on that measure `Constant` ties the
323            // best-fitting alternative for a parentless node — a root's `LinearGaussian` fit is
324            // `intercept = mean, coeffs = [], sigma = SD`, whose residual MSE is exactly this
325            // `mse`. `Constant` then won on the tie-break, because `LinearGaussian` alone pays
326            // the `sigma` penalty above.
327            //
328            // That made every root deterministic. Interventional and attribution paths that
329            // swap a root's mechanism between populations became no-ops, since both fits are
330            // `Constant{mean}` and neither carries the variance that actually changed.
331            //
332            // Mean-squared error cannot see this: it compares point predictions, and a point
333            // mass predicts the mean perfectly. So gate on the claim `Constant` is making —
334            // it is only admissible when the target really is degenerate.
335            if mse > CONSTANT_FAMILY_MAX_VARIANCE { f64::NEG_INFINITY } else { -mse }
336        }
337        MechanismSlot::Discrete { support, probs, logit_coeffs } => match logit_coeffs {
338            None => {
339                let ent: f64 = probs.iter().map(|p| if *p > 0.0 { -p * p.ln() } else { 0.0 }).sum();
340                -ent
341            }
342            Some(logits) => discrete_mean_loglik(gather, model, data, y, support, logits)?,
343        },
344        MechanismSlot::LinearGaussianStateSpace { a, process_std, obs_std, initial_mean } => {
345            // Kalman one-step-ahead predictive residual, on the same fitted-residual scale as
346            // the other families (was: mean(y²), the raw second moment of the target — not a
347            // fitted residual at all, and incomparable across families).
348            let q = (process_std * process_std).max(1e-16);
349            let r_var = (obs_std * obs_std).max(1e-16);
350            let (_, _, x_pred, _) = crate::lgssm::kalman_filter(y, *a, q, r_var, *initial_mean, q);
351            let mse = y.iter().zip(x_pred.iter()).map(|(yi, xp)| (yi - xp).powi(2)).sum::<f64>()
352                / y.len().max(1) as f64;
353            -mse - (process_std + obs_std).ln().abs() * 0.01
354        }
355        MechanismSlot::GaussianProcess {
356            length_scale,
357            variance,
358            noise_std,
359            x_train,
360            n_train,
361            n_parents,
362            alpha,
363        } => {
364            let mse = gp_residual_mse(
365                gather,
366                model,
367                data,
368                y,
369                *length_scale,
370                *variance,
371                x_train,
372                *n_train,
373                *n_parents,
374                alpha,
375            )?;
376            -mse - noise_std.ln().abs() * 0.01
377        }
378        _ => f64::NEG_INFINITY,
379    };
380    Ok((
381        MechanismCandidate {
382            family,
383            score,
384            fit_cost: 1.0 + gather.n_parents() as f64,
385            eval_cost: 1.0 + gather.n_parents() as f64,
386        },
387        fitted,
388    ))
389}
390
391fn fit_family(
392    family: MechanismFamily,
393    gather: &ParentGatherPlan,
394    model: &CompiledCausalModel,
395    data: &TabularData,
396    y: &[f64],
397    backend: FaerBackend,
398    ls_ws: &mut LeastSquaresWorkspace,
399) -> Result<MechanismSlot, ModelError> {
400    let n = y.len();
401    match family {
402        MechanismFamily::Constant => {
403            let mean = y.iter().sum::<f64>() / n.max(1) as f64;
404            Ok(MechanismSlot::Constant { value: mean })
405        }
406        MechanismFamily::Discrete => {
407            let mut pairs: Vec<(i64, f64, usize)> = Vec::new();
408            for &yi in y {
409                if !yi.is_finite() {
410                    continue;
411                }
412                let key = (yi * 1e6).round() as i64;
413                if let Some(e) = pairs.iter_mut().find(|(k, _, _)| *k == key) {
414                    e.2 += 1;
415                } else {
416                    pairs.push((key, yi, 1));
417                }
418            }
419            if pairs.is_empty() {
420                return Err(ModelError::Shape {
421                    message: "no finite values for discrete fit".into(),
422                });
423            }
424            // Stable support order → stable baseline-category reference (index 0).
425            pairs.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
426            let total = pairs.iter().map(|(_, _, c)| *c).sum::<usize>() as f64;
427            let support: Vec<f64> = pairs.iter().map(|(_, v, _)| *v).collect();
428            let probs: Vec<f64> = pairs.iter().map(|(_, _, c)| *c as f64 / total).collect();
429            let k = support.len();
430            let p = gather.n_parents();
431            if p == 0 {
432                return Ok(MechanismSlot::Discrete {
433                    support: Arc::from(support),
434                    probs: Arc::from(probs),
435                    logit_coeffs: None,
436                });
437            }
438            // Parent-conditional: baseline-category multinomial logit MLE (Fisher / IRLS).
439            // Coefficients are true softmax logits; category 0 is the reference (zeros).
440            let ncols = 1 + p;
441            let mut x = vec![0.0; n * ncols];
442            for r in 0..n {
443                x[r] = 1.0;
444            }
445            for (pi, &parent) in gather.parents.iter().enumerate() {
446                let var = model.output_layout.variables[parent.as_usize()];
447                let col = data.float64_cow(var).map_err(ModelError::from)?;
448                let base = (1 + pi) * n;
449                x[base..base + n].copy_from_slice(&col[..n]);
450            }
451            let mut y_cat = vec![0u32; n];
452            for (r, &yi) in y.iter().enumerate() {
453                let Some(idx) = support.iter().position(|&s| (s - yi).abs() < 1e-12) else {
454                    return Err(ModelError::Shape {
455                        message: "discrete outcome not in fitted support".into(),
456                    });
457                };
458                y_cat[r] = u32::try_from(idx).map_err(|_| ModelError::Shape {
459                    message: "too many discrete categories".into(),
460                })?;
461            }
462            let fit = fit_multinomial_logit(
463                MultinomialDesignRef {
464                    x_colmajor: &x,
465                    nrows: n,
466                    ncols,
467                    y_category: &y_cat,
468                    n_categories: k,
469                },
470                &backend,
471                ls_ws,
472                &GlmOptions::default(),
473            )?;
474            // Refuse non-converged fits; separation is allowed (near-deterministic
475            // conditionals → large logits; softmax evaluation remains well-defined).
476            if !fit.converged {
477                return Err(ModelError::Numerical {
478                    message: format!(
479                        "multinomial logit did not converge (iters={}, deviance={})",
480                        fit.iterations, fit.deviance
481                    ),
482                });
483            }
484            Ok(MechanismSlot::Discrete {
485                support: Arc::from(support),
486                probs: Arc::from(probs),
487                logit_coeffs: Some(Arc::from(fit.coefficients)),
488            })
489        }
490        MechanismFamily::LinearGaussian => {
491            fit_linear_gaussian(gather, model, data, y, backend, ls_ws, 0.0)
492        }
493        MechanismFamily::HierarchicalLinear => {
494            fit_hierarchical_linear(gather, model, data, y, backend, ls_ws)
495        }
496        MechanismFamily::HierarchicalGlm => {
497            fit_hierarchical_glm(gather, model, data, y, backend, ls_ws)
498        }
499        MechanismFamily::Bvar => fit_bvar_minnesota(gather, model, data, y, backend, ls_ws),
500        MechanismFamily::LinearGaussianStateSpace => {
501            fit_lgssm_kalman_em(gather, model, data, y, backend, ls_ws)
502        }
503        MechanismFamily::GaussianProcess => {
504            #[cfg(feature = "gaussian-process")]
505            {
506                fit_gaussian_process(gather, model, data, y)
507            }
508            #[cfg(not(feature = "gaussian-process"))]
509            {
510                let _ = (gather, model, data, y, backend, ls_ws);
511                Err(ModelError::Unsupported {
512                    message: "GaussianProcess requires feature `gaussian-process`".into(),
513                })
514            }
515        }
516    }
517}
518
519fn gather_parent_cols<'d>(
520    gather: &ParentGatherPlan,
521    model: &CompiledCausalModel,
522    data: &'d TabularData,
523) -> Result<Vec<std::borrow::Cow<'d, [f64]>>, ModelError> {
524    let mut parent_cols = Vec::with_capacity(gather.n_parents());
525    for &parent in gather.parents.iter() {
526        let var = model.output_layout.variables[parent.as_usize()];
527        parent_cols.push(data.float64_cow(var).map_err(ModelError::from)?);
528    }
529    Ok(parent_cols)
530}
531
532/// Empirical-Bayes hierarchical linear: estimate τ² / λ from OLS, optional `UnitId`
533/// random-intercept demeaning for partial pooling.
534fn fit_hierarchical_linear(
535    gather: &ParentGatherPlan,
536    model: &CompiledCausalModel,
537    data: &TabularData,
538    y: &[f64],
539    backend: FaerBackend,
540    ls_ws: &mut LeastSquaresWorkspace,
541) -> Result<MechanismSlot, ModelError> {
542    let n = y.len();
543    let ols = fit_linear_gaussian(gather, model, data, y, backend, ls_ws, 0.0)?;
544    let MechanismSlot::LinearGaussian { intercept: ols_int, coeffs: ols_coeffs, sigma: ols_sigma } =
545        ols
546    else {
547        return Err(ModelError::Unsupported { message: "hierarchical base fit failed".into() });
548    };
549    let p = ols_coeffs.len();
550    // Method-of-moments EB: τ² ≈ mean(β̂²) − σ²·mean(diag((X'X)^{-1})) proxy;
551    // use simplified τ² = mean(β̂²) clamped, λ = σ² / τ².
552    let mean_b2 =
553        if p == 0 { 0.0 } else { ols_coeffs.iter().map(|b| b * b).sum::<f64>() / p as f64 };
554    let tau2 = (mean_b2 - ols_sigma * ols_sigma / n.max(1) as f64).max(1e-8);
555    let mut lambda = (ols_sigma * ols_sigma / tau2).clamp(1e-6, 1e6);
556
557    // Optional UnitId random-intercept: demean within groups, then refit EB ridge.
558    let mut y_work = y.to_vec();
559    let mut group_tau = 0.0;
560    if let Some(groups) = unit_id_groups(data, y.len()) {
561        let (demeaned, tau) = demean_by_group(&y_work, &groups);
562        y_work = demeaned;
563        group_tau = tau;
564        // Re-estimate λ on demeaned series.
565        let ols2 = fit_linear_gaussian(gather, model, data, &y_work, backend, ls_ws, 0.0)?;
566        if let MechanismSlot::LinearGaussian { coeffs, sigma, .. } = ols2 {
567            let mean_b2 = if coeffs.is_empty() {
568                0.0
569            } else {
570                coeffs.iter().map(|b| b * b).sum::<f64>() / coeffs.len() as f64
571            };
572            let tau2 = (mean_b2 - sigma * sigma / n.max(1) as f64).max(1e-8);
573            lambda = (sigma * sigma / tau2).clamp(1e-6, 1e6);
574        }
575    }
576
577    let slot = fit_linear_gaussian(gather, model, data, &y_work, backend, ls_ws, lambda)?;
578    match slot {
579        MechanismSlot::LinearGaussian { intercept, coeffs, sigma } => {
580            // Restore population intercept when we demeaned.
581            let intercept = if group_tau > 0.0 {
582                y.iter().sum::<f64>() / n.max(1) as f64
583                    - coeffs.iter().enumerate().try_fold(0.0, |acc, (i, c)| {
584                        let var = model.output_layout.variables[gather.parents[i].as_usize()];
585                        let col = data.float64_cow(var).map_err(ModelError::from)?;
586                        let mean = col.iter().sum::<f64>() / n.max(1) as f64;
587                        Ok::<_, ModelError>(acc + c * mean)
588                    })?
589            } else {
590                intercept
591            };
592            let _ = (ols_int, group_tau);
593            Ok(MechanismSlot::HierarchicalLinear { intercept, coeffs, sigma, shrinkage: lambda })
594        }
595        other => Ok(other),
596    }
597}
598
599/// Hierarchical Bernoulli logit with always-on empirical-Bayes ridge.
600///
601/// EB λ is estimated from linear-probability OLS moments and passed to
602/// [`fit_glm_ridge`], which applies the penalty on ordinary (non-separated) data
603/// as well as separated cases (MM-014). Intercept is left unpenalized.
604fn fit_hierarchical_glm(
605    gather: &ParentGatherPlan,
606    model: &CompiledCausalModel,
607    data: &TabularData,
608    y: &[f64],
609    backend: FaerBackend,
610    ls_ws: &mut LeastSquaresWorkspace,
611) -> Result<MechanismSlot, ModelError> {
612    let n = y.len();
613    let binary = y.iter().all(|&yi| yi == 0.0 || yi == 1.0);
614    if !binary {
615        return Err(ModelError::Unsupported {
616            message: "HierarchicalGlm requires binary {0,1} outcomes".into(),
617        });
618    }
619    // EB λ from linear-probability OLS moments.
620    let ols = fit_linear_gaussian(gather, model, data, y, backend, ls_ws, 0.0)?;
621    let lambda = match &ols {
622        MechanismSlot::LinearGaussian { coeffs, sigma, .. } => {
623            let p = coeffs.len().max(1);
624            let mean_b2 = coeffs.iter().map(|b| b * b).sum::<f64>() / p as f64;
625            let tau2 = (mean_b2 - sigma * sigma / n.max(1) as f64).max(1e-8);
626            (sigma * sigma / tau2).clamp(1e-4, 1e3)
627        }
628        _ => 1.0,
629    };
630    let p = gather.n_parents();
631    let ncols = 1 + p;
632    let mut x = vec![0.0; n * ncols];
633    for r in 0..n {
634        x[r] = 1.0;
635    }
636    for (pi, &parent) in gather.parents.iter().enumerate() {
637        let var = model.output_layout.variables[parent.as_usize()];
638        let col = data.float64_cow(var).map_err(ModelError::from)?;
639        let base = (1 + pi) * n;
640        x[base..base + n].copy_from_slice(&col[..n]);
641    }
642    let opts = GlmOptions::default();
643    let fit = fit_glm_ridge(
644        GlmFamily::BinomialLogit,
645        GlmDesignRef { x_colmajor: &x, nrows: n, ncols, y },
646        &backend,
647        ls_ws,
648        &opts,
649        lambda,
650    )
651    .map_err(|e| ModelError::Numerical { message: e.to_string() })?;
652    if !fit.converged {
653        return Err(ModelError::Numerical {
654            message: "hierarchical GLM logit did not converge".into(),
655        });
656    }
657    // Encode as 2-category Discrete with baseline-category logits (cat0 = 0, cat1 = β).
658    let mut logit_coeffs = vec![0.0; 2 * ncols];
659    logit_coeffs[ncols..].copy_from_slice(&fit.coefficients[..ncols]);
660    let n1 = y.iter().filter(|&&yi| yi == 1.0).count() as f64;
661    let p1 = n1 / n.max(1) as f64;
662    Ok(MechanismSlot::Discrete {
663        support: Arc::from([0.0, 1.0]),
664        probs: Arc::from([1.0 - p1, p1]),
665        logit_coeffs: Some(Arc::from(logit_coeffs)),
666    })
667}
668
669/// Minnesota-prior single-equation BVAR: prior variance φ/(ℓ+1)² on coefficient ℓ.
670fn fit_bvar_minnesota(
671    gather: &ParentGatherPlan,
672    model: &CompiledCausalModel,
673    data: &TabularData,
674    y: &[f64],
675    backend: FaerBackend,
676    ls_ws: &mut LeastSquaresWorkspace,
677) -> Result<MechanismSlot, ModelError> {
678    let n = y.len();
679    let p = gather.n_parents();
680    let ncols = 1 + p;
681    let phi: f64 = 0.2; // overall tightness
682    let mut x = vec![0.0; n * ncols];
683    for r in 0..n {
684        x[r] = 1.0;
685    }
686    for (pi, &parent) in gather.parents.iter().enumerate() {
687        let var = model.output_layout.variables[parent.as_usize()];
688        let col = data.float64_cow(var).map_err(ModelError::from)?;
689        let base = (1 + pi) * n;
690        x[base..base + n].copy_from_slice(&col[..n]);
691    }
692    // Augment with Minnesota prior pseudo-observations: √(1/v_j) * e_j → 0.
693    let extra = ncols; // intercept + each lag coeff
694    let mut x2 = vec![0.0; (n + extra) * ncols];
695    let mut y2 = vec![0.0; n + extra];
696    for c in 0..ncols {
697        for r in 0..n {
698            x2[c * (n + extra) + r] = x[c * n + r];
699        }
700    }
701    y2[..n].copy_from_slice(y);
702    // Intercept prior: loose (v = 100 * φ)
703    let v0: f64 = (100.0 * phi).max(1e-6);
704    x2[n] = (1.0 / v0).sqrt();
705    for j in 0..p {
706        let lag = (j + 1) as f64;
707        let v: f64 = (phi / (lag * lag)).max(1e-8);
708        x2[(1 + j) * (n + extra) + (n + 1 + j)] = (1.0 / v).sqrt();
709    }
710    let fit = backend.least_squares(&x2, n + extra, ncols, &y2, ls_ws).map_err(ModelError::from)?;
711    let intercept = fit.coefficients[0];
712    let coeffs: Arc<[f64]> = Arc::from(fit.coefficients[1..].to_vec());
713    let sigma = (fit.rss / (n.saturating_sub(ncols)).max(1) as f64).sqrt().max(1e-8);
714    Ok(MechanismSlot::Bvar { intercept, coeffs, sigma })
715}
716
717/// Scalar LGSSM on parent-adjusted residuals via EM (Kalman 1960 filter / Rauch–Tung–Striebel
718/// 1965 smoother).
719fn fit_lgssm_kalman_em(
720    gather: &ParentGatherPlan,
721    model: &CompiledCausalModel,
722    data: &TabularData,
723    y: &[f64],
724    backend: FaerBackend,
725    ls_ws: &mut LeastSquaresWorkspace,
726) -> Result<MechanismSlot, ModelError> {
727    let lg = fit_linear_gaussian(gather, model, data, y, backend, ls_ws, 0.0)?;
728    let (intercept, coeffs, sigma) = match lg {
729        MechanismSlot::LinearGaussian { intercept, coeffs, sigma } => (intercept, coeffs, sigma),
730        _ => {
731            return Err(ModelError::Unsupported {
732                message: "lgssm fit requires linear base".into(),
733            });
734        }
735    };
736    let parent_cols = gather_parent_cols(gather, model, data)?;
737    let mut resid = vec![0.0; y.len()];
738    for r in 0..y.len() {
739        let mut pred = intercept;
740        for (p, col) in parent_cols.iter().enumerate() {
741            pred += coeffs[p] * col[r];
742        }
743        resid[r] = y[r] - pred;
744    }
745    let (a, process_std, obs_std, initial_mean) = lgssm_em(&resid, 25);
746    let _ = sigma;
747    Ok(MechanismSlot::LinearGaussianStateSpace {
748        a,
749        process_std: process_std.max(1e-8),
750        obs_std: obs_std.max(1e-8),
751        initial_mean,
752    })
753}
754
755/// EM for scalar LGSSM: `x_t` = a x_{t-1} + q ε, `y_t` = `x_t` + r η.
756fn lgssm_em(y: &[f64], max_iters: usize) -> (f64, f64, f64, f64) {
757    let n = y.len();
758    if n < 3 {
759        let (a, q) = fit_ar1(y);
760        return (a, q, q.max(1e-8), y.first().copied().unwrap_or(0.0));
761    }
762    let mut a = 0.8;
763    let mut q = 1.0; // process variance
764    let mut r = 1.0; // obs variance
765    let mut x0 = y[0];
766    let p0 = 1.0;
767    for _ in 0..max_iters {
768        let (x_f, p_f, x_pred, p_pred) = crate::lgssm::kalman_filter(y, a, q, r, x0, p0);
769        let (x_s, p_s, p_lag) = crate::lgssm::rts_smooth(a, &x_f, &p_f, &x_pred, &p_pred);
770        // M-step
771        let mut num = 0.0;
772        let mut den = 0.0;
773        for t in 1..n {
774            num += p_lag[t] + x_s[t] * x_s[t - 1];
775            den += p_s[t - 1] + x_s[t - 1] * x_s[t - 1];
776        }
777        a = if den > 1e-12 { (num / den).clamp(-0.999, 0.999) } else { a };
778        let mut q_acc = 0.0;
779        for t in 1..n {
780            q_acc += p_s[t] + x_s[t] * x_s[t] + a * a * (p_s[t - 1] + x_s[t - 1] * x_s[t - 1])
781                - 2.0 * a * (p_lag[t] + x_s[t] * x_s[t - 1]);
782        }
783        q = (q_acc / (n - 1) as f64).max(1e-8);
784        let mut r_acc = 0.0;
785        for t in 0..n {
786            r_acc += p_s[t] + (y[t] - x_s[t]).powi(2);
787        }
788        r = (r_acc / n as f64).max(1e-8);
789        x0 = x_s[0];
790    }
791    (a, q.sqrt(), r.sqrt(), x0)
792}
793
794fn unit_id_groups(data: &TabularData, n: usize) -> Option<Vec<u32>> {
795    let schema = data.schema();
796    for var in schema.variables() {
797        if !var.role_hints.contains(RoleHint::UnitId) {
798            continue;
799        }
800        let Ok(col) = data.float64_cow(var.id) else {
801            continue;
802        };
803        if col.len() != n {
804            continue;
805        }
806        let mut groups = Vec::with_capacity(n);
807        let mut ok = true;
808        for &v in col.iter() {
809            if !v.is_finite() {
810                ok = false;
811                break;
812            }
813            groups.push(v.round() as u32);
814        }
815        if ok {
816            let mut uniq = groups.clone();
817            uniq.sort_unstable();
818            uniq.dedup();
819            if uniq.len() >= 2 && uniq.len() < n {
820                return Some(groups);
821            }
822        }
823    }
824    None
825}
826
827fn demean_by_group(y: &[f64], groups: &[u32]) -> (Vec<f64>, f64) {
828    let mut sums = std::collections::HashMap::<u32, (f64, usize)>::new();
829    for (&g, &yi) in groups.iter().zip(y.iter()) {
830        let e = sums.entry(g).or_insert((0.0, 0));
831        e.0 += yi;
832        e.1 += 1;
833    }
834    let grand = y.iter().sum::<f64>() / y.len().max(1) as f64;
835    let mut tau2 = 0.0;
836    let mut gcount = 0usize;
837    for (s, c) in sums.values() {
838        let m = s / (*c).max(1) as f64;
839        tau2 += (m - grand).powi(2);
840        gcount += 1;
841    }
842    let tau = (tau2 / gcount.max(1) as f64).sqrt();
843    let out: Vec<f64> = groups
844        .iter()
845        .zip(y.iter())
846        .map(|(&g, &yi)| {
847            let (s, c) = sums[&g];
848            yi - s / c.max(1) as f64
849        })
850        .collect();
851    (out, tau)
852}
853
854fn fit_ar1(series: &[f64]) -> (f64, f64) {
855    let n = series.len();
856    if n < 3 {
857        return (0.0, series.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-8));
858    }
859    let mut num = 0.0;
860    let mut den = 0.0;
861    for t in 1..n {
862        num += series[t] * series[t - 1];
863        den += series[t - 1] * series[t - 1];
864    }
865    let a = if den > 1e-12 { (num / den).clamp(-0.999, 0.999) } else { 0.0 };
866    let mut rss = 0.0;
867    for t in 1..n {
868        let e = series[t] - a * series[t - 1];
869        rss += e * e;
870    }
871    let process_std = (rss / (n - 1) as f64).sqrt().max(1e-8);
872    (a, process_std)
873}
874
875fn fit_linear_gaussian(
876    gather: &ParentGatherPlan,
877    model: &CompiledCausalModel,
878    data: &TabularData,
879    y: &[f64],
880    backend: FaerBackend,
881    ls_ws: &mut LeastSquaresWorkspace,
882    ridge: f64,
883) -> Result<MechanismSlot, ModelError> {
884    let n = y.len();
885    let p = gather.n_parents();
886    let ncols = 1 + p;
887    let mut x = vec![0.0; n * ncols];
888    for r in 0..n {
889        x[r] = 1.0;
890    }
891    for (pi, &parent) in gather.parents.iter().enumerate() {
892        let var = model.output_layout.variables[parent.as_usize()];
893        let col = data.float64_cow(var).map_err(ModelError::from)?;
894        let base = (1 + pi) * n;
895        x[base..base + n].copy_from_slice(&col[..n]);
896    }
897    if ridge > 0.0 {
898        // Augment with ridge rows for coefficients (not intercept).
899        let extra = p;
900        let mut x2 = vec![0.0; (n + extra) * ncols];
901        let mut y2 = vec![0.0; n + extra];
902        for c in 0..ncols {
903            for r in 0..n {
904                x2[c * (n + extra) + r] = x[c * n + r];
905            }
906        }
907        y2[..n].copy_from_slice(y);
908        let sqrt_r = ridge.sqrt();
909        for j in 0..p {
910            x2[(1 + j) * (n + extra) + (n + j)] = sqrt_r;
911        }
912        let fit =
913            backend.least_squares(&x2, n + extra, ncols, &y2, ls_ws).map_err(ModelError::from)?;
914        let intercept = fit.coefficients[0];
915        let coeffs: Arc<[f64]> = Arc::from(fit.coefficients[1..].to_vec());
916        let sigma = (fit.rss / (n.saturating_sub(ncols)).max(1) as f64).sqrt().max(1e-8);
917        return Ok(MechanismSlot::LinearGaussian { intercept, coeffs, sigma });
918    }
919    let fit = backend.least_squares(&x, n, ncols, y, ls_ws).map_err(ModelError::from)?;
920    let intercept = fit.coefficients[0];
921    let coeffs: Arc<[f64]> = Arc::from(fit.coefficients[1..].to_vec());
922    let sigma = (fit.rss / (n.saturating_sub(ncols)).max(1) as f64).sqrt().max(1e-8);
923    Ok(MechanismSlot::LinearGaussian { intercept, coeffs, sigma })
924}
925
926/// Row cap for the [`MechanismFamily::GaussianProcess`] grid search.
927///
928/// The fit runs a 20-cell `(ℓ, σ)` grid where every cell builds an O(n²) dense
929/// Gram matrix and factors it with an O(n³) Cholesky. At n = 2 000 that is a
930/// 32 MB Gram and ≈ 20 · n³/3 ≈ 5×10¹⁰ flops — seconds on current hardware and
931/// the last point where the exact GP is a reasonable candidate; n = 10 000
932/// would already need 800 MB and minutes per node. Above the cap
933/// `fit_family(GaussianProcess)` refuses with [`ModelError::Unsupported`]
934/// instead of silently hanging; because model selection records failed
935/// families and picks among the rest, the refusal is surfaced in
936/// [`MechanismAssignment::failed_families`] and another family is selected.
937#[cfg(feature = "gaussian-process")]
938pub const GP_FAMILY_MAX_ROWS: usize = 2_000;
939
940#[cfg(feature = "gaussian-process")]
941/// Grid-search RBF GP hyperparameters by exact Cholesky NLML.
942///
943/// For each `(ℓ, σ)` cell, form `K = k_RBF + σ²I`, factor once with
944/// [`cholesky_spd`], reuse that factor for both `log|K|` ([`chol_log_det`]) and
945/// `α = K⁻¹y` ([`chol_solve`]). Do not proxy the determinant by `Σ log Kᵢᵢ`
946/// (MM-015).
947fn fit_gaussian_process(
948    gather: &ParentGatherPlan,
949    model: &CompiledCausalModel,
950    data: &TabularData,
951    y: &[f64],
952) -> Result<MechanismSlot, ModelError> {
953    let n = y.len();
954    let p = gather.n_parents();
955    if p == 0 {
956        return Err(ModelError::Unsupported {
957            message: "GaussianProcess requires at least one parent".into(),
958        });
959    }
960    if n > GP_FAMILY_MAX_ROWS {
961        return Err(ModelError::Unsupported {
962            message: format!(
963                "GaussianProcess is limited to {GP_FAMILY_MAX_ROWS} rows (got {n}): the exact \
964                 Cholesky grid search is O(n³) per cell with an O(n²) Gram matrix; use another \
965                 mechanism family (or subsample) for larger data"
966            ),
967        });
968    }
969    let parent_cols = gather_parent_cols(gather, model, data)?;
970    let mut x_train = vec![0.0; n * p];
971    for r in 0..n {
972        for c in 0..p {
973            x_train[r * p + c] = parent_cols[c][r];
974        }
975    }
976    // Grid-search length_scale and noise on log marginal likelihood (variance fixed at 1).
977    let variance = 1.0;
978    let length_scales = [0.25, 0.5, 1.0, 2.0, 4.0];
979    let noise_stds = [0.05, 0.1, 0.2, 0.5];
980    let mut best = None::<(f64, f64, f64, Vec<f64>)>; // (nlml, ℓ, σ, α)
981    for &length_scale in &length_scales {
982        for &noise_std in &noise_stds {
983            let mut k = vec![0.0; n * n];
984            let inv_l2 = 1.0 / (length_scale * length_scale);
985            for i in 0..n {
986                for j in i..n {
987                    let mut d2 = 0.0;
988                    for c in 0..p {
989                        let d = x_train[i * p + c] - x_train[j * p + c];
990                        d2 += d * d;
991                    }
992                    let kij = variance * (-0.5 * d2 * inv_l2).exp();
993                    k[i * n + j] = kij;
994                    k[j * n + i] = kij;
995                }
996                k[i * n + i] += noise_std * noise_std;
997            }
998            let Some(chol) = cholesky_spd(&k, n) else {
999                continue;
1000            };
1001            let Some(alpha) = chol_solve(&chol, n, y) else {
1002                continue;
1003            };
1004            let mut y_alpha = 0.0;
1005            for i in 0..n {
1006                y_alpha += y[i] * alpha[i];
1007            }
1008            let nlml = 0.5 * y_alpha
1009                + 0.5 * chol_log_det(&chol, n)
1010                + 0.5 * n as f64 * (2.0 * std::f64::consts::PI).ln();
1011            match &best {
1012                Some((best_nlml, ..)) if nlml >= *best_nlml => {}
1013                _ => best = Some((nlml, length_scale, noise_std, alpha)),
1014            }
1015        }
1016    }
1017    let (_nlml, length_scale, noise_std, alpha) = best.ok_or_else(|| ModelError::Numerical {
1018        message: "GP hyperparameter search failed".into(),
1019    })?;
1020    Ok(MechanismSlot::GaussianProcess {
1021        length_scale,
1022        variance,
1023        noise_std,
1024        x_train: Arc::from(x_train),
1025        n_train: n,
1026        n_parents: p,
1027        alpha: Arc::from(alpha),
1028    })
1029}
1030
1031// Keep the old LinearGaussian arm body removed — already handled above.
1032
1033fn residual_mse(
1034    gather: &ParentGatherPlan,
1035    model: &CompiledCausalModel,
1036    data: &TabularData,
1037    y: &[f64],
1038    intercept: f64,
1039    coeffs: &[f64],
1040) -> Result<f64, ModelError> {
1041    let n = y.len();
1042    let mut sse = 0.0;
1043    let parent_cols = gather_parent_cols(gather, model, data)?;
1044    for r in 0..n {
1045        let mut pred = intercept;
1046        for (p, col) in parent_cols.iter().enumerate() {
1047            pred += coeffs[p] * col[r];
1048        }
1049        let e = y[r] - pred;
1050        sse += e * e;
1051    }
1052    Ok(sse / n.max(1) as f64)
1053}
1054
1055/// Fitted-residual MSE for a GP mechanism, on the same scale as [`residual_mse`]: uses the
1056/// dual-form predictive mean (shared with `evaluate_column` / `log_prob_column`), not the raw
1057/// second moment of `y`.
1058#[allow(clippy::too_many_arguments)]
1059fn gp_residual_mse(
1060    gather: &ParentGatherPlan,
1061    model: &CompiledCausalModel,
1062    data: &TabularData,
1063    y: &[f64],
1064    length_scale: f64,
1065    variance: f64,
1066    x_train: &[f64],
1067    n_train: usize,
1068    n_parents: usize,
1069    alpha: &[f64],
1070) -> Result<f64, ModelError> {
1071    let n = y.len();
1072    let p = gather.n_parents();
1073    let parent_cols = gather_parent_cols(gather, model, data)?;
1074    let mut parent_mat = vec![0.0; n * p.max(1)];
1075    for (pi, col) in parent_cols.iter().enumerate() {
1076        parent_mat[pi * n..pi * n + n].copy_from_slice(&col[..n]);
1077    }
1078    let parents =
1079        ParentBatch { n_rows: n, n_parents: p, values: &parent_mat[..p.saturating_mul(n)] };
1080    let mut pred = vec![0.0; n];
1081    gp_predictive_mean_column(
1082        length_scale,
1083        variance,
1084        x_train,
1085        n_train,
1086        n_parents,
1087        alpha,
1088        parents,
1089        &mut pred,
1090    )?;
1091    Ok(y.iter().zip(pred.iter()).map(|(yi, m)| (yi - m).powi(2)).sum::<f64>() / n.max(1) as f64)
1092}
1093
1094fn discrete_mean_loglik(
1095    gather: &ParentGatherPlan,
1096    model: &CompiledCausalModel,
1097    data: &TabularData,
1098    y: &[f64],
1099    support: &[f64],
1100    logits: &[f64],
1101) -> Result<f64, ModelError> {
1102    let n = y.len();
1103    let p = gather.n_parents();
1104    let mut parent_mat = vec![0.0; n * p.max(1)];
1105    for (pi, &parent) in gather.parents.iter().enumerate() {
1106        let var = model.output_layout.variables[parent.as_usize()];
1107        let col = data.float64_cow(var).map_err(ModelError::from)?;
1108        let base = pi * n;
1109        parent_mat[base..base + n].copy_from_slice(&col[..n]);
1110    }
1111    let parents = ParentBatch { n_rows: n, n_parents: p, values: &parent_mat[..n * p] };
1112    let slot = MechanismSlot::Discrete {
1113        support: Arc::from(support.to_vec()),
1114        probs: Arc::from(vec![0.0; support.len()]),
1115        logit_coeffs: Some(Arc::from(logits.to_vec())),
1116    };
1117    let mut lp = vec![0.0; n];
1118    log_prob_column(&slot, y, parents, &mut lp)?;
1119    Ok(lp.iter().sum::<f64>() / n.max(1) as f64)
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124    use super::*;
1125    use antecedent_core::{
1126        CausalSchemaBuilder, MeasurementSpec, RoleHint, SmallRoleSet, ValueType, VariableId,
1127    };
1128    use antecedent_data::column::{Float64Column, ValidityBitmap};
1129    use antecedent_data::{OwnedColumn, OwnedColumnarStorage, TabularData};
1130    use antecedent_graph::{Dag, DenseNodeId};
1131
1132    fn toy_data() -> (TabularData, Dag) {
1133        let n = 40usize;
1134        let mut b = CausalSchemaBuilder::new();
1135        b.add_variable(
1136            "x",
1137            ValueType::Continuous,
1138            SmallRoleSet::from_hint(RoleHint::Context),
1139            None,
1140            None,
1141            MeasurementSpec::default(),
1142        )
1143        .unwrap();
1144        b.add_variable(
1145            "y",
1146            ValueType::Continuous,
1147            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1148            None,
1149            None,
1150            MeasurementSpec::default(),
1151        )
1152        .unwrap();
1153        let schema = b.build().unwrap();
1154        let mut xv = vec![0.0; n];
1155        let mut yv = vec![0.0; n];
1156        for i in 0..n {
1157            xv[i] = i as f64 * 0.1;
1158            yv[i] = 1.0 + 2.0 * xv[i];
1159        }
1160        let validity = ValidityBitmap::all_valid(n);
1161        let cols = vec![
1162            OwnedColumn::Float64(
1163                Float64Column::new(VariableId::from_raw(0), Arc::from(xv), validity.clone())
1164                    .unwrap(),
1165            ),
1166            OwnedColumn::Float64(
1167                Float64Column::new(VariableId::from_raw(1), Arc::from(yv), validity).unwrap(),
1168            ),
1169        ];
1170        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1171        let mut g = Dag::with_variables(2);
1172        g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
1173        (TabularData::new(storage), g)
1174    }
1175
1176    /// The early-exiting distinct scan must agree with the historical
1177    /// sort+dedup implementation on ties, NaN/±inf, negatives, and the
1178    /// quantization boundary (values closer than 1e-6 collapse to one level).
1179    #[test]
1180    fn is_low_cardinality_matches_sort_dedup_reference() {
1181        fn reference(y: &[f64], max_levels: usize) -> bool {
1182            let mut vals: Vec<i64> =
1183                y.iter().filter(|v| v.is_finite()).map(|v| (v * 1e6).round() as i64).collect();
1184            vals.sort_unstable();
1185            vals.dedup();
1186            !vals.is_empty() && vals.len() <= max_levels
1187        }
1188        let cases: [&[f64]; 8] = [
1189            &[],
1190            &[f64::NAN, f64::INFINITY, f64::NEG_INFINITY],
1191            &[1.0, 1.0, 1.0, 1.0],
1192            &[-3.0, -3.0, 2.0, 2.0, f64::NAN, -3.0],
1193            &[0.0, -0.0, 1e-7, 2e-7, 5e-7], // all quantize to the same level
1194            &[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0],
1195            &[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0],
1196            &[-1.5, 1.5, -1.5, 1.5, 0.25, f64::NAN, -8.75],
1197        ];
1198        for y in cases {
1199            for max_levels in [0usize, 1, 2, 8] {
1200                assert_eq!(
1201                    is_low_cardinality(y, max_levels),
1202                    reference(y, max_levels),
1203                    "mismatch for y={y:?} max_levels={max_levels}"
1204                );
1205            }
1206        }
1207    }
1208
1209    /// Above [`GP_FAMILY_MAX_ROWS`] the GP family must refuse (recorded in
1210    /// `failed_families`) instead of running the multi-minute O(n³) grid, and
1211    /// selection must still pick another family.
1212    #[cfg(feature = "gaussian-process")]
1213    #[test]
1214    fn gp_family_refuses_above_row_cap_and_selection_falls_back() {
1215        let n = GP_FAMILY_MAX_ROWS + 1;
1216        let mut b = CausalSchemaBuilder::new();
1217        for name in ["x", "y"] {
1218            b.add_variable(
1219                name,
1220                ValueType::Continuous,
1221                SmallRoleSet::from_hint(RoleHint::Context),
1222                None,
1223                None,
1224                MeasurementSpec::default(),
1225            )
1226            .unwrap();
1227        }
1228        let schema = b.build().unwrap();
1229        let xv: Vec<f64> = (0..n).map(|i| (i as f64 * 0.37).sin() * 3.0).collect();
1230        let yv: Vec<f64> = xv.iter().map(|x| 1.0 + 2.0 * x + (x * 0.5).cos() * 0.01).collect();
1231        let validity = ValidityBitmap::all_valid(n);
1232        let cols = vec![
1233            OwnedColumn::Float64(
1234                Float64Column::new(VariableId::from_raw(0), Arc::from(xv), validity.clone())
1235                    .unwrap(),
1236            ),
1237            OwnedColumn::Float64(
1238                Float64Column::new(VariableId::from_raw(1), Arc::from(yv), validity).unwrap(),
1239            ),
1240        ];
1241        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1242        let data = TabularData::new(storage);
1243        let mut g = Dag::with_variables(2);
1244        g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
1245        let compiled = CompiledCausalModel::compile(g).unwrap();
1246        let (_, assigns) = MechanismRegistry::with_bayesian_families()
1247            .assign_and_fit(&compiled, &data, SelectionPolicy::BestScore)
1248            .unwrap();
1249        let child = assigns.iter().find(|a| a.node == DenseNodeId::from_raw(1)).unwrap();
1250        let gp_failure = child
1251            .failed_families
1252            .iter()
1253            .find(|(f, _)| *f == MechanismFamily::GaussianProcess)
1254            .expect("GP refusal must be recorded, not silently absent");
1255        assert!(gp_failure.1.contains("limited to"), "message: {}", gp_failure.1);
1256        assert_ne!(child.selected, MechanismFamily::GaussianProcess);
1257        assert!(!child.candidates.iter().any(|c| c.family == MechanismFamily::GaussianProcess));
1258    }
1259
1260    #[test]
1261    fn auto_assign_linear_chain() {
1262        let (data, g) = toy_data();
1263        let compiled = CompiledCausalModel::compile(g).unwrap();
1264        let reg = MechanismRegistry::standard();
1265        let (store, assigns) =
1266            reg.assign_and_fit(&compiled, &data, SelectionPolicy::BestScore).unwrap();
1267        assert_eq!(assigns.len(), 2);
1268        assert!(matches!(
1269            store.get(DenseNodeId::from_raw(1)),
1270            MechanismSlot::LinearGaussian { .. }
1271        ));
1272    }
1273
1274    #[test]
1275    fn bayesian_families_fit_hierarchical_and_bvar() {
1276        let (data, g) = toy_data();
1277        let compiled = CompiledCausalModel::compile(g).unwrap();
1278        let reg = MechanismRegistry::with_bayesian_families();
1279        let (store, _) = reg
1280            .assign_and_fit(
1281                &compiled,
1282                &data,
1283                SelectionPolicy::RequireFamily(MechanismFamily::HierarchicalLinear),
1284            )
1285            .unwrap();
1286        assert!(matches!(
1287            store.get(DenseNodeId::from_raw(1)),
1288            MechanismSlot::HierarchicalLinear { .. }
1289        ));
1290        let (store2, _) = reg
1291            .assign_and_fit(&compiled, &data, SelectionPolicy::RequireFamily(MechanismFamily::Bvar))
1292            .unwrap();
1293        assert!(matches!(store2.get(DenseNodeId::from_raw(1)), MechanismSlot::Bvar { .. }));
1294        let (store3, _) = reg
1295            .assign_and_fit(
1296                &compiled,
1297                &data,
1298                SelectionPolicy::RequireFamily(MechanismFamily::LinearGaussianStateSpace),
1299            )
1300            .unwrap();
1301        assert!(matches!(
1302            store3.get(DenseNodeId::from_raw(1)),
1303            MechanismSlot::LinearGaussianStateSpace { .. }
1304        ));
1305    }
1306
1307    #[test]
1308    fn discrete_conditional_multinomial_logit_mle() {
1309        let n = 120usize;
1310        let mut b = CausalSchemaBuilder::new();
1311        b.add_variable(
1312            "x",
1313            ValueType::Continuous,
1314            SmallRoleSet::from_hint(RoleHint::Context),
1315            None,
1316            None,
1317            MeasurementSpec::default(),
1318        )
1319        .unwrap();
1320        b.add_variable(
1321            "y",
1322            ValueType::Continuous,
1323            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1324            None,
1325            None,
1326            MeasurementSpec::default(),
1327        )
1328        .unwrap();
1329        let schema = b.build().unwrap();
1330        let mut xv = vec![0.0; n];
1331        let mut yv = vec![0.0; n];
1332        for i in 0..n {
1333            let t = if i < n / 2 { 0.0 } else { 1.0 };
1334            xv[i] = t;
1335            // Soft association: mostly Y=t, occasional flips (avoids complete separation).
1336            yv[i] = if i % 8 == 0 { 1.0 - t } else { t };
1337        }
1338        let validity = ValidityBitmap::all_valid(n);
1339        let cols = vec![
1340            OwnedColumn::Float64(
1341                Float64Column::new(VariableId::from_raw(0), Arc::from(xv), validity.clone())
1342                    .unwrap(),
1343            ),
1344            OwnedColumn::Float64(
1345                Float64Column::new(VariableId::from_raw(1), Arc::from(yv), validity).unwrap(),
1346            ),
1347        ];
1348        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1349        let data = TabularData::new(storage);
1350        let mut g = Dag::with_variables(2);
1351        g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
1352        let compiled = CompiledCausalModel::compile(g).unwrap();
1353        let reg = MechanismRegistry::standard();
1354        let (store, _) = reg
1355            .assign_and_fit(
1356                &compiled,
1357                &data,
1358                SelectionPolicy::RequireFamily(MechanismFamily::Discrete),
1359            )
1360            .unwrap();
1361        let MechanismSlot::Discrete { support, logit_coeffs, .. } =
1362            store.get(DenseNodeId::from_raw(1))
1363        else {
1364            panic!("expected discrete mechanism");
1365        };
1366        let logits = logit_coeffs.as_ref().expect("parent-conditional logits");
1367        assert_eq!(support.len(), 2);
1368        assert_eq!(logits.len(), 2 * 2); // K * (1 + p)
1369        // Reference category pinned to zero.
1370        assert!(logits[0].abs() < 1e-12 && logits[1].abs() < 1e-12);
1371        // Positive slope for the higher class vs reference.
1372        assert!(logits[3] > 0.5, "slope={}", logits[3]);
1373    }
1374
1375    #[cfg(feature = "gaussian-process")]
1376    #[test]
1377    fn gaussian_process_matches_exact_logdet_oracle() {
1378        let fixture: serde_json::Value = serde_json::from_str(include_str!(
1379            "../../../conformance/gcm/gaussian_process/expected.json"
1380        ))
1381        .unwrap();
1382        let n = fixture["data"]["n"].as_u64().unwrap() as usize;
1383        let mut builder = CausalSchemaBuilder::new();
1384        for name in ["x", "y"] {
1385            builder
1386                .add_variable(
1387                    name,
1388                    ValueType::Continuous,
1389                    SmallRoleSet::from_hint(RoleHint::Context),
1390                    None,
1391                    None,
1392                    MeasurementSpec::default(),
1393                )
1394                .unwrap();
1395        }
1396        let schema = builder.build().unwrap();
1397        let x: Vec<f64> = (0..n).map(|i| -2.4 + 4.8 * i as f64 / (n - 1) as f64).collect();
1398        let y: Vec<f64> =
1399            x.iter().map(|value| (1.3 * value).sin() + 0.18 * (3.1 * value).cos()).collect();
1400        let validity = ValidityBitmap::all_valid(n);
1401        let columns = vec![
1402            OwnedColumn::Float64(
1403                Float64Column::new(VariableId::from_raw(0), Arc::from(x), validity.clone())
1404                    .unwrap(),
1405            ),
1406            OwnedColumn::Float64(
1407                Float64Column::new(VariableId::from_raw(1), Arc::from(y), validity).unwrap(),
1408            ),
1409        ];
1410        let data =
1411            TabularData::new(OwnedColumnarStorage::try_new(schema, columns, None, None).unwrap());
1412        let mut graph = Dag::with_variables(2);
1413        graph.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
1414        let compiled = CompiledCausalModel::compile(graph).unwrap();
1415        let gather = compiled.gather_for(DenseNodeId::from_raw(1)).unwrap();
1416        let outcome = data.float64_values(VariableId::from_raw(1)).unwrap();
1417        let mut workspace = LeastSquaresWorkspace::default();
1418        let slot = fit_family(
1419            MechanismFamily::GaussianProcess,
1420            gather,
1421            &compiled,
1422            &data,
1423            &outcome,
1424            FaerBackend,
1425            &mut workspace,
1426        )
1427        .unwrap();
1428        let MechanismSlot::GaussianProcess { length_scale, noise_std, .. } = slot else {
1429            panic!("GP slot");
1430        };
1431        // MM-015: exact `log|K|` from Cholesky selects ℓ=1.0; the old Σlog Kᵢᵢ proxy
1432        // preferred ℓ=0.5 on this fixture.
1433        assert_eq!(length_scale, fixture["reference"]["length_scale"].as_f64().unwrap());
1434        assert_eq!(noise_std, fixture["reference"]["noise_std"].as_f64().unwrap());
1435        assert_ne!(length_scale, 0.5, "must not select the diagonal-proxy length scale");
1436    }
1437
1438    /// MM-A1: LGSSM/GP scoring must use a genuine fitted residual, on the same scale as the
1439    /// linear families — not `mean(y²)`, the raw second moment of the target. On data
1440    /// generated from a persistent near-random-walk LGSSM with a large offset, `mean(y²)`
1441    /// is dominated by `mean(y)² ≈ 25`, which was always far worse than the intercept-only
1442    /// `LinearGaussian` residual MSE (≈ the small variance around the slowly-drifting level)
1443    /// regardless of how well LGSSM actually predicts one step ahead. With the fix, LGSSM's
1444    /// score uses the Kalman one-step-ahead predictive residual, which is small for this
1445    /// series, so `BestScore` correctly prefers LGSSM.
1446    /// A root with real variance must be fit as a *distribution*, not a point mass.
1447    ///
1448    /// Every family is scored on conditional-mean fit. For a parentless node the
1449    /// `LinearGaussian` fit is `intercept = mean, coeffs = [], sigma = SD` — the correct
1450    /// marginal — and its residual MSE is exactly `Constant`'s MSE. `Constant` then won the
1451    /// tie, because `LinearGaussian` alone pays the `sigma` penalty. Every root therefore
1452    /// became deterministic, and swapping a root's mechanism between two populations was a
1453    /// no-op even when its variance had changed — which is what made
1454    /// `AttributionComponents::InputsAndMechanisms` unable to attribute input change at all.
1455    ///
1456    /// MSE cannot see this: a point mass predicts the mean perfectly. The scoring now gates
1457    /// `Constant` on the claim it is actually making — that the target is degenerate.
1458    #[test]
1459    fn root_with_variance_is_fit_as_a_distribution_not_a_constant() {
1460        fn fit_single_column(values: Vec<f64>) -> MechanismSlot {
1461            let n = values.len();
1462            let mut b = CausalSchemaBuilder::new();
1463            b.add_variable(
1464                "x",
1465                ValueType::Continuous,
1466                SmallRoleSet::from_hint(RoleHint::Context),
1467                None,
1468                None,
1469                MeasurementSpec::default(),
1470            )
1471            .unwrap();
1472            let schema = b.build().unwrap();
1473            let cols = vec![OwnedColumn::Float64(
1474                Float64Column::new(
1475                    VariableId::from_raw(0),
1476                    Arc::from(values),
1477                    ValidityBitmap::all_valid(n),
1478                )
1479                .unwrap(),
1480            )];
1481            let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1482            let data = TabularData::new(storage);
1483            let compiled = CompiledCausalModel::compile(Dag::with_variables(1)).unwrap();
1484            let (store, _) = MechanismRegistry::standard()
1485                .assign_and_fit(&compiled, &data, SelectionPolicy::BestScore)
1486                .unwrap();
1487            store.slots[0].clone()
1488        }
1489
1490        // A root that genuinely varies: continuous ramp, sample SD ≈ 2.9.
1491        let spread: Vec<f64> = (0..100).map(|i| f64::from(i) * 0.1).collect();
1492        let mean = spread.iter().sum::<f64>() / spread.len() as f64;
1493        let var =
1494            spread.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (spread.len() - 1) as f64;
1495        let sd = var.sqrt();
1496        match fit_single_column(spread) {
1497            MechanismSlot::LinearGaussian { intercept, ref coeffs, sigma } => {
1498                assert!(coeffs.is_empty(), "a root has no parents");
1499                assert!((intercept - mean).abs() < 1e-9, "intercept {intercept} != mean {mean}");
1500                assert!(
1501                    sigma > 0.5 * sd,
1502                    "root sigma {sigma} must carry the marginal spread (SD {sd}), not collapse"
1503                );
1504            }
1505            other => panic!("root with variance fit as {other:?}; expected a real marginal"),
1506        }
1507
1508        // A genuinely degenerate column must still get a deterministic mechanism — the gate
1509        // keys on `Constant`'s claim being true, not on banning the family. A zero-variance
1510        // column is low-cardinality, so it is routed to the discrete family list and
1511        // `Discrete{support:[v], probs:[1.0]}` ties `Constant` at score 0 and wins on order.
1512        // That representation is an equally exact point mass, and it is what this path
1513        // selected before the gate existed too (mse = 0 scores identically either way), so
1514        // assert the invariant that matters rather than a particular family tag.
1515        match fit_single_column(vec![7.0; 100]) {
1516            MechanismSlot::Constant { value } => assert!((value - 7.0).abs() < 1e-12),
1517            MechanismSlot::Discrete { ref support, ref probs, logit_coeffs: None } => {
1518                assert_eq!(support.len(), 1, "degenerate column must have a single support point");
1519                assert!((support[0] - 7.0).abs() < 1e-12);
1520                assert!((probs[0] - 1.0).abs() < 1e-12);
1521            }
1522            other => panic!("constant column fit as {other:?}; expected a deterministic mechanism"),
1523        }
1524    }
1525
1526    #[test]
1527    fn best_score_prefers_lgssm_over_linear_gaussian_on_lgssm_generated_data() {
1528        use antecedent_core::CausalRng;
1529        use antecedent_kernels::standard_normal;
1530
1531        let n = 60usize;
1532        let a = 0.95_f64;
1533        let process_std = 0.05_f64;
1534        let obs_std = 0.05_f64;
1535        let initial_mean = 5.0_f64;
1536        let mut rng = CausalRng::from_seed(11);
1537        let mut yv = vec![0.0; n];
1538        let mut x = initial_mean;
1539        for i in 0..n {
1540            x = if i == 0 {
1541                initial_mean + process_std * standard_normal(&mut rng)
1542            } else {
1543                a * x + process_std * standard_normal(&mut rng)
1544            };
1545            yv[i] = x + obs_std * standard_normal(&mut rng);
1546        }
1547
1548        let mut b = CausalSchemaBuilder::new();
1549        b.add_variable(
1550            "y",
1551            ValueType::Continuous,
1552            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1553            None,
1554            None,
1555            MeasurementSpec::default(),
1556        )
1557        .unwrap();
1558        let schema = b.build().unwrap();
1559        let validity = ValidityBitmap::all_valid(n);
1560        let cols = vec![OwnedColumn::Float64(
1561            Float64Column::new(VariableId::from_raw(0), Arc::from(yv), validity).unwrap(),
1562        )];
1563        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1564        let data = TabularData::new(storage);
1565        let compiled = CompiledCausalModel::compile(Dag::with_variables(1)).unwrap();
1566
1567        let registry = MechanismRegistry::with_bayesian_families();
1568        let (_, assigns) =
1569            registry.assign_and_fit(&compiled, &data, SelectionPolicy::BestScore).unwrap();
1570        assert_eq!(assigns.len(), 1);
1571        let assignment = &assigns[0];
1572        let lg_candidate = assignment
1573            .candidates
1574            .iter()
1575            .find(|c| c.family == MechanismFamily::LinearGaussian)
1576            .expect("LinearGaussian candidate present");
1577        let lgssm_candidate = assignment
1578            .candidates
1579            .iter()
1580            .find(|c| c.family == MechanismFamily::LinearGaussianStateSpace)
1581            .expect("LGSSM candidate present");
1582        assert!(
1583            lgssm_candidate.score > lg_candidate.score,
1584            "lgssm={} linear={}",
1585            lgssm_candidate.score,
1586            lg_candidate.score
1587        );
1588        assert_eq!(assignment.selected, MechanismFamily::LinearGaussianStateSpace);
1589    }
1590}