1#![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
24pub 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
40pub 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
60pub 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
78pub 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
96pub 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 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 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
167pub 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
219pub 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 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 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 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 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 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 }
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
417pub 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
460pub 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
618pub 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
664pub 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 #[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}