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, fit_multinomial_logit,
27};
28
29use crate::batch::ParentBatch;
30use crate::compile::{
31    CompiledCausalModel, CompiledMechanismStore, MechanismSlot, ParentGatherPlan,
32};
33use crate::error::ModelError;
34use crate::mechanism::log_prob_column;
35
36/// Candidate mechanism family known to the registry.
37#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
38pub enum MechanismFamily {
39    /// Linear Gaussian additive noise (invertible).
40    LinearGaussian,
41    /// Constant (root or intercept-only).
42    Constant,
43    /// Discrete categorical (unconditional root or parent-conditional softmax).
44    Discrete,
45    /// Hierarchical linear Gaussian (EB / group partial pooling).
46    HierarchicalLinear,
47    /// Hierarchical Bernoulli-logit GLM (EB / group shrinkage) → [`MechanismSlot::Discrete`].
48    HierarchicalGlm,
49    /// Single-equation Bayesian VAR (Minnesota prior).
50    Bvar,
51    /// Linear Gaussian state-space observation mechanism (Kalman EM fit).
52    LinearGaussianStateSpace,
53    /// Gaussian-process regression mechanism (feature `gaussian-process`).
54    GaussianProcess,
55}
56
57impl MechanismFamily {
58    /// Registry id string.
59    #[must_use]
60    pub const fn id(self) -> &'static str {
61        match self {
62            Self::LinearGaussian => "linear_gaussian",
63            Self::Constant => "constant",
64            Self::Discrete => "discrete",
65            Self::HierarchicalLinear => "hierarchical_linear",
66            Self::HierarchicalGlm => "hierarchical_glm",
67            Self::Bvar => "bvar",
68            Self::LinearGaussianStateSpace => "lgssm",
69            Self::GaussianProcess => "gaussian_process",
70        }
71    }
72}
73
74/// Scored candidate for one node.
75#[derive(Clone, Debug)]
76pub struct MechanismCandidate {
77    /// Family.
78    pub family: MechanismFamily,
79    /// Validation score (higher is better; e.g. negative MSE or log-lik).
80    pub score: f64,
81    /// Estimated fit cost (relative).
82    pub fit_cost: f64,
83    /// Estimated evaluation cost (relative).
84    pub eval_cost: f64,
85}
86
87/// Result of auto-assignment for one node.
88#[derive(Clone, Debug)]
89pub struct MechanismAssignment {
90    /// Dense node.
91    pub node: DenseNodeId,
92    /// Variable.
93    pub variable: VariableId,
94    /// All scored candidates (sorted descending by score).
95    pub candidates: Arc<[MechanismCandidate]>,
96    /// Selected family (must be chosen explicitly from candidates).
97    pub selected: MechanismFamily,
98    /// Fitted slot.
99    pub fitted: MechanismSlot,
100    /// Families that failed to score/fit, with error messages.
101    pub failed_families: Arc<[(MechanismFamily, String)]>,
102}
103
104/// Registry of mechanism families.
105#[derive(Clone, Debug)]
106pub struct MechanismRegistry {
107    /// Families considered for continuous nodes.
108    pub continuous: Arc<[MechanismFamily]>,
109    /// Families considered for discrete / low-cardinality nodes.
110    pub discrete: Arc<[MechanismFamily]>,
111}
112
113impl Default for MechanismRegistry {
114    fn default() -> Self {
115        Self::standard()
116    }
117}
118
119impl MechanismRegistry {
120    /// Standard registry (core families).
121    #[must_use]
122    pub fn standard() -> Self {
123        Self {
124            continuous: Arc::from(vec![MechanismFamily::LinearGaussian, MechanismFamily::Constant]),
125            discrete: Arc::from(vec![MechanismFamily::Discrete, MechanismFamily::Constant]),
126        }
127    }
128
129    /// Extended continuous registry including hierarchical / BVAR / LGSSM / GP.
130    #[must_use]
131    pub fn with_bayesian_families() -> Self {
132        #[cfg(feature = "gaussian-process")]
133        let continuous = {
134            let mut continuous = vec![
135                MechanismFamily::LinearGaussian,
136                MechanismFamily::HierarchicalLinear,
137                MechanismFamily::Bvar,
138                MechanismFamily::LinearGaussianStateSpace,
139                MechanismFamily::Constant,
140            ];
141            continuous.insert(continuous.len() - 1, MechanismFamily::GaussianProcess);
142            continuous
143        };
144        #[cfg(not(feature = "gaussian-process"))]
145        let continuous = vec![
146            MechanismFamily::LinearGaussian,
147            MechanismFamily::HierarchicalLinear,
148            MechanismFamily::Bvar,
149            MechanismFamily::LinearGaussianStateSpace,
150            MechanismFamily::Constant,
151        ];
152        let discrete = vec![
153            MechanismFamily::Discrete,
154            MechanismFamily::HierarchicalGlm,
155            MechanismFamily::Constant,
156        ];
157        Self { continuous: Arc::from(continuous), discrete: Arc::from(discrete) }
158    }
159
160    /// Assign and fit all nodes. Requires an explicit selection policy.
161    ///
162    /// # Errors
163    ///
164    /// Data / fit failures, or empty candidate sets.
165    pub fn assign_and_fit(
166        &self,
167        model: &CompiledCausalModel,
168        data: &TabularData,
169        policy: SelectionPolicy,
170    ) -> Result<(CompiledMechanismStore, Vec<MechanismAssignment>), ModelError> {
171        let n = model.n_nodes();
172        let nrows = data.row_count();
173        if nrows == 0 {
174            return Err(ModelError::Shape { message: "empty data for mechanism fit".into() });
175        }
176        let mut slots = vec![MechanismSlot::Vacant; n];
177        let mut assignments = Vec::with_capacity(n);
178        let backend = FaerBackend;
179        let mut ls_ws = LeastSquaresWorkspace::default();
180
181        for gather in model.parent_gathers.iter() {
182            let node = gather.child;
183            let var = model.output_layout.variables[node.as_usize()];
184            let y = data.float64_values(var).map_err(ModelError::from)?;
185            let is_discrete = is_low_cardinality(&y, 8);
186            let families: &[MechanismFamily] =
187                if is_discrete { &self.discrete } else { &self.continuous };
188
189            let mut candidates = Vec::new();
190            let mut failed = Vec::new();
191            for &family in families {
192                match score_family(family, gather, model, data, &y, backend, &mut ls_ws) {
193                    Ok(c) => candidates.push(c),
194                    Err(e) => failed.push((family, e.to_string())),
195                }
196            }
197            if candidates.is_empty() {
198                let detail = failed
199                    .iter()
200                    .map(|(f, e)| format!("{f:?}: {e}"))
201                    .collect::<Vec<_>>()
202                    .join("; ");
203                return Err(ModelError::Unsupported {
204                    message: format!(
205                        "no mechanism candidates for variable {var} (failures: {detail})"
206                    ),
207                });
208            }
209            candidates
210                .sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
211            let selected = policy.select(&candidates).ok_or_else(|| ModelError::Unsupported {
212                message: "selection policy produced no family".into(),
213            })?;
214            let fitted = fit_family(selected, gather, model, data, &y, backend, &mut ls_ws)?;
215            slots[node.as_usize()] = fitted.clone();
216            assignments.push(MechanismAssignment {
217                node,
218                variable: var,
219                candidates: Arc::from(candidates),
220                selected,
221                fitted,
222                failed_families: Arc::from(failed),
223            });
224        }
225
226        Ok((CompiledMechanismStore { slots: Arc::from(slots) }, assignments))
227    }
228}
229
230/// How to pick among scored candidates (no silent fallback).
231#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
232pub enum SelectionPolicy {
233    /// Highest validation score.
234    BestScore,
235    /// Require the named family to appear; error if missing.
236    RequireFamily(MechanismFamily),
237}
238
239impl SelectionPolicy {
240    /// Select a family.
241    #[must_use]
242    pub fn select(self, candidates: &[MechanismCandidate]) -> Option<MechanismFamily> {
243        match self {
244            Self::BestScore => candidates.first().map(|c| c.family),
245            Self::RequireFamily(fam) => {
246                candidates.iter().find(|c| c.family == fam).map(|c| c.family)
247            }
248        }
249    }
250}
251
252fn is_low_cardinality(y: &[f64], max_levels: usize) -> bool {
253    let mut vals: Vec<i64> =
254        y.iter().filter(|v| v.is_finite()).map(|v| (v * 1e6).round() as i64).collect();
255    vals.sort_unstable();
256    vals.dedup();
257    !vals.is_empty() && vals.len() <= max_levels
258}
259
260fn score_family(
261    family: MechanismFamily,
262    gather: &ParentGatherPlan,
263    model: &CompiledCausalModel,
264    data: &TabularData,
265    y: &[f64],
266    backend: FaerBackend,
267    ls_ws: &mut LeastSquaresWorkspace,
268) -> Result<MechanismCandidate, ModelError> {
269    let fitted = fit_family(family, gather, model, data, y, backend, ls_ws)?;
270    let score = match &fitted {
271        MechanismSlot::LinearGaussian { intercept, coeffs, sigma }
272        | MechanismSlot::HierarchicalLinear { intercept, coeffs, sigma, .. }
273        | MechanismSlot::Bvar { intercept, coeffs, sigma } => {
274            let mse = residual_mse(gather, model, data, y, *intercept, coeffs)?;
275            -mse - sigma.ln().abs() * 0.01
276        }
277        MechanismSlot::Constant { value } => {
278            let mse = y.iter().map(|yi| (yi - value).powi(2)).sum::<f64>() / y.len().max(1) as f64;
279            -mse
280        }
281        MechanismSlot::Discrete { support, probs, logit_coeffs } => match logit_coeffs {
282            None => {
283                let ent: f64 = probs.iter().map(|p| if *p > 0.0 { -p * p.ln() } else { 0.0 }).sum();
284                -ent
285            }
286            Some(logits) => discrete_mean_loglik(gather, model, data, y, support, logits)?,
287        },
288        MechanismSlot::LinearGaussianStateSpace { process_std, obs_std, .. } => {
289            let mse = y.iter().map(|yi| yi.powi(2)).sum::<f64>() / y.len().max(1) as f64;
290            -mse - (process_std + obs_std).ln().abs() * 0.01
291        }
292        MechanismSlot::GaussianProcess { noise_std, .. } => {
293            let mse = y.iter().map(|yi| yi.powi(2)).sum::<f64>() / y.len().max(1) as f64;
294            -mse - noise_std.ln().abs() * 0.01
295        }
296        _ => f64::NEG_INFINITY,
297    };
298    Ok(MechanismCandidate {
299        family,
300        score,
301        fit_cost: 1.0 + gather.n_parents() as f64,
302        eval_cost: 1.0 + gather.n_parents() as f64,
303    })
304}
305
306fn fit_family(
307    family: MechanismFamily,
308    gather: &ParentGatherPlan,
309    model: &CompiledCausalModel,
310    data: &TabularData,
311    y: &[f64],
312    backend: FaerBackend,
313    ls_ws: &mut LeastSquaresWorkspace,
314) -> Result<MechanismSlot, ModelError> {
315    let n = y.len();
316    match family {
317        MechanismFamily::Constant => {
318            let mean = y.iter().sum::<f64>() / n.max(1) as f64;
319            Ok(MechanismSlot::Constant { value: mean })
320        }
321        MechanismFamily::Discrete => {
322            let mut pairs: Vec<(i64, f64, usize)> = Vec::new();
323            for &yi in y {
324                if !yi.is_finite() {
325                    continue;
326                }
327                let key = (yi * 1e6).round() as i64;
328                if let Some(e) = pairs.iter_mut().find(|(k, _, _)| *k == key) {
329                    e.2 += 1;
330                } else {
331                    pairs.push((key, yi, 1));
332                }
333            }
334            if pairs.is_empty() {
335                return Err(ModelError::Shape {
336                    message: "no finite values for discrete fit".into(),
337                });
338            }
339            // Stable support order → stable baseline-category reference (index 0).
340            pairs.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
341            let total = pairs.iter().map(|(_, _, c)| *c).sum::<usize>() as f64;
342            let support: Vec<f64> = pairs.iter().map(|(_, v, _)| *v).collect();
343            let probs: Vec<f64> = pairs.iter().map(|(_, _, c)| *c as f64 / total).collect();
344            let k = support.len();
345            let p = gather.n_parents();
346            if p == 0 {
347                return Ok(MechanismSlot::Discrete {
348                    support: Arc::from(support),
349                    probs: Arc::from(probs),
350                    logit_coeffs: None,
351                });
352            }
353            // Parent-conditional: baseline-category multinomial logit MLE (Fisher / IRLS).
354            // Coefficients are true softmax logits; category 0 is the reference (zeros).
355            let ncols = 1 + p;
356            let mut x = vec![0.0; n * ncols];
357            for r in 0..n {
358                x[r] = 1.0;
359            }
360            for (pi, &parent) in gather.parents.iter().enumerate() {
361                let var = model.output_layout.variables[parent.as_usize()];
362                let col = data.float64_values(var).map_err(ModelError::from)?;
363                let base = (1 + pi) * n;
364                x[base..base + n].copy_from_slice(&col[..n]);
365            }
366            let mut y_cat = vec![0u32; n];
367            for (r, &yi) in y.iter().enumerate() {
368                let Some(idx) = support.iter().position(|&s| (s - yi).abs() < 1e-12) else {
369                    return Err(ModelError::Shape {
370                        message: "discrete outcome not in fitted support".into(),
371                    });
372                };
373                y_cat[r] = u32::try_from(idx).map_err(|_| ModelError::Shape {
374                    message: "too many discrete categories".into(),
375                })?;
376            }
377            let fit = fit_multinomial_logit(
378                MultinomialDesignRef {
379                    x_colmajor: &x,
380                    nrows: n,
381                    ncols,
382                    y_category: &y_cat,
383                    n_categories: k,
384                },
385                &backend,
386                ls_ws,
387                &GlmOptions::default(),
388            )?;
389            // Refuse non-converged fits; separation is allowed (near-deterministic
390            // conditionals → large logits; softmax evaluation remains well-defined).
391            if !fit.converged {
392                return Err(ModelError::Numerical {
393                    message: format!(
394                        "multinomial logit did not converge (iters={}, deviance={})",
395                        fit.iterations, fit.deviance
396                    ),
397                });
398            }
399            Ok(MechanismSlot::Discrete {
400                support: Arc::from(support),
401                probs: Arc::from(probs),
402                logit_coeffs: Some(Arc::from(fit.coefficients)),
403            })
404        }
405        MechanismFamily::LinearGaussian => {
406            fit_linear_gaussian(gather, model, data, y, backend, ls_ws, 0.0)
407        }
408        MechanismFamily::HierarchicalLinear => {
409            fit_hierarchical_linear(gather, model, data, y, backend, ls_ws)
410        }
411        MechanismFamily::HierarchicalGlm => {
412            fit_hierarchical_glm(gather, model, data, y, backend, ls_ws)
413        }
414        MechanismFamily::Bvar => fit_bvar_minnesota(gather, model, data, y, backend, ls_ws),
415        MechanismFamily::LinearGaussianStateSpace => {
416            fit_lgssm_kalman_em(gather, model, data, y, backend, ls_ws)
417        }
418        MechanismFamily::GaussianProcess => {
419            #[cfg(feature = "gaussian-process")]
420            {
421                fit_gaussian_process(gather, model, data, y)
422            }
423            #[cfg(not(feature = "gaussian-process"))]
424            {
425                let _ = (gather, model, data, y, backend, ls_ws);
426                Err(ModelError::Unsupported {
427                    message: "GaussianProcess requires feature `gaussian-process`".into(),
428                })
429            }
430        }
431    }
432}
433
434fn gather_parent_cols(
435    gather: &ParentGatherPlan,
436    model: &CompiledCausalModel,
437    data: &TabularData,
438) -> Result<Vec<Vec<f64>>, ModelError> {
439    let mut parent_cols: Vec<Vec<f64>> = Vec::with_capacity(gather.n_parents());
440    for &parent in gather.parents.iter() {
441        let var = model.output_layout.variables[parent.as_usize()];
442        parent_cols.push(data.float64_values(var).map_err(ModelError::from)?);
443    }
444    Ok(parent_cols)
445}
446
447/// Empirical-Bayes hierarchical linear: estimate τ² / λ from OLS, optional `UnitId`
448/// random-intercept demeaning for partial pooling.
449fn fit_hierarchical_linear(
450    gather: &ParentGatherPlan,
451    model: &CompiledCausalModel,
452    data: &TabularData,
453    y: &[f64],
454    backend: FaerBackend,
455    ls_ws: &mut LeastSquaresWorkspace,
456) -> Result<MechanismSlot, ModelError> {
457    let n = y.len();
458    let ols = fit_linear_gaussian(gather, model, data, y, backend, ls_ws, 0.0)?;
459    let MechanismSlot::LinearGaussian { intercept: ols_int, coeffs: ols_coeffs, sigma: ols_sigma } =
460        ols
461    else {
462        return Err(ModelError::Unsupported { message: "hierarchical base fit failed".into() });
463    };
464    let p = ols_coeffs.len();
465    // Method-of-moments EB: τ² ≈ mean(β̂²) − σ²·mean(diag((X'X)^{-1})) proxy;
466    // use simplified τ² = mean(β̂²) clamped, λ = σ² / τ².
467    let mean_b2 =
468        if p == 0 { 0.0 } else { ols_coeffs.iter().map(|b| b * b).sum::<f64>() / p as f64 };
469    let tau2 = (mean_b2 - ols_sigma * ols_sigma / n.max(1) as f64).max(1e-8);
470    let mut lambda = (ols_sigma * ols_sigma / tau2).clamp(1e-6, 1e6);
471
472    // Optional UnitId random-intercept: demean within groups, then refit EB ridge.
473    let mut y_work = y.to_vec();
474    let mut group_tau = 0.0;
475    if let Some(groups) = unit_id_groups(data, y.len()) {
476        let (demeaned, tau) = demean_by_group(&y_work, &groups);
477        y_work = demeaned;
478        group_tau = tau;
479        // Re-estimate λ on demeaned series.
480        let ols2 = fit_linear_gaussian(gather, model, data, &y_work, backend, ls_ws, 0.0)?;
481        if let MechanismSlot::LinearGaussian { coeffs, sigma, .. } = ols2 {
482            let mean_b2 = if coeffs.is_empty() {
483                0.0
484            } else {
485                coeffs.iter().map(|b| b * b).sum::<f64>() / coeffs.len() as f64
486            };
487            let tau2 = (mean_b2 - sigma * sigma / n.max(1) as f64).max(1e-8);
488            lambda = (sigma * sigma / tau2).clamp(1e-6, 1e6);
489        }
490    }
491
492    let slot = fit_linear_gaussian(gather, model, data, &y_work, backend, ls_ws, lambda)?;
493    match slot {
494        MechanismSlot::LinearGaussian { intercept, coeffs, sigma } => {
495            // Restore population intercept when we demeaned.
496            let intercept = if group_tau > 0.0 {
497                y.iter().sum::<f64>() / n.max(1) as f64
498                    - coeffs.iter().enumerate().try_fold(0.0, |acc, (i, c)| {
499                        let var = model.output_layout.variables[gather.parents[i].as_usize()];
500                        let col = data.float64_values(var).map_err(ModelError::from)?;
501                        let mean = col.iter().sum::<f64>() / n.max(1) as f64;
502                        Ok::<_, ModelError>(acc + c * mean)
503                    })?
504            } else {
505                intercept
506            };
507            let _ = (ols_int, group_tau);
508            Ok(MechanismSlot::HierarchicalLinear { intercept, coeffs, sigma, shrinkage: lambda })
509        }
510        other => Ok(other),
511    }
512}
513
514/// Hierarchical Bernoulli logit with EB ridge (and optional `UnitId` demeaning of the
515/// linear predictor target via frequency offsets — here: ridge λ from OLS proxy on
516/// working residuals).
517fn fit_hierarchical_glm(
518    gather: &ParentGatherPlan,
519    model: &CompiledCausalModel,
520    data: &TabularData,
521    y: &[f64],
522    backend: FaerBackend,
523    ls_ws: &mut LeastSquaresWorkspace,
524) -> Result<MechanismSlot, ModelError> {
525    let n = y.len();
526    let binary = y.iter().all(|&yi| yi == 0.0 || yi == 1.0);
527    if !binary {
528        return Err(ModelError::Unsupported {
529            message: "HierarchicalGlm requires binary {0,1} outcomes".into(),
530        });
531    }
532    // EB λ from linear-probability OLS moments.
533    let ols = fit_linear_gaussian(gather, model, data, y, backend, ls_ws, 0.0)?;
534    let lambda = match &ols {
535        MechanismSlot::LinearGaussian { coeffs, sigma, .. } => {
536            let p = coeffs.len().max(1);
537            let mean_b2 = coeffs.iter().map(|b| b * b).sum::<f64>() / p as f64;
538            let tau2 = (mean_b2 - sigma * sigma / n.max(1) as f64).max(1e-8);
539            (sigma * sigma / tau2).clamp(1e-4, 1e3)
540        }
541        _ => 1.0,
542    };
543    let p = gather.n_parents();
544    let ncols = 1 + p;
545    let mut x = vec![0.0; n * ncols];
546    for r in 0..n {
547        x[r] = 1.0;
548    }
549    for (pi, &parent) in gather.parents.iter().enumerate() {
550        let var = model.output_layout.variables[parent.as_usize()];
551        let col = data.float64_values(var).map_err(ModelError::from)?;
552        let base = (1 + pi) * n;
553        x[base..base + n].copy_from_slice(&col[..n]);
554    }
555    let opts = GlmOptions { ridge_on_separation: Some(lambda), ..Default::default() };
556    let fit = fit_glm(
557        GlmFamily::BinomialLogit,
558        GlmDesignRef { x_colmajor: &x, nrows: n, ncols, y },
559        &backend,
560        ls_ws,
561        &opts,
562    )
563    .map_err(|e| ModelError::Numerical { message: e.to_string() })?;
564    if !fit.converged {
565        return Err(ModelError::Numerical {
566            message: "hierarchical GLM logit did not converge".into(),
567        });
568    }
569    // Encode as 2-category Discrete with baseline-category logits (cat0 = 0, cat1 = β).
570    let mut logit_coeffs = vec![0.0; 2 * ncols];
571    logit_coeffs[ncols..].copy_from_slice(&fit.coefficients[..ncols]);
572    let n1 = y.iter().filter(|&&yi| yi == 1.0).count() as f64;
573    let p1 = n1 / n.max(1) as f64;
574    Ok(MechanismSlot::Discrete {
575        support: Arc::from([0.0, 1.0]),
576        probs: Arc::from([1.0 - p1, p1]),
577        logit_coeffs: Some(Arc::from(logit_coeffs)),
578    })
579}
580
581/// Minnesota-prior single-equation BVAR: prior variance φ/(ℓ+1)² on coefficient ℓ.
582fn fit_bvar_minnesota(
583    gather: &ParentGatherPlan,
584    model: &CompiledCausalModel,
585    data: &TabularData,
586    y: &[f64],
587    backend: FaerBackend,
588    ls_ws: &mut LeastSquaresWorkspace,
589) -> Result<MechanismSlot, ModelError> {
590    let n = y.len();
591    let p = gather.n_parents();
592    let ncols = 1 + p;
593    let phi: f64 = 0.2; // overall tightness
594    let mut x = vec![0.0; n * ncols];
595    for r in 0..n {
596        x[r] = 1.0;
597    }
598    for (pi, &parent) in gather.parents.iter().enumerate() {
599        let var = model.output_layout.variables[parent.as_usize()];
600        let col = data.float64_values(var).map_err(ModelError::from)?;
601        let base = (1 + pi) * n;
602        x[base..base + n].copy_from_slice(&col[..n]);
603    }
604    // Augment with Minnesota prior pseudo-observations: √(1/v_j) * e_j → 0.
605    let extra = ncols; // intercept + each lag coeff
606    let mut x2 = vec![0.0; (n + extra) * ncols];
607    let mut y2 = vec![0.0; n + extra];
608    for c in 0..ncols {
609        for r in 0..n {
610            x2[c * (n + extra) + r] = x[c * n + r];
611        }
612    }
613    y2[..n].copy_from_slice(y);
614    // Intercept prior: loose (v = 100 * φ)
615    let v0: f64 = (100.0 * phi).max(1e-6);
616    x2[n] = (1.0 / v0).sqrt();
617    for j in 0..p {
618        let lag = (j + 1) as f64;
619        let v: f64 = (phi / (lag * lag)).max(1e-8);
620        x2[(1 + j) * (n + extra) + (n + 1 + j)] = (1.0 / v).sqrt();
621    }
622    let fit = backend.least_squares(&x2, n + extra, ncols, &y2, ls_ws).map_err(ModelError::from)?;
623    let intercept = fit.coefficients[0];
624    let coeffs: Arc<[f64]> = Arc::from(fit.coefficients[1..].to_vec());
625    let sigma = (fit.rss / (n.saturating_sub(ncols)).max(1) as f64).sqrt().max(1e-8);
626    Ok(MechanismSlot::Bvar { intercept, coeffs, sigma })
627}
628
629/// Scalar LGSSM on parent-adjusted residuals via EM (Kalman filter/smoother).
630fn fit_lgssm_kalman_em(
631    gather: &ParentGatherPlan,
632    model: &CompiledCausalModel,
633    data: &TabularData,
634    y: &[f64],
635    backend: FaerBackend,
636    ls_ws: &mut LeastSquaresWorkspace,
637) -> Result<MechanismSlot, ModelError> {
638    let lg = fit_linear_gaussian(gather, model, data, y, backend, ls_ws, 0.0)?;
639    let (intercept, coeffs, sigma) = match lg {
640        MechanismSlot::LinearGaussian { intercept, coeffs, sigma } => (intercept, coeffs, sigma),
641        _ => {
642            return Err(ModelError::Unsupported {
643                message: "lgssm fit requires linear base".into(),
644            });
645        }
646    };
647    let parent_cols = gather_parent_cols(gather, model, data)?;
648    let mut resid = vec![0.0; y.len()];
649    for r in 0..y.len() {
650        let mut pred = intercept;
651        for (p, col) in parent_cols.iter().enumerate() {
652            pred += coeffs[p] * col[r];
653        }
654        resid[r] = y[r] - pred;
655    }
656    let (a, process_std, obs_std, initial_mean) = lgssm_em(&resid, 25);
657    let _ = sigma;
658    Ok(MechanismSlot::LinearGaussianStateSpace {
659        a,
660        process_std: process_std.max(1e-8),
661        obs_std: obs_std.max(1e-8),
662        initial_mean,
663    })
664}
665
666/// EM for scalar LGSSM: `x_t` = a x_{t-1} + q ε, `y_t` = `x_t` + r η.
667fn lgssm_em(y: &[f64], max_iters: usize) -> (f64, f64, f64, f64) {
668    let n = y.len();
669    if n < 3 {
670        let (a, q) = fit_ar1(y);
671        return (a, q, q.max(1e-8), y.first().copied().unwrap_or(0.0));
672    }
673    let mut a = 0.8;
674    let mut q = 1.0; // process variance
675    let mut r = 1.0; // obs variance
676    let mut x0 = y[0];
677    let p0 = 1.0;
678    for _ in 0..max_iters {
679        let (x_f, p_f, x_pred, p_pred) = crate::lgssm::kalman_filter(y, a, q, r, x0, p0);
680        let (x_s, p_s, p_lag) = crate::lgssm::rts_smooth(a, &x_f, &p_f, &x_pred, &p_pred);
681        // M-step
682        let mut num = 0.0;
683        let mut den = 0.0;
684        for t in 1..n {
685            num += p_lag[t] + x_s[t] * x_s[t - 1];
686            den += p_s[t - 1] + x_s[t - 1] * x_s[t - 1];
687        }
688        a = if den > 1e-12 { (num / den).clamp(-0.999, 0.999) } else { a };
689        let mut q_acc = 0.0;
690        for t in 1..n {
691            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])
692                - 2.0 * a * (p_lag[t] + x_s[t] * x_s[t - 1]);
693        }
694        q = (q_acc / (n - 1) as f64).max(1e-8);
695        let mut r_acc = 0.0;
696        for t in 0..n {
697            r_acc += p_s[t] + (y[t] - x_s[t]).powi(2);
698        }
699        r = (r_acc / n as f64).max(1e-8);
700        x0 = x_s[0];
701    }
702    (a, q.sqrt(), r.sqrt(), x0)
703}
704
705fn unit_id_groups(data: &TabularData, n: usize) -> Option<Vec<u32>> {
706    let schema = data.schema();
707    for var in schema.variables() {
708        if !var.role_hints.contains(RoleHint::UnitId) {
709            continue;
710        }
711        let Ok(col) = data.float64_values(var.id) else {
712            continue;
713        };
714        if col.len() != n {
715            continue;
716        }
717        let mut groups = Vec::with_capacity(n);
718        let mut ok = true;
719        for &v in &col {
720            if !v.is_finite() {
721                ok = false;
722                break;
723            }
724            groups.push(v.round() as u32);
725        }
726        if ok {
727            let mut uniq = groups.clone();
728            uniq.sort_unstable();
729            uniq.dedup();
730            if uniq.len() >= 2 && uniq.len() < n {
731                return Some(groups);
732            }
733        }
734    }
735    None
736}
737
738fn demean_by_group(y: &[f64], groups: &[u32]) -> (Vec<f64>, f64) {
739    let mut sums = std::collections::HashMap::<u32, (f64, usize)>::new();
740    for (&g, &yi) in groups.iter().zip(y.iter()) {
741        let e = sums.entry(g).or_insert((0.0, 0));
742        e.0 += yi;
743        e.1 += 1;
744    }
745    let grand = y.iter().sum::<f64>() / y.len().max(1) as f64;
746    let mut tau2 = 0.0;
747    let mut gcount = 0usize;
748    for (s, c) in sums.values() {
749        let m = s / (*c).max(1) as f64;
750        tau2 += (m - grand).powi(2);
751        gcount += 1;
752    }
753    let tau = (tau2 / gcount.max(1) as f64).sqrt();
754    let out: Vec<f64> = groups
755        .iter()
756        .zip(y.iter())
757        .map(|(&g, &yi)| {
758            let (s, c) = sums[&g];
759            yi - s / c.max(1) as f64
760        })
761        .collect();
762    (out, tau)
763}
764
765fn fit_ar1(series: &[f64]) -> (f64, f64) {
766    let n = series.len();
767    if n < 3 {
768        return (0.0, series.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-8));
769    }
770    let mut num = 0.0;
771    let mut den = 0.0;
772    for t in 1..n {
773        num += series[t] * series[t - 1];
774        den += series[t - 1] * series[t - 1];
775    }
776    let a = if den > 1e-12 { (num / den).clamp(-0.999, 0.999) } else { 0.0 };
777    let mut rss = 0.0;
778    for t in 1..n {
779        let e = series[t] - a * series[t - 1];
780        rss += e * e;
781    }
782    let process_std = (rss / (n - 1) as f64).sqrt().max(1e-8);
783    (a, process_std)
784}
785
786fn fit_linear_gaussian(
787    gather: &ParentGatherPlan,
788    model: &CompiledCausalModel,
789    data: &TabularData,
790    y: &[f64],
791    backend: FaerBackend,
792    ls_ws: &mut LeastSquaresWorkspace,
793    ridge: f64,
794) -> Result<MechanismSlot, ModelError> {
795    let n = y.len();
796    let p = gather.n_parents();
797    let ncols = 1 + p;
798    let mut x = vec![0.0; n * ncols];
799    for r in 0..n {
800        x[r] = 1.0;
801    }
802    for (pi, &parent) in gather.parents.iter().enumerate() {
803        let var = model.output_layout.variables[parent.as_usize()];
804        let col = data.float64_values(var).map_err(ModelError::from)?;
805        let base = (1 + pi) * n;
806        x[base..base + n].copy_from_slice(&col[..n]);
807    }
808    if ridge > 0.0 {
809        // Augment with ridge rows for coefficients (not intercept).
810        let extra = p;
811        let mut x2 = vec![0.0; (n + extra) * ncols];
812        let mut y2 = vec![0.0; n + extra];
813        for c in 0..ncols {
814            for r in 0..n {
815                x2[c * (n + extra) + r] = x[c * n + r];
816            }
817        }
818        y2[..n].copy_from_slice(y);
819        let sqrt_r = ridge.sqrt();
820        for j in 0..p {
821            x2[(1 + j) * (n + extra) + (n + j)] = sqrt_r;
822        }
823        let fit =
824            backend.least_squares(&x2, n + extra, ncols, &y2, ls_ws).map_err(ModelError::from)?;
825        let intercept = fit.coefficients[0];
826        let coeffs: Arc<[f64]> = Arc::from(fit.coefficients[1..].to_vec());
827        let sigma = (fit.rss / (n.saturating_sub(ncols)).max(1) as f64).sqrt().max(1e-8);
828        return Ok(MechanismSlot::LinearGaussian { intercept, coeffs, sigma });
829    }
830    let fit = backend.least_squares(&x, n, ncols, y, ls_ws).map_err(ModelError::from)?;
831    let intercept = fit.coefficients[0];
832    let coeffs: Arc<[f64]> = Arc::from(fit.coefficients[1..].to_vec());
833    let sigma = (fit.rss / (n.saturating_sub(ncols)).max(1) as f64).sqrt().max(1e-8);
834    Ok(MechanismSlot::LinearGaussian { intercept, coeffs, sigma })
835}
836
837#[cfg(feature = "gaussian-process")]
838fn fit_gaussian_process(
839    gather: &ParentGatherPlan,
840    model: &CompiledCausalModel,
841    data: &TabularData,
842    y: &[f64],
843) -> Result<MechanismSlot, ModelError> {
844    let n = y.len();
845    let p = gather.n_parents();
846    if p == 0 {
847        return Err(ModelError::Unsupported {
848            message: "GaussianProcess requires at least one parent".into(),
849        });
850    }
851    let parent_cols = gather_parent_cols(gather, model, data)?;
852    let mut x_train = vec![0.0; n * p];
853    for r in 0..n {
854        for c in 0..p {
855            x_train[r * p + c] = parent_cols[c][r];
856        }
857    }
858    // Grid-search length_scale and noise on log marginal likelihood (variance fixed at 1).
859    let variance = 1.0;
860    let length_scales = [0.25, 0.5, 1.0, 2.0, 4.0];
861    let noise_stds = [0.05, 0.1, 0.2, 0.5];
862    let mut best = None::<(f64, f64, f64, Vec<f64>)>; // (nlml, ℓ, σ, α)
863    for &length_scale in &length_scales {
864        for &noise_std in &noise_stds {
865            let mut k = vec![0.0; n * n];
866            let inv_l2 = 1.0 / (length_scale * length_scale);
867            for i in 0..n {
868                for j in i..n {
869                    let mut d2 = 0.0;
870                    for c in 0..p {
871                        let d = x_train[i * p + c] - x_train[j * p + c];
872                        d2 += d * d;
873                    }
874                    let kij = variance * (-0.5 * d2 * inv_l2).exp();
875                    k[i * n + j] = kij;
876                    k[j * n + i] = kij;
877                }
878                k[i * n + i] += noise_std * noise_std;
879            }
880            let Ok(alpha) = solve_dense(&k, n, y) else {
881                continue;
882            };
883            // Approximate NLML ∝ y'α + log|K| via diagonal of Cholesky-free proxy: sum log diag after GE.
884            let mut y_alpha = 0.0;
885            for i in 0..n {
886                y_alpha += y[i] * alpha[i];
887            }
888            let logdet_proxy: f64 = (0..n).map(|i| k[i * n + i].abs().max(1e-12).ln()).sum();
889            let nlml = 0.5 * y_alpha + 0.5 * logdet_proxy;
890            match &best {
891                Some((best_nlml, ..)) if nlml >= *best_nlml => {}
892                _ => best = Some((nlml, length_scale, noise_std, alpha)),
893            }
894        }
895    }
896    let (_nlml, length_scale, noise_std, alpha) = best.ok_or_else(|| ModelError::Numerical {
897        message: "GP hyperparameter search failed".into(),
898    })?;
899    Ok(MechanismSlot::GaussianProcess {
900        length_scale,
901        variance,
902        noise_std,
903        x_train: Arc::from(x_train),
904        n_train: n,
905        n_parents: p,
906        alpha: Arc::from(alpha),
907    })
908}
909
910#[cfg(feature = "gaussian-process")]
911fn solve_dense(a: &[f64], n: usize, b: &[f64]) -> Result<Vec<f64>, ModelError> {
912    let mut m = a.to_vec();
913    let mut x = b.to_vec();
914    for col in 0..n {
915        let mut piv = col;
916        for r in (col + 1)..n {
917            if m[r * n + col].abs() > m[piv * n + col].abs() {
918                piv = r;
919            }
920        }
921        if m[piv * n + col].abs() < 1e-12 {
922            return Err(ModelError::Numerical { message: "singular GP kernel".into() });
923        }
924        if piv != col {
925            for c in 0..n {
926                m.swap(col * n + c, piv * n + c);
927            }
928            x.swap(col, piv);
929        }
930        let diag = m[col * n + col];
931        for r in (col + 1)..n {
932            let f = m[r * n + col] / diag;
933            for c in col..n {
934                m[r * n + c] -= f * m[col * n + c];
935            }
936            x[r] -= f * x[col];
937        }
938    }
939    for col in (0..n).rev() {
940        let mut acc = x[col];
941        for c in (col + 1)..n {
942            acc -= m[col * n + c] * x[c];
943        }
944        x[col] = acc / m[col * n + col];
945    }
946    Ok(x)
947}
948
949// Keep the old LinearGaussian arm body removed — already handled above.
950
951fn residual_mse(
952    gather: &ParentGatherPlan,
953    model: &CompiledCausalModel,
954    data: &TabularData,
955    y: &[f64],
956    intercept: f64,
957    coeffs: &[f64],
958) -> Result<f64, ModelError> {
959    let n = y.len();
960    let mut sse = 0.0;
961    let mut parent_cols: Vec<Vec<f64>> = Vec::with_capacity(gather.n_parents());
962    for &parent in gather.parents.iter() {
963        let var = model.output_layout.variables[parent.as_usize()];
964        parent_cols.push(data.float64_values(var).map_err(ModelError::from)?);
965    }
966    for r in 0..n {
967        let mut pred = intercept;
968        for (p, col) in parent_cols.iter().enumerate() {
969            pred += coeffs[p] * col[r];
970        }
971        let e = y[r] - pred;
972        sse += e * e;
973    }
974    Ok(sse / n.max(1) as f64)
975}
976
977fn discrete_mean_loglik(
978    gather: &ParentGatherPlan,
979    model: &CompiledCausalModel,
980    data: &TabularData,
981    y: &[f64],
982    support: &[f64],
983    logits: &[f64],
984) -> Result<f64, ModelError> {
985    let n = y.len();
986    let p = gather.n_parents();
987    let mut parent_mat = vec![0.0; n * p.max(1)];
988    for (pi, &parent) in gather.parents.iter().enumerate() {
989        let var = model.output_layout.variables[parent.as_usize()];
990        let col = data.float64_values(var).map_err(ModelError::from)?;
991        let base = pi * n;
992        parent_mat[base..base + n].copy_from_slice(&col[..n]);
993    }
994    let parents = ParentBatch { n_rows: n, n_parents: p, values: &parent_mat[..n * p] };
995    let slot = MechanismSlot::Discrete {
996        support: Arc::from(support.to_vec()),
997        probs: Arc::from(vec![0.0; support.len()]),
998        logit_coeffs: Some(Arc::from(logits.to_vec())),
999    };
1000    let mut lp = vec![0.0; n];
1001    log_prob_column(&slot, y, parents, &mut lp)?;
1002    Ok(lp.iter().sum::<f64>() / n.max(1) as f64)
1003}
1004
1005/// Collection of fitted models weighted by graph posterior mass.
1006#[derive(Clone, Debug)]
1007pub struct ModelCollection {
1008    /// Per-graph compiled models.
1009    pub models: Arc<[CompiledCausalModel]>,
1010    /// Graph keys aligned with `models`.
1011    pub graph_keys: Arc<[u64]>,
1012    /// Normalized weights (sum to 1 over identified graphs).
1013    pub weights: Arc<[f64]>,
1014}
1015
1016impl ModelCollection {
1017    /// Build from parallel arrays.
1018    ///
1019    /// # Errors
1020    ///
1021    /// Length mismatch or non-positive weight sum.
1022    pub fn new(
1023        models: impl Into<Arc<[CompiledCausalModel]>>,
1024        graph_keys: impl Into<Arc<[u64]>>,
1025        weights: impl Into<Arc<[f64]>>,
1026    ) -> Result<Self, ModelError> {
1027        let models = models.into();
1028        let graph_keys = graph_keys.into();
1029        let weights = weights.into();
1030        if models.len() != graph_keys.len() || models.len() != weights.len() {
1031            return Err(ModelError::Shape { message: "ModelCollection length mismatch".into() });
1032        }
1033        let sum: f64 = weights.iter().sum();
1034        if sum.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) {
1035            return Err(ModelError::Shape {
1036                message: "ModelCollection weights non-positive".into(),
1037            });
1038        }
1039        let weights: Arc<[f64]> = Arc::from(weights.iter().map(|w| w / sum).collect::<Vec<_>>());
1040        Ok(Self { models, graph_keys, weights })
1041    }
1042
1043    /// Number of graphs.
1044    #[must_use]
1045    pub fn len(&self) -> usize {
1046        self.models.len()
1047    }
1048
1049    /// Empty check.
1050    #[must_use]
1051    pub fn is_empty(&self) -> bool {
1052        self.models.is_empty()
1053    }
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058    use super::*;
1059    use antecedent_core::{
1060        CausalSchemaBuilder, MeasurementSpec, RoleHint, SmallRoleSet, ValueType, VariableId,
1061    };
1062    use antecedent_data::column::{Float64Column, ValidityBitmap};
1063    use antecedent_data::{OwnedColumn, OwnedColumnarStorage, TabularData};
1064    use antecedent_graph::{Dag, DenseNodeId};
1065
1066    fn toy_data() -> (TabularData, Dag) {
1067        let n = 40usize;
1068        let mut b = CausalSchemaBuilder::new();
1069        b.add_variable(
1070            "x",
1071            ValueType::Continuous,
1072            SmallRoleSet::from_hint(RoleHint::Context),
1073            None,
1074            None,
1075            MeasurementSpec::default(),
1076        )
1077        .unwrap();
1078        b.add_variable(
1079            "y",
1080            ValueType::Continuous,
1081            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1082            None,
1083            None,
1084            MeasurementSpec::default(),
1085        )
1086        .unwrap();
1087        let schema = b.build().unwrap();
1088        let mut xv = vec![0.0; n];
1089        let mut yv = vec![0.0; n];
1090        for i in 0..n {
1091            xv[i] = i as f64 * 0.1;
1092            yv[i] = 1.0 + 2.0 * xv[i];
1093        }
1094        let validity = ValidityBitmap::all_valid(n);
1095        let cols = vec![
1096            OwnedColumn::Float64(
1097                Float64Column::new(VariableId::from_raw(0), Arc::from(xv), validity.clone())
1098                    .unwrap(),
1099            ),
1100            OwnedColumn::Float64(
1101                Float64Column::new(VariableId::from_raw(1), Arc::from(yv), validity).unwrap(),
1102            ),
1103        ];
1104        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1105        let mut g = Dag::with_variables(2);
1106        g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
1107        (TabularData::new(storage), g)
1108    }
1109
1110    #[test]
1111    fn auto_assign_linear_chain() {
1112        let (data, g) = toy_data();
1113        let compiled = CompiledCausalModel::compile(g).unwrap();
1114        let reg = MechanismRegistry::standard();
1115        let (store, assigns) =
1116            reg.assign_and_fit(&compiled, &data, SelectionPolicy::BestScore).unwrap();
1117        assert_eq!(assigns.len(), 2);
1118        assert!(matches!(
1119            store.get(DenseNodeId::from_raw(1)),
1120            MechanismSlot::LinearGaussian { .. }
1121        ));
1122    }
1123
1124    #[test]
1125    fn bayesian_families_fit_hierarchical_and_bvar() {
1126        let (data, g) = toy_data();
1127        let compiled = CompiledCausalModel::compile(g).unwrap();
1128        let reg = MechanismRegistry::with_bayesian_families();
1129        let (store, _) = reg
1130            .assign_and_fit(
1131                &compiled,
1132                &data,
1133                SelectionPolicy::RequireFamily(MechanismFamily::HierarchicalLinear),
1134            )
1135            .unwrap();
1136        assert!(matches!(
1137            store.get(DenseNodeId::from_raw(1)),
1138            MechanismSlot::HierarchicalLinear { .. }
1139        ));
1140        let (store2, _) = reg
1141            .assign_and_fit(&compiled, &data, SelectionPolicy::RequireFamily(MechanismFamily::Bvar))
1142            .unwrap();
1143        assert!(matches!(store2.get(DenseNodeId::from_raw(1)), MechanismSlot::Bvar { .. }));
1144        let (store3, _) = reg
1145            .assign_and_fit(
1146                &compiled,
1147                &data,
1148                SelectionPolicy::RequireFamily(MechanismFamily::LinearGaussianStateSpace),
1149            )
1150            .unwrap();
1151        assert!(matches!(
1152            store3.get(DenseNodeId::from_raw(1)),
1153            MechanismSlot::LinearGaussianStateSpace { .. }
1154        ));
1155    }
1156
1157    #[test]
1158    fn discrete_conditional_multinomial_logit_mle() {
1159        let n = 120usize;
1160        let mut b = CausalSchemaBuilder::new();
1161        b.add_variable(
1162            "x",
1163            ValueType::Continuous,
1164            SmallRoleSet::from_hint(RoleHint::Context),
1165            None,
1166            None,
1167            MeasurementSpec::default(),
1168        )
1169        .unwrap();
1170        b.add_variable(
1171            "y",
1172            ValueType::Continuous,
1173            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1174            None,
1175            None,
1176            MeasurementSpec::default(),
1177        )
1178        .unwrap();
1179        let schema = b.build().unwrap();
1180        let mut xv = vec![0.0; n];
1181        let mut yv = vec![0.0; n];
1182        for i in 0..n {
1183            let t = if i < n / 2 { 0.0 } else { 1.0 };
1184            xv[i] = t;
1185            // Soft association: mostly Y=t, occasional flips (avoids complete separation).
1186            yv[i] = if i % 8 == 0 { 1.0 - t } else { t };
1187        }
1188        let validity = ValidityBitmap::all_valid(n);
1189        let cols = vec![
1190            OwnedColumn::Float64(
1191                Float64Column::new(VariableId::from_raw(0), Arc::from(xv), validity.clone())
1192                    .unwrap(),
1193            ),
1194            OwnedColumn::Float64(
1195                Float64Column::new(VariableId::from_raw(1), Arc::from(yv), validity).unwrap(),
1196            ),
1197        ];
1198        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1199        let data = TabularData::new(storage);
1200        let mut g = Dag::with_variables(2);
1201        g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
1202        let compiled = CompiledCausalModel::compile(g).unwrap();
1203        let reg = MechanismRegistry::standard();
1204        let (store, _) = reg
1205            .assign_and_fit(
1206                &compiled,
1207                &data,
1208                SelectionPolicy::RequireFamily(MechanismFamily::Discrete),
1209            )
1210            .unwrap();
1211        let MechanismSlot::Discrete { support, logit_coeffs, .. } =
1212            store.get(DenseNodeId::from_raw(1))
1213        else {
1214            panic!("expected discrete mechanism");
1215        };
1216        let logits = logit_coeffs.as_ref().expect("parent-conditional logits");
1217        assert_eq!(support.len(), 2);
1218        assert_eq!(logits.len(), 2 * 2); // K * (1 + p)
1219        // Reference category pinned to zero.
1220        assert!(logits[0].abs() < 1e-12 && logits[1].abs() < 1e-12);
1221        // Positive slope for the higher class vs reference.
1222        assert!(logits[3] > 0.5, "slope={}", logits[3]);
1223    }
1224}