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 observational draws into a caller-owned column-major buffer.
41///
42/// `values` must be at least `n_rows * n_nodes`. The buffer is overwritten,
43/// not grown; coalition loops can reuse one allocation across masks.
44///
45/// # Errors
46///
47/// Unfitted mechanisms, `n_rows == 0`, or a short buffer.
48pub fn sample_observational_into(
49    model: &CompiledCausalModel,
50    n_rows: usize,
51    rng: &mut CausalRng,
52    ws: &mut MechanismWorkspace,
53    values: &mut [f64],
54    _ctx: &ExecutionContext,
55) -> Result<(), ModelError> {
56    let view = ModelView::observational(model);
57    sample_with_overlay_into(&view, n_rows, rng, ws, values)
58}
59
60/// Sample under interventions (compiled to an overlay; model is not cloned).
61///
62/// # Errors
63///
64/// Overlay / mechanism failures.
65pub fn sample_interventional(
66    model: &CompiledCausalModel,
67    interventions: &[Intervention],
68    n_rows: usize,
69    rng: &mut CausalRng,
70    ws: &mut MechanismWorkspace,
71    _ctx: &ExecutionContext,
72) -> Result<ValueBatch, ModelError> {
73    let overlay = InterventionOverlay::from_interventions(model, interventions)?;
74    let view = ModelView::with_overlay(model, overlay);
75    sample_with_overlay(&view, n_rows, rng, ws)
76}
77
78/// Core ancestral sampler with overlay.
79///
80/// # Errors
81///
82/// Mechanism failures, or an overlay with a node both hard-set and shifted
83/// (see [`InterventionOverlay::validate`]).
84pub fn sample_with_overlay(
85    view: &ModelView<'_>,
86    n_rows: usize,
87    rng: &mut CausalRng,
88    ws: &mut MechanismWorkspace,
89) -> Result<ValueBatch, ModelError> {
90    let n_nodes = view.model.n_nodes();
91    let mut values_buf = vec![0.0; n_rows.saturating_mul(n_nodes)];
92    sample_with_overlay_into(view, n_rows, rng, ws, &mut values_buf)?;
93    Ok(ValueBatch { n_rows, n_nodes, values: std::sync::Arc::from(values_buf) })
94}
95
96/// Ancestral sample into a caller-owned column-major buffer.
97///
98/// # Errors
99///
100/// Shape, overlay, or mechanism failures.
101pub fn sample_with_overlay_into(
102    view: &ModelView<'_>,
103    n_rows: usize,
104    rng: &mut CausalRng,
105    ws: &mut MechanismWorkspace,
106    values: &mut [f64],
107) -> Result<(), ModelError> {
108    if n_rows == 0 {
109        return Err(ModelError::Shape { message: "n_rows must be > 0".into() });
110    }
111    // Overlays reaching here need not have come from `from_interventions` — this is a
112    // public entry point taking a caller-built `ModelView`, so the invariant the hard-set
113    // branch below relies on is re-established rather than assumed.
114    view.overlay.validate()?;
115    let model = view.model;
116    let n_nodes = model.n_nodes();
117    let mut values = ValueBatchMut::new(n_rows, n_nodes, values)?;
118    let overlay = view.overlay.as_ref();
119
120    // Gather target hoisted out of the node loop (grow-only) so the parent
121    // batch borrows a buffer disjoint from `ws` — `sample_column` needs
122    // `&mut ws` while parents stay alive, which previously forced a fresh
123    // `to_vec` per node.
124    let mut parent_buf: Vec<f64> = Vec::new();
125    for gather in model.parent_gathers.iter() {
126        let node = gather.child;
127        let idx = node.as_usize();
128        let need = gather.n_parents().max(1).saturating_mul(n_rows);
129        if parent_buf.len() < need {
130            parent_buf.resize(need, 0.0);
131        }
132        gather.gather(values.values, n_rows, &mut parent_buf);
133        let parents = ParentBatch {
134            n_rows,
135            n_parents: gather.n_parents(),
136            values: &parent_buf[..gather.n_parents().saturating_mul(n_rows)],
137        };
138
139        let out = values.column_mut(idx)?;
140
141        if let Some(v) = overlay.hard_set[idx] {
142            out.fill(v);
143            continue;
144        }
145        if let Some(policy) = &overlay.stochastic[idx] {
146            sample_stochastic(policy, n_rows, rng, out)?;
147            apply_shift(out, overlay.shifts[idx]);
148            continue;
149        }
150        if let Some(soft) = &overlay.soft[idx] {
151            let existing = model.mechanisms.get(node);
152            refuse_cross_family_soft(existing, soft)?;
153            let slot = soft_to_slot(soft, gather.n_parents())?;
154            sample_column(&slot, parents, rng, out, ws)?;
155            apply_shift(out, overlay.shifts[idx]);
156            continue;
157        }
158
159        let slot = model.mechanisms.get(node);
160        sample_column(slot, parents, rng, out, ws)?;
161        apply_shift(out, overlay.shifts[idx]);
162    }
163
164    Ok(())
165}
166
167/// Soft overrides must share noise semantics with the fitted mechanism. Reusing a
168/// Discrete Uniform(0,1) residual as an additive Gaussian U (or the reverse) is not
169/// a well-defined counterfactual.
170///
171/// # Errors
172///
173/// [`ModelError::Unsupported`] when the fitted slot and override disagree on noise kind.
174pub fn refuse_cross_family_soft(
175    existing: &MechanismSlot,
176    soft: &MechanismOverride,
177) -> Result<(), ModelError> {
178    let have = noise_kind_slot(existing);
179    let want = noise_kind_override(soft.family_id.as_ref());
180    if have == "any" || have == want {
181        return Ok(());
182    }
183    Err(ModelError::Unsupported {
184        message: format!(
185            "cross-family soft override refused: fitted `{have}` vs override `{}` (`{want}`)",
186            soft.family_id
187        ),
188    })
189}
190
191fn noise_kind_slot(slot: &MechanismSlot) -> &'static str {
192    match slot {
193        MechanismSlot::Vacant | MechanismSlot::Pending { .. } | MechanismSlot::Dynamic { .. } => {
194            "any"
195        }
196        MechanismSlot::LinearGaussian { .. }
197        | MechanismSlot::HierarchicalLinear { .. }
198        | MechanismSlot::Bvar { .. } => "additive_gaussian",
199        MechanismSlot::Discrete { .. } => "discrete",
200        MechanismSlot::Constant { .. } => "constant",
201        MechanismSlot::LinearGaussianStateSpace { .. } => "lgssm",
202        MechanismSlot::GaussianProcess { .. } => "gaussian_process",
203    }
204}
205
206fn noise_kind_override(family_id: &str) -> &'static str {
207    match family_id {
208        "linear_gaussian" | "hierarchical_linear" | "bvar" | "additive_shift" => {
209            "additive_gaussian"
210        }
211        "discrete" => "discrete",
212        "constant" => "constant",
213        "lgssm" => "lgssm",
214        "gaussian_process" => "gaussian_process",
215        _ => "unknown",
216    }
217}
218
219/// Sample under interventions conditioned on observed node values.
220///
221/// Strategy:
222/// 1. **Rejection sampling** when conditions match within `1e-9` (exact / discrete).
223/// 2. **Likelihood-weighting SIR** when rejection under-accepts: propose from `do(·)`,
224///    weight by `∏_c p(condition_c | parents_c)` via the internal `log_prob_column` evaluation, resample.
225///
226/// Conditioning nodes must not be hard-intervened.
227///
228/// # Errors
229///
230/// Empty condition, intervened condition nodes, density failures, or empty weights.
231pub fn sample_conditional_interventional(
232    model: &CompiledCausalModel,
233    interventions: &[Intervention],
234    condition_nodes: &[antecedent_graph::DenseNodeId],
235    condition_values: &[f64],
236    n_rows: usize,
237    rng: &mut CausalRng,
238    ws: &mut MechanismWorkspace,
239    ctx: &ExecutionContext,
240) -> Result<ValueBatch, ModelError> {
241    if condition_nodes.is_empty() || condition_values.len() != condition_nodes.len() {
242        return Err(ModelError::Shape {
243            message: "conditional interventional sampling needs matching condition_nodes/values"
244                .into(),
245        });
246    }
247    if n_rows == 0 {
248        return Err(ModelError::Shape { message: "n_rows must be > 0".into() });
249    }
250    let overlay = InterventionOverlay::from_interventions(model, interventions)?;
251    for &node in condition_nodes {
252        let idx = node.as_usize();
253        if idx >= model.n_nodes() {
254            return Err(ModelError::Shape { message: "condition node out of range".into() });
255        }
256        if overlay.hard_set[idx].is_some() {
257            return Err(ModelError::Unsupported {
258                message: "cannot condition on a hard-intervened node".into(),
259            });
260        }
261        // A soft/stochastic override on a condition node would make the importance weight
262        // below (`sample_conditional_interventional_lw`) inconsistent: the proposal draws
263        // for that node come from `overlay.soft`/`overlay.stochastic`, but the weight is
264        // computed against the model's *original* mechanism
265        // (`log_prob_column(model.mechanisms.get(node), ...)`), which never consults the
266        // overlay. That mismatch would silently bias the conditional estimate instead of
267        // erroring, so reject it here — matching the hard-set posture above — rather than
268        // letting it through.
269        if overlay.soft[idx].is_some() || overlay.stochastic[idx].is_some() {
270            return Err(ModelError::Unsupported {
271                message: "cannot condition on a soft- or stochastic-intervened node".into(),
272            });
273        }
274    }
275
276    let n_nodes = model.n_nodes();
277    let mut accepted = vec![0.0; n_rows * n_nodes];
278    let mut got = 0usize;
279    let max_attempts = n_rows.saturating_mul(100).max(100);
280    // Overlay built once; the attempt loop previously rebuilt it (five
281    // per-node vectors plus a linear id scan per intervention) per candidate.
282    let overlay = InterventionOverlay::from_interventions(model, interventions)?;
283    let view = ModelView::with_overlay(model, overlay);
284    for _ in 0..max_attempts {
285        if got >= n_rows {
286            break;
287        }
288        let batch = sample_with_overlay(&view, 1, rng, ws)?;
289        let mut ok = true;
290        for (i, &node) in condition_nodes.iter().enumerate() {
291            let v = batch.column(node.as_usize())?[0];
292            if (v - condition_values[i]).abs() > 1e-9 {
293                ok = false;
294                break;
295            }
296        }
297        if !ok {
298            continue;
299        }
300        for node in 0..n_nodes {
301            accepted[node * n_rows + got] = batch.column(node)?[0];
302        }
303        got += 1;
304    }
305    if got >= n_rows {
306        let _ = ctx;
307        return Ok(ValueBatch { n_rows, n_nodes, values: accepted.into() });
308    }
309
310    // Likelihood-weighting / SIR for continuous conditions.
311    sample_conditional_interventional_lw(
312        model,
313        interventions,
314        condition_nodes,
315        condition_values,
316        n_rows,
317        rng,
318        ws,
319        ctx,
320    )
321}
322
323fn sample_conditional_interventional_lw(
324    model: &CompiledCausalModel,
325    interventions: &[Intervention],
326    condition_nodes: &[antecedent_graph::DenseNodeId],
327    condition_values: &[f64],
328    n_rows: usize,
329    rng: &mut CausalRng,
330    ws: &mut MechanismWorkspace,
331    ctx: &ExecutionContext,
332) -> Result<ValueBatch, ModelError> {
333    use crate::mechanism::log_prob_column;
334
335    let n_nodes = model.n_nodes();
336    let n_particles = n_rows.saturating_mul(20).max(64);
337    let proposal = sample_interventional(model, interventions, n_particles, rng, ws, ctx)?;
338    let mut log_w = vec![0.0; n_particles];
339    let mut lp_buf = vec![0.0; n_particles];
340
341    let mut parent_buf: Vec<f64> = Vec::new();
342    for (ci, &node) in condition_nodes.iter().enumerate() {
343        let gather = model.gather_for(node).ok_or_else(|| ModelError::Unsupported {
344            message: format!("missing gather for condition node {node:?}"),
345        })?;
346        let need = gather.n_parents().max(1).saturating_mul(n_particles);
347        if parent_buf.len() < need {
348            parent_buf.resize(need, 0.0);
349        }
350        gather.gather(&proposal.values, n_particles, &mut parent_buf);
351        let parents = ParentBatch {
352            n_rows: n_particles,
353            n_parents: gather.n_parents(),
354            values: &parent_buf[..gather.n_parents().saturating_mul(n_particles)],
355        };
356        // Score the *conditioned* value under each particle's parents.
357        let conditioned = vec![condition_values[ci]; n_particles];
358        log_prob_column(model.mechanisms.get(node), &conditioned, parents, &mut lp_buf)?;
359        for p in 0..n_particles {
360            if !lp_buf[p].is_finite() {
361                return Err(ModelError::Unsupported {
362                    message: format!(
363                        "conditional do: mechanism for node {node:?} cannot provide a finite density \
364                         for likelihood weighting"
365                    ),
366                });
367            }
368            log_w[p] += lp_buf[p];
369        }
370    }
371
372    let max_lw = log_w.iter().copied().fold(f64::NEG_INFINITY, f64::max);
373    if !max_lw.is_finite() {
374        return Err(ModelError::Unsupported {
375            message: "conditional do: all likelihood weights are non-finite".into(),
376        });
377    }
378    let mut weights = vec![0.0; n_particles];
379    let mut sum_w = 0.0;
380    for p in 0..n_particles {
381        let w = (log_w[p] - max_lw).exp();
382        weights[p] = w;
383        sum_w += w;
384    }
385    if sum_w <= 0.0 {
386        return Err(ModelError::Unsupported {
387            message: "conditional do: likelihood weights sum to zero".into(),
388        });
389    }
390    for w in &mut weights {
391        *w /= sum_w;
392    }
393
394    // Systematic resampling.
395    let mut accepted = vec![0.0; n_rows * n_nodes];
396    let u0 = rng.next_f64() / n_rows as f64;
397    let mut cdf = 0.0;
398    let mut idx = 0usize;
399    for i in 0..n_rows {
400        let target = u0 + i as f64 / n_rows as f64;
401        while idx + 1 < n_particles && cdf + weights[idx] < target {
402            cdf += weights[idx];
403            idx += 1;
404        }
405        for node in 0..n_nodes {
406            accepted[node * n_rows + i] = proposal.column(node)?[idx];
407            // Overwrite conditioned nodes with exact condition values.
408        }
409        for (ci, &node) in condition_nodes.iter().enumerate() {
410            accepted[node.as_usize() * n_rows + i] = condition_values[ci];
411        }
412    }
413    let _ = ctx;
414    Ok(ValueBatch { n_rows, n_nodes, values: accepted.into() })
415}
416
417/// Posterior-predictive interventional sampling: for each coefficient draw block,
418/// refresh `LinearGaussian` slots then sample. `draw_updater` mutates slots in place.
419///
420/// # Errors
421///
422/// Updater / sample failures.
423pub fn sample_posterior_predictive<F>(
424    model: &mut CompiledCausalModel,
425    interventions: &[Intervention],
426    n_rows_per_draw: usize,
427    n_draws: usize,
428    rng: &mut CausalRng,
429    ws: &mut MechanismWorkspace,
430    mut draw_updater: F,
431    ctx: &ExecutionContext,
432) -> Result<ValueBatch, ModelError>
433where
434    F: FnMut(usize, &mut CompiledCausalModel) -> Result<(), ModelError>,
435{
436    let n_nodes = model.n_nodes();
437    let total_rows = n_rows_per_draw.saturating_mul(n_draws);
438    let mut all = vec![0.0; total_rows * n_nodes];
439    for d in 0..n_draws {
440        draw_updater(d, model)?;
441        let batch = sample_interventional(model, interventions, n_rows_per_draw, rng, ws, ctx)?;
442        for node in 0..n_nodes {
443            let src = batch.column(node)?;
444            let dest_row0 = d * n_rows_per_draw;
445            let dest = node * total_rows + dest_row0;
446            all[dest..dest + n_rows_per_draw].copy_from_slice(src);
447        }
448    }
449    Ok(ValueBatch { n_rows: total_rows, n_nodes, values: all.into() })
450}
451
452fn apply_shift(out: &mut [f64], shift: f64) {
453    if shift != 0.0 {
454        for v in out.iter_mut() {
455            *v += shift;
456        }
457    }
458}
459
460/// Convert a soft [`MechanismOverride`] into a concrete mechanism slot.
461///
462/// `additive_shift` is rejected here — [`InterventionOverlay::from_interventions`] maps it
463/// onto overlay shifts so sampling paths share noise semantics.
464///
465/// # Errors
466///
467/// Unknown family or shape mismatches.
468pub fn soft_to_slot(
469    soft: &MechanismOverride,
470    n_parents: usize,
471) -> Result<MechanismSlot, ModelError> {
472    match soft.family_id.as_ref() {
473        "constant" => {
474            let v = soft.parameters.first().copied().unwrap_or(0.0);
475            Ok(MechanismSlot::Constant { value: v })
476        }
477        "additive_shift" => Err(ModelError::Unsupported {
478            message: "additive_shift soft overrides must be applied as Intervention::Shift / overlay shifts"
479                .into(),
480        }),
481        "linear_gaussian" => {
482            if soft.parameters.len() < 2 + n_parents {
483                return Err(ModelError::Shape {
484                    message: "linear_gaussian override needs intercept, coeffs..., sigma".into(),
485                });
486            }
487            let intercept = soft.parameters[0];
488            let coeffs = std::sync::Arc::from(soft.parameters[1..=n_parents].to_vec());
489            let sigma = soft.parameters[1 + n_parents].max(1e-12);
490            Ok(MechanismSlot::LinearGaussian { intercept, coeffs, sigma })
491        }
492        "hierarchical_linear" => {
493            if soft.parameters.len() < 3 + n_parents {
494                return Err(ModelError::Shape {
495                    message: "hierarchical_linear override needs intercept, coeffs..., sigma, shrinkage"
496                        .into(),
497                });
498            }
499            let intercept = soft.parameters[0];
500            let coeffs = std::sync::Arc::from(soft.parameters[1..=n_parents].to_vec());
501            let sigma = soft.parameters[1 + n_parents].max(1e-12);
502            let shrinkage = soft.parameters[2 + n_parents].max(0.0);
503            Ok(MechanismSlot::HierarchicalLinear { intercept, coeffs, sigma, shrinkage })
504        }
505        "bvar" => {
506            if soft.parameters.len() < 2 + n_parents {
507                return Err(ModelError::Shape {
508                    message: "bvar override needs intercept, coeffs..., sigma".into(),
509                });
510            }
511            let intercept = soft.parameters[0];
512            let coeffs = std::sync::Arc::from(soft.parameters[1..=n_parents].to_vec());
513            let sigma = soft.parameters[1 + n_parents].max(1e-12);
514            Ok(MechanismSlot::Bvar { intercept, coeffs, sigma })
515        }
516        "discrete" => soft_discrete_slot(soft, n_parents),
517        "lgssm" => {
518            if soft.parameters.len() < 4 {
519                return Err(ModelError::Shape {
520                    message: "lgssm override needs a, process_std, obs_std, initial_mean".into(),
521                });
522            }
523            Ok(MechanismSlot::LinearGaussianStateSpace {
524                a: soft.parameters[0],
525                process_std: soft.parameters[1].max(1e-12),
526                obs_std: soft.parameters[2].max(1e-12),
527                initial_mean: soft.parameters[3],
528            })
529        }
530        "gaussian_process" => soft_gp_slot(soft, n_parents),
531        other => Err(ModelError::Unsupported {
532            message: format!("unknown soft override family {other}"),
533        }),
534    }
535}
536
537fn soft_discrete_slot(
538    soft: &MechanismOverride,
539    n_parents: usize,
540) -> Result<MechanismSlot, ModelError> {
541    if soft.parameters.is_empty() {
542        return Err(ModelError::Shape {
543            message: "discrete override needs k, support..., probs/logits...".into(),
544        });
545    }
546    let k = soft.parameters[0] as usize;
547    if k == 0 {
548        return Err(ModelError::Shape { message: "discrete override k must be > 0".into() });
549    }
550    if soft.parameters.len() < 1 + k {
551        return Err(ModelError::Shape { message: "discrete override truncated support".into() });
552    }
553    let support: std::sync::Arc<[f64]> = std::sync::Arc::from(soft.parameters[1..=k].to_vec());
554    let rest = &soft.parameters[1 + k..];
555    if rest.len() == k {
556        Ok(MechanismSlot::Discrete {
557            support,
558            probs: std::sync::Arc::from(rest.to_vec()),
559            logit_coeffs: None,
560        })
561    } else if rest.len() == k * (1 + n_parents) {
562        Ok(MechanismSlot::Discrete {
563            support,
564            probs: std::sync::Arc::from(vec![1.0 / k as f64; k]),
565            logit_coeffs: Some(std::sync::Arc::from(rest.to_vec())),
566        })
567    } else {
568        Err(ModelError::Shape {
569            message: format!(
570                "discrete override expects {k} probs or {} logits after support, got {}",
571                k * (1 + n_parents),
572                rest.len()
573            ),
574        })
575    }
576}
577
578fn soft_gp_slot(soft: &MechanismOverride, n_parents: usize) -> Result<MechanismSlot, ModelError> {
579    if soft.parameters.len() < 5 {
580        return Err(ModelError::Shape {
581            message: "gaussian_process override truncated header".into(),
582        });
583    }
584    let length_scale = soft.parameters[0].max(1e-12);
585    let variance = soft.parameters[1].max(0.0);
586    let noise_std = soft.parameters[2].max(1e-12);
587    let n_train = soft.parameters[3] as usize;
588    let n_par = soft.parameters[4] as usize;
589    if n_par != n_parents {
590        return Err(ModelError::Shape {
591            message: format!("gaussian_process override n_parents {n_par} != gather {n_parents}"),
592        });
593    }
594    let need = 5 + n_train * n_par + n_train;
595    if soft.parameters.len() < need {
596        return Err(ModelError::Shape {
597            message: format!(
598                "gaussian_process override needs {need} params, got {}",
599                soft.parameters.len()
600            ),
601        });
602    }
603    let x_train = std::sync::Arc::from(soft.parameters[5..5 + n_train * n_par].to_vec());
604    let alpha = std::sync::Arc::from(
605        soft.parameters[5 + n_train * n_par..5 + n_train * n_par + n_train].to_vec(),
606    );
607    Ok(MechanismSlot::GaussianProcess {
608        length_scale,
609        variance,
610        noise_std,
611        x_train,
612        n_train,
613        n_parents: n_par,
614        alpha,
615    })
616}
617
618/// Draw values from a stochastic intervention policy into `out`.
619///
620/// # Errors
621///
622/// Unsupported policy variants.
623pub fn sample_stochastic(
624    policy: &StochasticPolicy,
625    n_rows: usize,
626    rng: &mut CausalRng,
627    out: &mut [f64],
628) -> Result<(), ModelError> {
629    match policy {
630        StochasticPolicy::Bernoulli { p } => {
631            for i in 0..n_rows {
632                out[i] = if rng.next_f64() < *p { 1.0 } else { 0.0 };
633            }
634            Ok(())
635        }
636        StochasticPolicy::Gaussian { mean, variance } => {
637            let s = variance.sqrt();
638            for i in 0..n_rows {
639                out[i] = mean + s * standard_normal(rng);
640            }
641            Ok(())
642        }
643        StochasticPolicy::Categorical { probs } => {
644            let sum: f64 = probs.iter().sum::<f64>().max(f64::EPSILON);
645            for i in 0..n_rows {
646                let u = rng.next_f64() * sum;
647                let mut acc = 0.0;
648                let mut chosen = (probs.len() - 1) as f64;
649                for (k, &p) in probs.iter().enumerate() {
650                    acc += p;
651                    if u <= acc {
652                        chosen = k as f64;
653                        break;
654                    }
655                }
656                out[i] = chosen;
657            }
658            Ok(())
659        }
660        _ => Err(ModelError::Unsupported { message: "unknown stochastic policy".into() }),
661    }
662}
663
664/// Structural path: sample noise then evaluate with overlays applied post-hoc for hard sets.
665///
666/// # Errors
667///
668/// Mechanism failures, or an overlay with a node both hard-set and shifted
669/// (see [`InterventionOverlay::validate`]).
670pub fn sample_structural_with_overlay(
671    view: &ModelView<'_>,
672    n_rows: usize,
673    rng: &mut CausalRng,
674    ws: &mut MechanismWorkspace,
675) -> Result<(ValueBatch, Vec<f64>), ModelError> {
676    view.overlay.validate()?;
677    let model = view.model;
678    let n_nodes = model.n_nodes();
679    let mut noise_buf = vec![0.0; n_rows * n_nodes];
680    {
681        let mut noise = NoiseBatchMut::new(n_rows, n_nodes, &mut noise_buf)?;
682        for gather in model.parent_gathers.iter() {
683            let idx = gather.child.as_usize();
684            let col = noise.column_mut(idx)?;
685            if view.overlay.hard_set[idx].is_some() || view.overlay.stochastic[idx].is_some() {
686                col.fill(0.0);
687            } else {
688                sample_noise_column(model.mechanisms.get(gather.child), n_rows, rng, col)?;
689            }
690        }
691    }
692    let mut values_buf = vec![0.0; n_rows * n_nodes];
693    let mut values = ValueBatchMut::new(n_rows, n_nodes, &mut values_buf)?;
694    let overlay = view.overlay.as_ref();
695    let mut parent_buf: Vec<f64> = Vec::new();
696    for gather in model.parent_gathers.iter() {
697        let node = gather.child;
698        let idx = node.as_usize();
699        let need = gather.n_parents().max(1).saturating_mul(n_rows);
700        if parent_buf.len() < need {
701            parent_buf.resize(need, 0.0);
702        }
703        gather.gather(values.values, n_rows, &mut parent_buf);
704        let parents = ParentBatch {
705            n_rows,
706            n_parents: gather.n_parents(),
707            values: &parent_buf[..gather.n_parents().saturating_mul(n_rows)],
708        };
709        let out = values.column_mut(idx)?;
710        if let Some(v) = overlay.hard_set[idx] {
711            out.fill(v);
712            continue;
713        }
714        if let Some(policy) = &overlay.stochastic[idx] {
715            sample_stochastic(policy, n_rows, rng, out)?;
716            apply_shift(out, overlay.shifts[idx]);
717            continue;
718        }
719        let noise_col = &noise_buf[idx * n_rows..(idx + 1) * n_rows];
720        let slot = if let Some(soft) = &overlay.soft[idx] {
721            let existing = model.mechanisms.get(node);
722            refuse_cross_family_soft(existing, soft)?;
723            soft_to_slot(soft, gather.n_parents())?
724        } else {
725            model.mechanisms.get(node).clone()
726        };
727        evaluate_column(&slot, parents, noise_col, out, ws)?;
728        apply_shift(out, overlay.shifts[idx]);
729    }
730    Ok((values.into_batch(), noise_buf))
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736    use crate::registry::{MechanismRegistry, SelectionPolicy};
737    use antecedent_core::{
738        CausalSchemaBuilder, ExecutionContext, Intervention, MeasurementSpec, RoleHint,
739        SmallRoleSet, Value, ValueType, VariableId,
740    };
741    use antecedent_data::column::{Float64Column, ValidityBitmap};
742    use antecedent_data::{OwnedColumn, OwnedColumnarStorage, TabularData};
743    use antecedent_graph::{Dag, DenseNodeId};
744    use std::sync::Arc;
745
746    fn fitted_chain() -> CompiledCausalModel {
747        let n = 30usize;
748        let mut b = CausalSchemaBuilder::new();
749        b.add_variable(
750            "x",
751            ValueType::Continuous,
752            SmallRoleSet::from_hint(RoleHint::Context),
753            None,
754            None,
755            MeasurementSpec::default(),
756        )
757        .unwrap();
758        b.add_variable(
759            "y",
760            ValueType::Continuous,
761            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
762            None,
763            None,
764            MeasurementSpec::default(),
765        )
766        .unwrap();
767        let schema = b.build().unwrap();
768        let xv: Vec<f64> = (0..n).map(|i| i as f64 * 0.1).collect();
769        let yv: Vec<f64> = xv.iter().map(|x| 1.0 + 2.0 * x).collect();
770        let validity = ValidityBitmap::all_valid(n);
771        let cols = vec![
772            OwnedColumn::Float64(
773                Float64Column::new(VariableId::from_raw(0), Arc::from(xv), validity.clone())
774                    .unwrap(),
775            ),
776            OwnedColumn::Float64(
777                Float64Column::new(VariableId::from_raw(1), Arc::from(yv), validity).unwrap(),
778            ),
779        ];
780        let data =
781            TabularData::new(OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap());
782        let mut g = Dag::with_variables(2);
783        g.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
784        let compiled = CompiledCausalModel::compile(g).unwrap();
785        let (store, _) = MechanismRegistry::standard()
786            .assign_and_fit(&compiled, &data, SelectionPolicy::BestScore)
787            .unwrap();
788        compiled.with_mechanisms(store)
789    }
790
791    #[test]
792    fn hard_intervention_fixes_column() {
793        let model = fitted_chain();
794        let mut rng = CausalRng::from_seed(1);
795        let mut ws = MechanismWorkspace::default();
796        let t = VariableId::from_raw(0);
797        let batch = sample_interventional(
798            &model,
799            &[Intervention::set(t, Value::f64(3.0))],
800            20,
801            &mut rng,
802            &mut ws,
803            &ExecutionContext::for_tests(1),
804        )
805        .unwrap();
806        let col = batch.column(0).unwrap();
807        assert!(col.iter().all(|&v| (v - 3.0).abs() < 1e-12));
808    }
809
810    /// Conditioning on a node that is *also* named with a `Stochastic` (or `Soft`) override
811    /// must be refused, matching the existing hard-set posture.
812    ///
813    /// Without this check, `sample_conditional_interventional_lw` would draw the condition
814    /// node's proposal values from the overlay's stochastic policy, but weight them under
815    /// `model.mechanisms.get(node)` -- the model's *original*, un-overridden mechanism. That
816    /// mismatch between what generated the draws and what scores them is an internally
817    /// inconsistent importance weight, silently biasing the conditional estimate instead of
818    /// erroring.
819    #[test]
820    fn conditioning_on_stochastic_intervened_node_is_refused() {
821        let model = fitted_chain();
822        let mut rng = CausalRng::from_seed(1);
823        let mut ws = MechanismWorkspace::default();
824        let y = VariableId::from_raw(1);
825        let y_node = DenseNodeId::from_raw(1);
826        let err = sample_conditional_interventional(
827            &model,
828            &[Intervention::Stochastic {
829                variable: y,
830                policy: StochasticPolicy::Gaussian { mean: 0.0, variance: 1.0 },
831            }],
832            &[y_node],
833            &[0.5],
834            10,
835            &mut rng,
836            &mut ws,
837            &ExecutionContext::for_tests(1),
838        )
839        .unwrap_err();
840        assert!(matches!(err, ModelError::Unsupported { .. }), "expected Unsupported, got {err:?}");
841    }
842
843    #[test]
844    fn observational_into_matches_allocating_sample() {
845        let model = fitted_chain();
846        let ctx = ExecutionContext::for_tests(1);
847        let n_rows = 16usize;
848        let n_nodes = model.n_nodes();
849        let mut rng_a = CausalRng::from_seed(7);
850        let mut rng_b = CausalRng::from_seed(7);
851        let mut ws_a = MechanismWorkspace::default();
852        let mut ws_b = MechanismWorkspace::default();
853        let batch = sample_observational(&model, n_rows, &mut rng_a, &mut ws_a, &ctx).unwrap();
854        let mut buf = vec![0.0; n_rows * n_nodes];
855        sample_observational_into(&model, n_rows, &mut rng_b, &mut ws_b, &mut buf, &ctx).unwrap();
856        assert_eq!(&*batch.values, buf.as_slice());
857        sample_observational_into(&model, n_rows, &mut rng_b, &mut ws_b, &mut buf, &ctx).unwrap();
858        assert_eq!(buf.len(), n_rows * n_nodes);
859    }
860}