Skip to main content

antecedent_model/
sample.rs

1//! Observational and interventional batch sampling.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5#![allow(
6    clippy::cast_possible_truncation,
7    clippy::cast_precision_loss,
8    clippy::cast_sign_loss,
9    clippy::needless_range_loop,
10    clippy::too_many_arguments
11)]
12
13use antecedent_core::{
14    CausalRng, ExecutionContext, Intervention, MechanismOverride, StochasticPolicy,
15};
16use antecedent_kernels::standard_normal;
17
18use crate::batch::{MechanismWorkspace, NoiseBatchMut, ParentBatch, ValueBatch, ValueBatchMut};
19use crate::compile::{CompiledCausalModel, MechanismSlot};
20use crate::error::ModelError;
21use crate::mechanism::{evaluate_column, sample_column, sample_noise_column};
22use crate::overlay::{InterventionOverlay, ModelView};
23
24/// Sample `n_rows` observational draws from a fitted model.
25///
26/// # Errors
27///
28/// Unfitted mechanisms or shape errors.
29pub fn sample_observational(
30    model: &CompiledCausalModel,
31    n_rows: usize,
32    rng: &mut CausalRng,
33    ws: &mut MechanismWorkspace,
34    _ctx: &ExecutionContext,
35) -> Result<ValueBatch, ModelError> {
36    let view = ModelView::observational(model);
37    sample_with_overlay(&view, n_rows, rng, ws)
38}
39
40/// Sample under interventions (compiled to an overlay; model is not cloned).
41///
42/// # Errors
43///
44/// Overlay / mechanism failures.
45pub fn sample_interventional(
46    model: &CompiledCausalModel,
47    interventions: &[Intervention],
48    n_rows: usize,
49    rng: &mut CausalRng,
50    ws: &mut MechanismWorkspace,
51    _ctx: &ExecutionContext,
52) -> Result<ValueBatch, ModelError> {
53    let overlay = InterventionOverlay::from_interventions(model, interventions)?;
54    let view = ModelView::with_overlay(model, overlay);
55    sample_with_overlay(&view, n_rows, rng, ws)
56}
57
58/// Core ancestral sampler with overlay.
59///
60/// # Errors
61///
62/// Mechanism failures.
63pub fn sample_with_overlay(
64    view: &ModelView<'_>,
65    n_rows: usize,
66    rng: &mut CausalRng,
67    ws: &mut MechanismWorkspace,
68) -> Result<ValueBatch, ModelError> {
69    if n_rows == 0 {
70        return Err(ModelError::Shape { message: "n_rows must be > 0".into() });
71    }
72    let model = view.model;
73    let n_nodes = model.n_nodes();
74    let mut values_buf = vec![0.0; n_rows * n_nodes];
75    let mut values = ValueBatchMut::new(n_rows, n_nodes, &mut values_buf)?;
76    let overlay = view.overlay.as_ref();
77
78    for gather in model.parent_gathers.iter() {
79        let node = gather.child;
80        let idx = node.as_usize();
81        ws.prepare(n_rows, gather.n_parents().max(1));
82        gather.gather(values.values, n_rows, &mut ws.parents);
83        let parents = ParentBatch {
84            n_rows,
85            n_parents: gather.n_parents(),
86            values: &ws.parents[..gather.n_parents().saturating_mul(n_rows)],
87        };
88        // Copy parents to owned so we can mutably write the child column.
89        let parent_owned = parents.values.to_vec();
90        let parents = ParentBatch { n_rows, n_parents: gather.n_parents(), values: &parent_owned };
91
92        let out = values.column_mut(idx)?;
93
94        if let Some(v) = overlay.hard_set[idx] {
95            out.fill(v);
96            continue;
97        }
98        if let Some(policy) = &overlay.stochastic[idx] {
99            sample_stochastic(policy, n_rows, rng, out)?;
100            apply_shift(out, overlay.shifts[idx]);
101            continue;
102        }
103        if let Some(soft) = &overlay.soft[idx] {
104            let slot = soft_to_slot(soft, gather.n_parents())?;
105            sample_column(&slot, parents, rng, out, ws)?;
106            apply_shift(out, overlay.shifts[idx]);
107            continue;
108        }
109
110        let slot = model.mechanisms.get(node);
111        sample_column(slot, parents, rng, out, ws)?;
112        apply_shift(out, overlay.shifts[idx]);
113    }
114
115    Ok(values.into_batch())
116}
117
118/// Sample under interventions conditioned on observed node values.
119///
120/// Strategy:
121/// 1. **Rejection sampling** when conditions match within `1e-9` (exact / discrete).
122/// 2. **Likelihood-weighting SIR** when rejection under-accepts: propose from `do(·)`,
123///    weight by `∏_c p(condition_c | parents_c)` via [`log_prob_column`], resample.
124///
125/// Conditioning nodes must not be hard-intervened.
126///
127/// # Errors
128///
129/// Empty condition, intervened condition nodes, density failures, or empty weights.
130pub fn sample_conditional_interventional(
131    model: &CompiledCausalModel,
132    interventions: &[Intervention],
133    condition_nodes: &[antecedent_graph::DenseNodeId],
134    condition_values: &[f64],
135    n_rows: usize,
136    rng: &mut CausalRng,
137    ws: &mut MechanismWorkspace,
138    ctx: &ExecutionContext,
139) -> Result<ValueBatch, ModelError> {
140    if condition_nodes.is_empty() || condition_values.len() != condition_nodes.len() {
141        return Err(ModelError::Shape {
142            message: "conditional interventional sampling needs matching condition_nodes/values"
143                .into(),
144        });
145    }
146    if n_rows == 0 {
147        return Err(ModelError::Shape { message: "n_rows must be > 0".into() });
148    }
149    let overlay = InterventionOverlay::from_interventions(model, interventions)?;
150    for &node in condition_nodes {
151        let idx = node.as_usize();
152        if idx >= model.n_nodes() {
153            return Err(ModelError::Shape { message: "condition node out of range".into() });
154        }
155        if overlay.hard_set[idx].is_some() {
156            return Err(ModelError::Unsupported {
157                message: "cannot condition on a hard-intervened node".into(),
158            });
159        }
160    }
161
162    let n_nodes = model.n_nodes();
163    let mut accepted = vec![0.0; n_rows * n_nodes];
164    let mut got = 0usize;
165    let max_attempts = n_rows.saturating_mul(100).max(100);
166    for _ in 0..max_attempts {
167        if got >= n_rows {
168            break;
169        }
170        let batch = sample_interventional(model, interventions, 1, rng, ws, ctx)?;
171        let mut ok = true;
172        for (i, &node) in condition_nodes.iter().enumerate() {
173            let v = batch.column(node.as_usize())?[0];
174            if (v - condition_values[i]).abs() > 1e-9 {
175                ok = false;
176                break;
177            }
178        }
179        if !ok {
180            continue;
181        }
182        for node in 0..n_nodes {
183            accepted[node * n_rows + got] = batch.column(node)?[0];
184        }
185        got += 1;
186    }
187    if got >= n_rows {
188        let _ = ctx;
189        return Ok(ValueBatch { n_rows, n_nodes, values: accepted.into() });
190    }
191
192    // Likelihood-weighting / SIR for continuous conditions.
193    sample_conditional_interventional_lw(
194        model,
195        interventions,
196        condition_nodes,
197        condition_values,
198        n_rows,
199        rng,
200        ws,
201        ctx,
202    )
203}
204
205fn sample_conditional_interventional_lw(
206    model: &CompiledCausalModel,
207    interventions: &[Intervention],
208    condition_nodes: &[antecedent_graph::DenseNodeId],
209    condition_values: &[f64],
210    n_rows: usize,
211    rng: &mut CausalRng,
212    ws: &mut MechanismWorkspace,
213    ctx: &ExecutionContext,
214) -> Result<ValueBatch, ModelError> {
215    use crate::mechanism::log_prob_column;
216
217    let n_nodes = model.n_nodes();
218    let n_particles = n_rows.saturating_mul(20).max(64);
219    let proposal = sample_interventional(model, interventions, n_particles, rng, ws, ctx)?;
220    let mut log_w = vec![0.0; n_particles];
221    let mut lp_buf = vec![0.0; n_particles];
222
223    for (ci, &node) in condition_nodes.iter().enumerate() {
224        let gather = model.gather_for(node).ok_or_else(|| ModelError::Unsupported {
225            message: format!("missing gather for condition node {node:?}"),
226        })?;
227        ws.prepare(n_particles, gather.n_parents().max(1));
228        gather.gather(&proposal.values, n_particles, &mut ws.parents);
229        let parent_owned = ws.parents[..gather.n_parents().saturating_mul(n_particles)].to_vec();
230        let parents = ParentBatch {
231            n_rows: n_particles,
232            n_parents: gather.n_parents(),
233            values: &parent_owned,
234        };
235        // Score the *conditioned* value under each particle's parents.
236        let conditioned = vec![condition_values[ci]; n_particles];
237        log_prob_column(model.mechanisms.get(node), &conditioned, parents, &mut lp_buf)?;
238        for p in 0..n_particles {
239            if !lp_buf[p].is_finite() {
240                return Err(ModelError::Unsupported {
241                    message: format!(
242                        "conditional do: mechanism for node {node:?} cannot provide a finite density \
243                         for likelihood weighting"
244                    ),
245                });
246            }
247            log_w[p] += lp_buf[p];
248        }
249    }
250
251    let max_lw = log_w.iter().copied().fold(f64::NEG_INFINITY, f64::max);
252    if !max_lw.is_finite() {
253        return Err(ModelError::Unsupported {
254            message: "conditional do: all likelihood weights are non-finite".into(),
255        });
256    }
257    let mut weights = vec![0.0; n_particles];
258    let mut sum_w = 0.0;
259    for p in 0..n_particles {
260        let w = (log_w[p] - max_lw).exp();
261        weights[p] = w;
262        sum_w += w;
263    }
264    if sum_w <= 0.0 {
265        return Err(ModelError::Unsupported {
266            message: "conditional do: likelihood weights sum to zero".into(),
267        });
268    }
269    for w in &mut weights {
270        *w /= sum_w;
271    }
272
273    // Systematic resampling.
274    let mut accepted = vec![0.0; n_rows * n_nodes];
275    let u0 = rng.next_f64() / n_rows as f64;
276    let mut cdf = 0.0;
277    let mut idx = 0usize;
278    for i in 0..n_rows {
279        let target = u0 + i as f64 / n_rows as f64;
280        while idx + 1 < n_particles && cdf + weights[idx] < target {
281            cdf += weights[idx];
282            idx += 1;
283        }
284        for node in 0..n_nodes {
285            accepted[node * n_rows + i] = proposal.column(node)?[idx];
286            // Overwrite conditioned nodes with exact condition values.
287        }
288        for (ci, &node) in condition_nodes.iter().enumerate() {
289            accepted[node.as_usize() * n_rows + i] = condition_values[ci];
290        }
291    }
292    let _ = ctx;
293    Ok(ValueBatch { n_rows, n_nodes, values: accepted.into() })
294}
295
296/// Posterior-predictive interventional sampling: for each coefficient draw block,
297/// refresh `LinearGaussian` slots then sample. `draw_updater` mutates slots in place.
298///
299/// # Errors
300///
301/// Updater / sample failures.
302pub fn sample_posterior_predictive<F>(
303    model: &mut CompiledCausalModel,
304    interventions: &[Intervention],
305    n_rows_per_draw: usize,
306    n_draws: usize,
307    rng: &mut CausalRng,
308    ws: &mut MechanismWorkspace,
309    mut draw_updater: F,
310    ctx: &ExecutionContext,
311) -> Result<ValueBatch, ModelError>
312where
313    F: FnMut(usize, &mut CompiledCausalModel) -> Result<(), ModelError>,
314{
315    let n_nodes = model.n_nodes();
316    let total_rows = n_rows_per_draw.saturating_mul(n_draws);
317    let mut all = vec![0.0; total_rows * n_nodes];
318    for d in 0..n_draws {
319        draw_updater(d, model)?;
320        let batch = sample_interventional(model, interventions, n_rows_per_draw, rng, ws, ctx)?;
321        for node in 0..n_nodes {
322            let src = batch.column(node)?;
323            let dest_row0 = d * n_rows_per_draw;
324            let dest = node * total_rows + dest_row0;
325            all[dest..dest + n_rows_per_draw].copy_from_slice(src);
326        }
327    }
328    Ok(ValueBatch { n_rows: total_rows, n_nodes, values: all.into() })
329}
330
331fn apply_shift(out: &mut [f64], shift: f64) {
332    if shift != 0.0 {
333        for v in out.iter_mut() {
334            *v += shift;
335        }
336    }
337}
338
339/// Convert a soft [`MechanismOverride`] into a concrete mechanism slot.
340///
341/// `additive_shift` is rejected here — [`InterventionOverlay::from_interventions`] maps it
342/// onto overlay shifts so sampling paths share noise semantics.
343///
344/// # Errors
345///
346/// Unknown family or shape mismatches.
347pub fn soft_to_slot(
348    soft: &MechanismOverride,
349    n_parents: usize,
350) -> Result<MechanismSlot, ModelError> {
351    match soft.family_id.as_ref() {
352        "constant" => {
353            let v = soft.parameters.first().copied().unwrap_or(0.0);
354            Ok(MechanismSlot::Constant { value: v })
355        }
356        "additive_shift" => Err(ModelError::Unsupported {
357            message: "additive_shift soft overrides must be applied as Intervention::Shift / overlay shifts"
358                .into(),
359        }),
360        "linear_gaussian" => {
361            if soft.parameters.len() < 2 + n_parents {
362                return Err(ModelError::Shape {
363                    message: "linear_gaussian override needs intercept, coeffs..., sigma".into(),
364                });
365            }
366            let intercept = soft.parameters[0];
367            let coeffs = std::sync::Arc::from(soft.parameters[1..=n_parents].to_vec());
368            let sigma = soft.parameters[1 + n_parents].max(1e-12);
369            Ok(MechanismSlot::LinearGaussian { intercept, coeffs, sigma })
370        }
371        "hierarchical_linear" => {
372            if soft.parameters.len() < 3 + n_parents {
373                return Err(ModelError::Shape {
374                    message: "hierarchical_linear override needs intercept, coeffs..., sigma, shrinkage"
375                        .into(),
376                });
377            }
378            let intercept = soft.parameters[0];
379            let coeffs = std::sync::Arc::from(soft.parameters[1..=n_parents].to_vec());
380            let sigma = soft.parameters[1 + n_parents].max(1e-12);
381            let shrinkage = soft.parameters[2 + n_parents].max(0.0);
382            Ok(MechanismSlot::HierarchicalLinear { intercept, coeffs, sigma, shrinkage })
383        }
384        "bvar" => {
385            if soft.parameters.len() < 2 + n_parents {
386                return Err(ModelError::Shape {
387                    message: "bvar override needs intercept, coeffs..., sigma".into(),
388                });
389            }
390            let intercept = soft.parameters[0];
391            let coeffs = std::sync::Arc::from(soft.parameters[1..=n_parents].to_vec());
392            let sigma = soft.parameters[1 + n_parents].max(1e-12);
393            Ok(MechanismSlot::Bvar { intercept, coeffs, sigma })
394        }
395        "discrete" => soft_discrete_slot(soft, n_parents),
396        "lgssm" => {
397            if soft.parameters.len() < 4 {
398                return Err(ModelError::Shape {
399                    message: "lgssm override needs a, process_std, obs_std, initial_mean".into(),
400                });
401            }
402            Ok(MechanismSlot::LinearGaussianStateSpace {
403                a: soft.parameters[0],
404                process_std: soft.parameters[1].max(1e-12),
405                obs_std: soft.parameters[2].max(1e-12),
406                initial_mean: soft.parameters[3],
407            })
408        }
409        "gaussian_process" => soft_gp_slot(soft, n_parents),
410        other => Err(ModelError::Unsupported {
411            message: format!("unknown soft override family {other}"),
412        }),
413    }
414}
415
416fn soft_discrete_slot(
417    soft: &MechanismOverride,
418    n_parents: usize,
419) -> Result<MechanismSlot, ModelError> {
420    if soft.parameters.is_empty() {
421        return Err(ModelError::Shape {
422            message: "discrete override needs k, support..., probs/logits...".into(),
423        });
424    }
425    let k = soft.parameters[0] as usize;
426    if k == 0 {
427        return Err(ModelError::Shape { message: "discrete override k must be > 0".into() });
428    }
429    if soft.parameters.len() < 1 + k {
430        return Err(ModelError::Shape { message: "discrete override truncated support".into() });
431    }
432    let support: std::sync::Arc<[f64]> = std::sync::Arc::from(soft.parameters[1..=k].to_vec());
433    let rest = &soft.parameters[1 + k..];
434    if rest.len() == k {
435        Ok(MechanismSlot::Discrete {
436            support,
437            probs: std::sync::Arc::from(rest.to_vec()),
438            logit_coeffs: None,
439        })
440    } else if rest.len() == k * (1 + n_parents) {
441        Ok(MechanismSlot::Discrete {
442            support,
443            probs: std::sync::Arc::from(vec![1.0 / k as f64; k]),
444            logit_coeffs: Some(std::sync::Arc::from(rest.to_vec())),
445        })
446    } else {
447        Err(ModelError::Shape {
448            message: format!(
449                "discrete override expects {k} probs or {} logits after support, got {}",
450                k * (1 + n_parents),
451                rest.len()
452            ),
453        })
454    }
455}
456
457fn soft_gp_slot(soft: &MechanismOverride, n_parents: usize) -> Result<MechanismSlot, ModelError> {
458    if soft.parameters.len() < 5 {
459        return Err(ModelError::Shape {
460            message: "gaussian_process override truncated header".into(),
461        });
462    }
463    let length_scale = soft.parameters[0].max(1e-12);
464    let variance = soft.parameters[1].max(0.0);
465    let noise_std = soft.parameters[2].max(1e-12);
466    let n_train = soft.parameters[3] as usize;
467    let n_par = soft.parameters[4] as usize;
468    if n_par != n_parents {
469        return Err(ModelError::Shape {
470            message: format!("gaussian_process override n_parents {n_par} != gather {n_parents}"),
471        });
472    }
473    let need = 5 + n_train * n_par + n_train;
474    if soft.parameters.len() < need {
475        return Err(ModelError::Shape {
476            message: format!(
477                "gaussian_process override needs {need} params, got {}",
478                soft.parameters.len()
479            ),
480        });
481    }
482    let x_train = std::sync::Arc::from(soft.parameters[5..5 + n_train * n_par].to_vec());
483    let alpha = std::sync::Arc::from(
484        soft.parameters[5 + n_train * n_par..5 + n_train * n_par + n_train].to_vec(),
485    );
486    Ok(MechanismSlot::GaussianProcess {
487        length_scale,
488        variance,
489        noise_std,
490        x_train,
491        n_train,
492        n_parents: n_par,
493        alpha,
494    })
495}
496
497/// Draw values from a stochastic intervention policy into `out`.
498///
499/// # Errors
500///
501/// Unsupported policy variants.
502pub fn sample_stochastic(
503    policy: &StochasticPolicy,
504    n_rows: usize,
505    rng: &mut CausalRng,
506    out: &mut [f64],
507) -> Result<(), ModelError> {
508    match policy {
509        StochasticPolicy::Bernoulli { p } => {
510            for i in 0..n_rows {
511                out[i] = if rng.next_f64() < *p { 1.0 } else { 0.0 };
512            }
513            Ok(())
514        }
515        StochasticPolicy::Gaussian { mean, variance } => {
516            let s = variance.sqrt();
517            for i in 0..n_rows {
518                out[i] = mean + s * standard_normal(rng);
519            }
520            Ok(())
521        }
522        StochasticPolicy::Categorical { probs } => {
523            let sum: f64 = probs.iter().sum::<f64>().max(f64::EPSILON);
524            for i in 0..n_rows {
525                let u = rng.next_f64() * sum;
526                let mut acc = 0.0;
527                let mut chosen = (probs.len() - 1) as f64;
528                for (k, &p) in probs.iter().enumerate() {
529                    acc += p;
530                    if u <= acc {
531                        chosen = k as f64;
532                        break;
533                    }
534                }
535                out[i] = chosen;
536            }
537            Ok(())
538        }
539        _ => Err(ModelError::Unsupported { message: "unknown stochastic policy".into() }),
540    }
541}
542
543/// Structural path: sample noise then evaluate with overlays applied post-hoc for hard sets.
544///
545/// # Errors
546///
547/// Mechanism failures.
548pub fn sample_structural_with_overlay(
549    view: &ModelView<'_>,
550    n_rows: usize,
551    rng: &mut CausalRng,
552    ws: &mut MechanismWorkspace,
553) -> Result<(ValueBatch, Vec<f64>), ModelError> {
554    let model = view.model;
555    let n_nodes = model.n_nodes();
556    let mut noise_buf = vec![0.0; n_rows * n_nodes];
557    {
558        let mut noise = NoiseBatchMut::new(n_rows, n_nodes, &mut noise_buf)?;
559        for gather in model.parent_gathers.iter() {
560            let idx = gather.child.as_usize();
561            let col = noise.column_mut(idx)?;
562            if view.overlay.hard_set[idx].is_some() || view.overlay.stochastic[idx].is_some() {
563                col.fill(0.0);
564            } else {
565                sample_noise_column(model.mechanisms.get(gather.child), n_rows, rng, col)?;
566            }
567        }
568    }
569    let mut values_buf = vec![0.0; n_rows * n_nodes];
570    let mut values = ValueBatchMut::new(n_rows, n_nodes, &mut values_buf)?;
571    let overlay = view.overlay.as_ref();
572    for gather in model.parent_gathers.iter() {
573        let node = gather.child;
574        let idx = node.as_usize();
575        ws.prepare(n_rows, gather.n_parents().max(1));
576        gather.gather(values.values, n_rows, &mut ws.parents);
577        let parent_owned = ws.parents[..gather.n_parents().saturating_mul(n_rows)].to_vec();
578        let parents = ParentBatch { n_rows, n_parents: gather.n_parents(), values: &parent_owned };
579        let out = values.column_mut(idx)?;
580        if let Some(v) = overlay.hard_set[idx] {
581            out.fill(v);
582            continue;
583        }
584        if let Some(policy) = &overlay.stochastic[idx] {
585            sample_stochastic(policy, n_rows, rng, out)?;
586            apply_shift(out, overlay.shifts[idx]);
587            continue;
588        }
589        let noise_col = &noise_buf[idx * n_rows..(idx + 1) * n_rows];
590        let slot = if let Some(soft) = &overlay.soft[idx] {
591            soft_to_slot(soft, gather.n_parents())?
592        } else {
593            model.mechanisms.get(node).clone()
594        };
595        evaluate_column(&slot, parents, noise_col, out, ws)?;
596        apply_shift(out, overlay.shifts[idx]);
597    }
598    Ok((values.into_batch(), noise_buf))
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604    use crate::registry::{MechanismRegistry, SelectionPolicy};
605    use antecedent_core::{
606        CausalSchemaBuilder, ExecutionContext, Intervention, MeasurementSpec, RoleHint,
607        SmallRoleSet, Value, ValueType, VariableId,
608    };
609    use antecedent_data::column::{Float64Column, ValidityBitmap};
610    use antecedent_data::{OwnedColumn, OwnedColumnarStorage, TabularData};
611    use antecedent_graph::{Dag, DenseNodeId};
612    use std::sync::Arc;
613
614    fn fitted_chain() -> CompiledCausalModel {
615        let n = 30usize;
616        let mut b = CausalSchemaBuilder::new();
617        b.add_variable(
618            "x",
619            ValueType::Continuous,
620            SmallRoleSet::from_hint(RoleHint::Context),
621            None,
622            None,
623            MeasurementSpec::default(),
624        )
625        .unwrap();
626        b.add_variable(
627            "y",
628            ValueType::Continuous,
629            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
630            None,
631            None,
632            MeasurementSpec::default(),
633        )
634        .unwrap();
635        let schema = b.build().unwrap();
636        let xv: Vec<f64> = (0..n).map(|i| i as f64 * 0.1).collect();
637        let yv: Vec<f64> = xv.iter().map(|x| 1.0 + 2.0 * x).collect();
638        let validity = ValidityBitmap::all_valid(n);
639        let cols = vec![
640            OwnedColumn::Float64(
641                Float64Column::new(VariableId::from_raw(0), Arc::from(xv), validity.clone())
642                    .unwrap(),
643            ),
644            OwnedColumn::Float64(
645                Float64Column::new(VariableId::from_raw(1), Arc::from(yv), validity).unwrap(),
646            ),
647        ];
648        let data =
649            TabularData::new(OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap());
650        let mut g = Dag::with_variables(2);
651        g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
652        let compiled = CompiledCausalModel::compile(g).unwrap();
653        let (store, _) = MechanismRegistry::standard()
654            .assign_and_fit(&compiled, &data, SelectionPolicy::BestScore)
655            .unwrap();
656        compiled.with_mechanisms(store)
657    }
658
659    #[test]
660    fn hard_intervention_fixes_column() {
661        let model = fitted_chain();
662        let mut rng = CausalRng::from_seed(1);
663        let mut ws = MechanismWorkspace::default();
664        let t = VariableId::from_raw(0);
665        let batch = sample_interventional(
666            &model,
667            &[Intervention::set(t, Value::f64(3.0))],
668            20,
669            &mut rng,
670            &mut ws,
671            &ExecutionContext::for_tests(1),
672        )
673        .unwrap();
674        let col = batch.column(0).unwrap();
675        assert!(col.iter().all(|&v| (v - 3.0).abs() < 1e-12));
676    }
677}