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_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
58pub 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 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
118pub 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 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 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 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 }
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
296pub 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
339pub 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
497pub 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
543pub 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}