fugue/runtime/interpreters.rs
1#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/docs/runtime/interpreters.md"))]
2
3use crate::core::address::Address;
4use crate::core::distribution::Distribution;
5use crate::core::model::Model;
6use crate::error::{ErrorCode, FugueError, FugueResult};
7use crate::runtime::handler::{run, Handler};
8use crate::runtime::trace::{Choice, ChoiceValue, Trace};
9
10use rand::RngCore;
11
12/// Panic when a sample site reuses an address already recorded in this
13/// execution's output trace (FG-47).
14///
15/// "Fast" handlers (`PriorHandler`, `ReplayHandler`, `ScoreGivenTrace`) treat a
16/// duplicate address as a programming error and panic with a precise message —
17/// this is the documented fast/safe split. Detection is O(log n) and needs no
18/// extra state: the output trace's `choices` map already contains exactly the
19/// addresses visited so far in this run, so a hit means the address was sampled
20/// twice. Before this check, the second visit silently double-counted its
21/// log-prior while dropping the first choice.
22#[inline]
23fn assert_no_duplicate_sample(trace: &Trace, addr: &Address, handler: &str) {
24 if trace.choices.contains_key(addr) {
25 panic!(
26 "{handler}: address {addr} was sampled twice in one execution \
27 (AddressConflict, ErrorCode::{:?}={}). Every sample site must have \
28 a unique address.",
29 ErrorCode::AddressConflict,
30 ErrorCode::AddressConflict as u32
31 );
32 }
33}
34
35/// Build the [`FugueError`] used for a duplicate sample address, so that "safe"
36/// handlers and the fallible scoring paths surface a real
37/// [`ErrorCode::AddressConflict`] (FG-47) instead of panicking.
38fn address_conflict_error(addr: &Address, handler: &str) -> FugueError {
39 FugueError::ModelError {
40 address: Some(addr.clone()),
41 reason: format!("{handler}: address sampled twice in one execution"),
42 code: ErrorCode::AddressConflict,
43 context: crate::error::ErrorContext::new(),
44 }
45}
46
47// =============================================================================
48// Internal monomorphization macros (FG-54)
49//
50// Each handler used to hand-copy four (now five, with i64) near-identical
51// `on_sample_*` / `on_observe_*` methods. These `macro_rules!` collapse that
52// copy-paste: a handler lists the `(method, type, variant)` rows once and the
53// macro expands the shared body for every supported value type. The behavior is
54// identical to the old hand-written methods, plus the FG-47 duplicate-address
55// check and the FG-48 fresh-logp fix, applied uniformly.
56// =============================================================================
57
58/// The five value types every handler supports, as `(sample_method,
59/// observe_method, rust_type, ChoiceValue variant, type-name literal,
60/// Option-getter, Result-getter)` rows. Passed to the per-handler macros so the
61/// row list lives in exactly one place.
62macro_rules! for_each_value_type {
63 ($m:ident) => {
64 $m! {
65 (on_sample_f64, on_observe_f64, f64, F64, "f64", get_f64, get_f64_result),
66 (on_sample_bool, on_observe_bool, bool, Bool, "bool", get_bool, get_bool_result),
67 (on_sample_u64, on_observe_u64, u64, U64, "u64", get_u64, get_u64_result),
68 (on_sample_usize, on_observe_usize, usize, Usize, "usize", get_usize, get_usize_result),
69 (on_sample_i64, on_observe_i64, i64, I64, "i64", get_i64, get_i64_result),
70 }
71 };
72}
73
74/// Generate the five identical `on_observe_*` methods (every handler scores an
75/// observation the same way: add its log-density to `log_likelihood`).
76macro_rules! impl_observe_methods {
77 ($(($sample:ident, $observe:ident, $ty:ty, $variant:ident, $tyname:literal, $get:ident, $get_res:ident)),* $(,)?) => {
78 $(
79 fn $observe(&mut self, _addr: &Address, dist: &dyn Distribution<$ty>, value: $ty) {
80 self.trace.log_likelihood += dist.log_prob(&value);
81 }
82 )*
83 };
84}
85
86/// `PriorHandler`: draw a fresh value, score it, record it. Panics on a
87/// duplicate address (fast handler).
88macro_rules! impl_prior_sample_methods {
89 ($(($sample:ident, $observe:ident, $ty:ty, $variant:ident, $tyname:literal, $get:ident, $get_res:ident)),* $(,)?) => {
90 $(
91 fn $sample(&mut self, addr: &Address, dist: &dyn Distribution<$ty>) -> $ty {
92 assert_no_duplicate_sample(&self.trace, addr, "PriorHandler");
93 let x = dist.sample(self.rng);
94 let lp = dist.log_prob(&x);
95 self.trace.log_prior += lp;
96 self.trace.choices.insert(
97 addr.clone(),
98 Choice { addr: addr.clone(), value: ChoiceValue::$variant(x), logp: lp },
99 );
100 x
101 }
102 )*
103 };
104}
105
106/// `ReplayHandler`: reuse the base value if present (panic on type mismatch),
107/// otherwise sample fresh; always re-score under the current distribution.
108/// Panics on a duplicate address (fast handler).
109macro_rules! impl_replay_sample_methods {
110 ($(($sample:ident, $observe:ident, $ty:ty, $variant:ident, $tyname:literal, $get:ident, $get_res:ident)),* $(,)?) => {
111 $(
112 fn $sample(&mut self, addr: &Address, dist: &dyn Distribution<$ty>) -> $ty {
113 assert_no_duplicate_sample(&self.trace, addr, "ReplayHandler");
114 let x = if let Some(c) = self.base.choices.get(addr) {
115 match c.value {
116 ChoiceValue::$variant(v) => v,
117 _ => panic!("expected {} at {}", $tyname, addr),
118 }
119 } else {
120 dist.sample(self.rng)
121 };
122 let lp = dist.log_prob(&x);
123 self.trace.log_prior += lp;
124 self.trace.choices.insert(
125 addr.clone(),
126 Choice { addr: addr.clone(), value: ChoiceValue::$variant(x), logp: lp },
127 );
128 x
129 }
130 )*
131 };
132}
133
134/// `ScoreGivenTrace`: read the fixed value from the base trace (panic if
135/// missing or wrong type), score it under the current distribution, and store a
136/// FRESH choice carrying that newly computed logp (FG-48). Panics on a duplicate
137/// address (fast handler).
138macro_rules! impl_score_sample_methods {
139 ($(($sample:ident, $observe:ident, $ty:ty, $variant:ident, $tyname:literal, $get:ident, $get_res:ident)),* $(,)?) => {
140 $(
141 fn $sample(&mut self, addr: &Address, dist: &dyn Distribution<$ty>) -> $ty {
142 assert_no_duplicate_sample(&self.trace, addr, "ScoreGivenTrace");
143 let c = self
144 .base
145 .choices
146 .get(addr)
147 .unwrap_or_else(|| panic!("missing value for site {} in base trace", addr));
148 let x = match c.value {
149 ChoiceValue::$variant(v) => v,
150 _ => panic!("expected {} at {}", $tyname, addr),
151 };
152 let lp = dist.log_prob(&x);
153 self.trace.log_prior += lp;
154 // FG-48: store the freshly computed logp, not the stale base one.
155 self.trace.choices.insert(
156 addr.clone(),
157 Choice { addr: addr.clone(), value: ChoiceValue::$variant(x), logp: lp },
158 );
159 x
160 }
161 )*
162 };
163}
164
165/// `SafeReplayHandler`: like `ReplayHandler` but recovers from missing/mismatched
166/// base values by sampling fresh (optionally warning). A duplicate address is a
167/// programming error even in the safe handler, so it invalidates the trace with
168/// `-inf` and warns rather than silently double-counting (FG-47).
169macro_rules! impl_safe_replay_sample_methods {
170 ($(($sample:ident, $observe:ident, $ty:ty, $variant:ident, $tyname:literal, $get:ident, $get_res:ident)),* $(,)?) => {
171 $(
172 fn $sample(&mut self, addr: &Address, dist: &dyn Distribution<$ty>) -> $ty {
173 if self.trace.choices.contains_key(addr) {
174 if self.warn_on_mismatch {
175 eprintln!("Warning: {}", address_conflict_error(addr, "SafeReplayHandler"));
176 }
177 self.trace.log_prior += f64::NEG_INFINITY;
178 return dist.sample(self.rng);
179 }
180 let x = match self.base.$get(addr) {
181 Some(v) => v,
182 None => {
183 if self.warn_on_mismatch && self.base.choices.contains_key(addr) {
184 if let Some(choice) = self.base.choices.get(addr) {
185 eprintln!(
186 "Warning: Type mismatch at {}: expected {}, found {}",
187 addr, $tyname, choice.value.type_name()
188 );
189 }
190 }
191 dist.sample(self.rng)
192 }
193 };
194 let lp = dist.log_prob(&x);
195 self.trace.log_prior += lp;
196 self.trace.choices.insert(
197 addr.clone(),
198 Choice { addr: addr.clone(), value: ChoiceValue::$variant(x), logp: lp },
199 );
200 x
201 }
202 )*
203 };
204}
205
206/// `SafeScoreGivenTrace`: like `ScoreGivenTrace` but returns an invalid (`-inf`)
207/// trace instead of panicking on a missing/mismatched address, and stores a
208/// FRESH choice with the newly computed logp (FG-48). A duplicate address
209/// invalidates the trace (FG-47).
210macro_rules! impl_safe_score_sample_methods {
211 ($(($sample:ident, $observe:ident, $ty:ty, $variant:ident, $tyname:literal, $get:ident, $get_res:ident)),* $(,)?) => {
212 $(
213 fn $sample(&mut self, addr: &Address, dist: &dyn Distribution<$ty>) -> $ty {
214 if self.trace.choices.contains_key(addr) {
215 if self.warn_on_error {
216 eprintln!("Warning: {}", address_conflict_error(addr, "SafeScoreGivenTrace"));
217 }
218 self.trace.log_prior += f64::NEG_INFINITY;
219 return <$ty as Default>::default();
220 }
221 match self.base.$get_res(addr) {
222 Ok(x) => {
223 let lp = dist.log_prob(&x);
224 self.trace.log_prior += lp;
225 // FG-48: fresh logp consistent with what we accumulated.
226 self.trace.choices.insert(
227 addr.clone(),
228 Choice { addr: addr.clone(), value: ChoiceValue::$variant(x), logp: lp },
229 );
230 x
231 }
232 Err(e) => {
233 if self.warn_on_error {
234 eprintln!("Warning: Failed to get {} at {}: {}", $tyname, addr, e);
235 }
236 self.trace.log_prior += f64::NEG_INFINITY;
237 <$ty as Default>::default()
238 }
239 }
240 }
241 )*
242 };
243}
244
245/// `StrictScoreGivenTrace`: the fallible, structure-checking scoring path
246/// (FG-20/FG-21). It records the FIRST structural problem into `self.error`
247/// instead of panicking: an address absent from the base trace or a type
248/// mismatch yields `ErrorCode::UnexpectedModelStructure`; a duplicate address
249/// yields `ErrorCode::AddressConflict`. On success it stores a fresh, correctly
250/// scored choice.
251macro_rules! impl_strict_score_sample_methods {
252 ($(($sample:ident, $observe:ident, $ty:ty, $variant:ident, $tyname:literal, $get:ident, $get_res:ident)),* $(,)?) => {
253 $(
254 fn $sample(&mut self, addr: &Address, dist: &dyn Distribution<$ty>) -> $ty {
255 if self.trace.choices.contains_key(addr) {
256 if self.error.is_none() {
257 *self.error = Some(address_conflict_error(addr, "StrictScoreGivenTrace"));
258 }
259 return <$ty as Default>::default();
260 }
261 match self.base.$get_res(addr) {
262 Ok(x) => {
263 let lp = dist.log_prob(&x);
264 self.trace.log_prior += lp;
265 self.trace.choices.insert(
266 addr.clone(),
267 Choice { addr: addr.clone(), value: ChoiceValue::$variant(x), logp: lp },
268 );
269 x
270 }
271 Err(cause) => {
272 if self.error.is_none() {
273 *self.error = Some(FugueError::ModelError {
274 address: Some(addr.clone()),
275 reason: format!(
276 "model visited address {} not present (as {}) in the base \
277 trace; structure varies between the base trace and this model",
278 addr, $tyname
279 ),
280 code: ErrorCode::UnexpectedModelStructure,
281 context: crate::error::ErrorContext::new().with_cause(cause),
282 });
283 }
284 <$ty as Default>::default()
285 }
286 }
287 }
288 )*
289 };
290}
291
292/// `ReconcilingScoreGivenTrace`: the reconciling scoring path (FG-20/FG-21).
293/// Addresses present in the base trace are replayed and re-scored; NEW addresses
294/// (absent, or present with the wrong type) are sampled fresh from the prior and
295/// their log-prior accumulated (the RJMCMC-correct treatment of prior-proposed
296/// fresh dimensions) and recorded in `fresh`. Vanished addresses are computed
297/// after the run by the driver. A duplicate address is still an error.
298macro_rules! impl_reconciling_score_sample_methods {
299 ($(($sample:ident, $observe:ident, $ty:ty, $variant:ident, $tyname:literal, $get:ident, $get_res:ident)),* $(,)?) => {
300 $(
301 fn $sample(&mut self, addr: &Address, dist: &dyn Distribution<$ty>) -> $ty {
302 if self.trace.choices.contains_key(addr) {
303 if self.error.is_none() {
304 *self.error =
305 Some(address_conflict_error(addr, "ReconcilingScoreGivenTrace"));
306 }
307 return <$ty as Default>::default();
308 }
309 let x = match self.base.$get(addr) {
310 Some(v) => v,
311 None => {
312 // New (or type-changed) dimension: propose from the prior.
313 self.fresh.push(addr.clone());
314 dist.sample(self.rng)
315 }
316 };
317 let lp = dist.log_prob(&x);
318 self.trace.log_prior += lp;
319 self.trace.choices.insert(
320 addr.clone(),
321 Choice { addr: addr.clone(), value: ChoiceValue::$variant(x), logp: lp },
322 );
323 x
324 }
325 )*
326 };
327}
328
329/// Handler for prior sampling - generates fresh random values from distributions.
330///
331/// This is the foundational interpreter that implements standard "forward sampling"
332/// from probabilistic models. It draws fresh values from distributions and accumulates
333/// log-probabilities in the trace.
334///
335/// Example:
336/// ```rust
337/// # use fugue::*;
338/// # use fugue::runtime::interpreters::PriorHandler;
339/// # use rand::rngs::StdRng;
340/// # use rand::SeedableRng;
341///
342/// let model = sample(addr!("x"), Normal::new(0.0, 1.0).unwrap())
343/// .bind(|x| observe(addr!("y"), Normal::new(x, 0.5).unwrap(), 1.2)
344/// .map(move |_| x));
345///
346/// let mut rng = StdRng::seed_from_u64(42);
347/// let (result, trace) = runtime::handler::run(
348/// PriorHandler { rng: &mut rng, trace: Trace::default() },
349/// model
350/// );
351///
352/// assert!(result.is_finite());
353/// assert!(trace.log_likelihood.is_finite());
354/// ```
355pub struct PriorHandler<'r, R: RngCore> {
356 /// Random number generator for sampling.
357 pub rng: &'r mut R,
358 /// Trace to accumulate execution history.
359 pub trace: Trace,
360}
361impl<'r, R: RngCore> Handler for PriorHandler<'r, R> {
362 for_each_value_type!(impl_prior_sample_methods);
363 for_each_value_type!(impl_observe_methods);
364
365 fn on_factor(&mut self, logw: f64) {
366 self.trace.log_factors += logw;
367 }
368
369 fn finish(self) -> Trace {
370 self.trace
371 }
372}
373
374/// Handler for replaying models with values from an existing trace.
375///
376/// ReplayHandler replays a model execution using stored trace values. When a sampling
377/// site is encountered: if the address exists in the base trace, use that value;
378/// if missing, sample fresh. Essential for MCMC where you replay most choices
379/// but sample new values at specific sites.
380///
381/// Example:
382/// ```rust
383/// # use fugue::*;
384/// # use fugue::runtime::interpreters::*;
385/// # use rand::rngs::StdRng;
386/// # use rand::SeedableRng;
387///
388/// // Create base trace
389/// let mut rng = StdRng::seed_from_u64(42);
390/// let model_fn = || sample(addr!("x"), Normal::new(0.0, 1.0).unwrap());
391/// let (original, base_trace) = runtime::handler::run(
392/// PriorHandler { rng: &mut rng, trace: Trace::default() },
393/// model_fn()
394/// );
395///
396/// // Replay using base trace values
397/// let (replayed, _) = runtime::handler::run(
398/// ReplayHandler {
399/// rng: &mut rng,
400/// base: base_trace,
401/// trace: Trace::default()
402/// },
403/// model_fn()
404/// );
405///
406/// assert_eq!(original, replayed); // Same value replayed
407/// ```
408pub struct ReplayHandler<'r, R: RngCore> {
409 /// Random number generator for sampling at addresses not in base trace.
410 pub rng: &'r mut R,
411 /// Base trace containing values to replay.
412 pub base: Trace,
413 /// New trace to accumulate the replay execution.
414 pub trace: Trace,
415}
416impl<'r, R: RngCore> Handler for ReplayHandler<'r, R> {
417 for_each_value_type!(impl_replay_sample_methods);
418 for_each_value_type!(impl_observe_methods);
419
420 fn on_factor(&mut self, logw: f64) {
421 self.trace.log_factors += logw;
422 }
423
424 fn finish(self) -> Trace {
425 self.trace
426 }
427}
428
429/// Handler for scoring a model given a complete trace of fixed choices.
430///
431/// ScoreGivenTrace computes log-probability of a model execution where all random
432/// choices are predetermined. No sampling occurs - values are looked up from the
433/// base trace and their log-probabilities computed. Essential for MCMC acceptance
434/// ratios, importance sampling, and model comparison.
435///
436/// Example:
437/// ```rust
438/// # use fugue::*;
439/// # use fugue::runtime::interpreters::*;
440/// # use rand::rngs::StdRng;
441/// # use rand::SeedableRng;
442///
443/// // Create a complete trace
444/// let mut rng = StdRng::seed_from_u64(42);
445/// let (_, complete_trace) = runtime::handler::run(
446/// PriorHandler { rng: &mut rng, trace: Trace::default() },
447/// sample(addr!("x"), Normal::new(0.0, 1.0).unwrap())
448/// );
449///
450/// // Score under different model parameters
451/// let (value, score_trace) = runtime::handler::run(
452/// ScoreGivenTrace {
453/// base: complete_trace,
454/// trace: Trace::default()
455/// },
456/// sample(addr!("x"), Normal::new(1.0, 2.0).unwrap()) // Different parameters
457/// );
458///
459/// assert!(score_trace.total_log_weight().is_finite());
460/// ```
461pub struct ScoreGivenTrace {
462 /// Base trace containing the fixed choices to score.
463 pub base: Trace,
464 /// New trace to accumulate log-probabilities.
465 pub trace: Trace,
466}
467impl Handler for ScoreGivenTrace {
468 for_each_value_type!(impl_score_sample_methods);
469 for_each_value_type!(impl_observe_methods);
470
471 fn on_factor(&mut self, logw: f64) {
472 self.trace.log_factors += logw;
473 }
474
475 fn finish(self) -> Trace {
476 self.trace
477 }
478}
479
480/// Safe version of ReplayHandler that gracefully handles trace inconsistencies.
481///
482/// SafeReplayHandler replays model execution like ReplayHandler, but handles type
483/// mismatches and missing addresses gracefully by logging warnings and sampling
484/// fresh values instead of panicking. Essential for production systems where
485/// trace consistency cannot be guaranteed.
486///
487/// Example:
488/// ```rust
489/// # use fugue::*;
490/// # use fugue::runtime::interpreters::*;
491/// # use rand::rngs::StdRng;
492/// # use rand::SeedableRng;
493///
494/// // Create trace with potential inconsistencies
495/// let mut rng = StdRng::seed_from_u64(42);
496/// let (_, base_trace) = runtime::handler::run(
497/// PriorHandler { rng: &mut rng, trace: Trace::default() },
498/// sample(addr!("x"), Normal::new(0.0, 1.0).unwrap()) // f64 value
499/// );
500///
501/// // Safe replay handles type mismatch gracefully
502/// let (result, trace) = runtime::handler::run(
503/// SafeReplayHandler {
504/// rng: &mut rng,
505/// base: base_trace,
506/// trace: Trace::default(),
507/// warn_on_mismatch: true, // Enable warnings
508/// },
509/// sample(addr!("x"), Bernoulli::new(0.5).unwrap()) // Expects bool
510/// );
511///
512/// assert!(trace.total_log_weight().is_finite()); // Continues execution
513/// ```
514pub struct SafeReplayHandler<'r, R: RngCore> {
515 /// Random number generator for sampling at addresses not in base trace.
516 pub rng: &'r mut R,
517 /// Base trace containing values to replay.
518 pub base: Trace,
519 /// New trace to accumulate the replay execution.
520 pub trace: Trace,
521 /// Whether to log warnings on type mismatches (useful for debugging).
522 pub warn_on_mismatch: bool,
523}
524impl<'r, R: RngCore> Handler for SafeReplayHandler<'r, R> {
525 for_each_value_type!(impl_safe_replay_sample_methods);
526 for_each_value_type!(impl_observe_methods);
527
528 fn on_factor(&mut self, logw: f64) {
529 self.trace.log_factors += logw;
530 }
531
532 fn finish(self) -> Trace {
533 self.trace
534 }
535}
536
537/// Safe version of ScoreGivenTrace that gracefully handles incomplete traces.
538///
539/// SafeScoreGivenTrace computes log-probability like ScoreGivenTrace, but handles
540/// missing addresses or type mismatches by returning negative infinity log-weight
541/// instead of panicking. Essential for production inference where trace validity
542/// cannot be guaranteed.
543///
544/// Example:
545/// ```rust
546/// # use fugue::*;
547/// # use fugue::runtime::interpreters::*;
548/// # use rand::rngs::StdRng;
549/// # use rand::SeedableRng;
550///
551/// // Create incomplete trace
552/// let mut rng = StdRng::seed_from_u64(42);
553/// let (_, incomplete_trace) = runtime::handler::run(
554/// PriorHandler { rng: &mut rng, trace: Trace::default() },
555/// sample(addr!("x"), Normal::new(0.0, 1.0).unwrap()) // Only has "x"
556/// );
557///
558/// // Safe scoring handles missing address gracefully
559/// let (_, score_trace) = runtime::handler::run(
560/// SafeScoreGivenTrace {
561/// base: incomplete_trace,
562/// trace: Trace::default(),
563/// warn_on_error: true, // Enable warnings
564/// },
565/// sample(addr!("missing"), Normal::new(0.0, 1.0).unwrap()) // Address not in base
566/// );
567///
568/// assert_eq!(score_trace.total_log_weight(), f64::NEG_INFINITY); // Graceful failure
569/// ```
570pub struct SafeScoreGivenTrace {
571 /// Base trace containing the fixed choices to score.
572 pub base: Trace,
573 /// New trace to accumulate log-probabilities.
574 pub trace: Trace,
575 /// Whether to log warnings on missing addresses or type mismatches.
576 pub warn_on_error: bool,
577}
578impl Handler for SafeScoreGivenTrace {
579 for_each_value_type!(impl_safe_score_sample_methods);
580 for_each_value_type!(impl_observe_methods);
581
582 fn on_factor(&mut self, logw: f64) {
583 self.trace.log_factors += logw;
584 }
585
586 fn finish(self) -> Trace {
587 self.trace
588 }
589}
590
591// =============================================================================
592// Structure-varying (trans-dimensional) scoring paths (FG-20 / FG-21)
593// =============================================================================
594
595/// The strict, fallible sibling of [`ScoreGivenTrace`].
596///
597/// This handler backs [`score_given_trace_strict`]. Instead of panicking when
598/// the model's address structure differs from the base trace, it records the
599/// first structural problem so the driver can return a [`FugueError`]:
600///
601/// - visiting an address absent from the base trace (or present with the wrong
602/// value type) -> [`ErrorCode::UnexpectedModelStructure`];
603/// - visiting the same address twice -> [`ErrorCode::AddressConflict`].
604///
605/// On success every visited site stores a fresh, correctly scored choice.
606pub struct StrictScoreGivenTrace<'e> {
607 /// Base trace containing the fixed choices to score.
608 pub base: Trace,
609 /// New trace to accumulate log-probabilities.
610 pub trace: Trace,
611 /// First structural error encountered, surfaced by the driver.
612 error: &'e mut Option<FugueError>,
613}
614impl<'e> Handler for StrictScoreGivenTrace<'e> {
615 for_each_value_type!(impl_strict_score_sample_methods);
616 for_each_value_type!(impl_observe_methods);
617
618 fn on_factor(&mut self, logw: f64) {
619 self.trace.log_factors += logw;
620 }
621
622 fn finish(self) -> Trace {
623 self.trace
624 }
625}
626
627/// Score `model` against `base` strictly, returning an error rather than
628/// panicking when the model's address structure does not match the base trace
629/// (FG-20 / FG-21).
630///
631/// Returns `Ok((value, scored_trace))` when every sample site visited by the
632/// model is present in `base` with a matching value type. Returns `Err` with
633/// [`ErrorCode::UnexpectedModelStructure`] if the model visits an address absent
634/// from `base` (a branch opened by a differing latent value), or
635/// [`ErrorCode::AddressConflict`] if the model visits the same address twice.
636///
637/// This is the mechanism the MCMC layer needs to stop crashing on
638/// structure-varying proposals; wiring it into the samplers is a separate work
639/// package.
640///
641/// # Example
642///
643/// ```rust
644/// # use fugue::*;
645/// # use fugue::runtime::interpreters::{score_given_trace_strict, PriorHandler};
646/// # use rand::rngs::StdRng;
647/// # use rand::SeedableRng;
648/// let mut rng = StdRng::seed_from_u64(1);
649/// let (_, base) = runtime::handler::run(
650/// PriorHandler { rng: &mut rng, trace: Trace::default() },
651/// sample(addr!("x"), Normal::new(0.0, 1.0).unwrap()),
652/// );
653///
654/// // Same structure: Ok.
655/// assert!(score_given_trace_strict(
656/// base.clone(),
657/// sample(addr!("x"), Normal::new(0.0, 1.0).unwrap()),
658/// ).is_ok());
659///
660/// // Model reaches an address the base trace never recorded: Err.
661/// let err = score_given_trace_strict(
662/// base,
663/// sample(addr!("y"), Normal::new(0.0, 1.0).unwrap()),
664/// ).unwrap_err();
665/// assert_eq!(err.code(), ErrorCode::UnexpectedModelStructure);
666/// ```
667pub fn score_given_trace_strict<A>(base: Trace, model: Model<A>) -> FugueResult<(A, Trace)> {
668 let mut error: Option<FugueError> = None;
669 let handler = StrictScoreGivenTrace {
670 base,
671 trace: Trace::default(),
672 error: &mut error,
673 };
674 let (a, trace) = run(handler, model);
675 match error {
676 Some(e) => Err(e),
677 None => Ok((a, trace)),
678 }
679}
680
681/// Report of the structural differences reconciled by
682/// [`score_given_trace_reconciled`].
683#[derive(Clone, Debug, Default, PartialEq)]
684pub struct ReconcileReport {
685 /// Addresses visited by the model that were absent from the base trace (or
686 /// present with the wrong value type). Each was proposed fresh from its
687 /// prior and its `log_prob` accumulated into `log_prior`.
688 pub fresh_addresses: Vec<Address>,
689 /// Addresses present in the base trace that the model did NOT visit. Their
690 /// contribution should be dropped by the caller (e.g. removed from the
691 /// reverse-move density in an RJMCMC step).
692 pub vanished_addresses: Vec<Address>,
693}
694
695/// The reconciling sibling of [`ScoreGivenTrace`], backing
696/// [`score_given_trace_reconciled`].
697///
698/// Addresses present in the base trace are replayed and re-scored. NEW addresses
699/// (absent, or present with a different value type) are sampled fresh from the
700/// prior, their `log_prob` accumulated into `log_prior`, and recorded in
701/// `fresh`. A duplicate address is still an error.
702pub struct ReconcilingScoreGivenTrace<'r, 'f, 'e, R: RngCore> {
703 /// RNG used to propose fresh values for new addresses.
704 pub rng: &'r mut R,
705 /// Base trace containing the fixed choices to replay/score.
706 pub base: Trace,
707 /// New trace accumulating the reconciled execution.
708 pub trace: Trace,
709 /// Addresses sampled fresh from the prior (not present in `base`), in
710 /// visitation order. Borrowed so the driver can read it after `finish`.
711 fresh: &'f mut Vec<Address>,
712 /// First duplicate-address error encountered, surfaced by the driver.
713 error: &'e mut Option<FugueError>,
714}
715impl<'r, 'f, 'e, R: RngCore> Handler for ReconcilingScoreGivenTrace<'r, 'f, 'e, R> {
716 for_each_value_type!(impl_reconciling_score_sample_methods);
717 for_each_value_type!(impl_observe_methods);
718
719 fn on_factor(&mut self, logw: f64) {
720 self.trace.log_factors += logw;
721 }
722
723 fn finish(self) -> Trace {
724 self.trace
725 }
726}
727
728/// Score `model` against `base`, reconciling a differing address structure
729/// instead of panicking (FG-20 / FG-21).
730///
731/// Addresses shared with `base` are replayed and re-scored under the current
732/// model. Addresses the model introduces that are **not** in `base` (new
733/// branches) are sampled fresh from their prior and their log-prior accumulated
734/// — the RJMCMC-correct treatment of prior-proposed fresh dimensions. Addresses
735/// in `base` that the model does **not** visit are reported as
736/// [`ReconcileReport::vanished_addresses`] so the caller can drop their
737/// contribution.
738///
739/// Returns `Err` with [`ErrorCode::AddressConflict`] only if the model visits
740/// the same address twice.
741///
742/// # Example
743///
744/// ```rust
745/// # use fugue::*;
746/// # use fugue::runtime::interpreters::{score_given_trace_reconciled, PriorHandler};
747/// # use rand::rngs::StdRng;
748/// # use rand::SeedableRng;
749/// let mut rng = StdRng::seed_from_u64(7);
750/// let (_, base) = runtime::handler::run(
751/// PriorHandler { rng: &mut rng, trace: Trace::default() },
752/// sample(addr!("x"), Normal::new(0.0, 1.0).unwrap()),
753/// );
754///
755/// // Model drops "x" and introduces "y": "y" is proposed fresh, "x" vanished.
756/// let (_v, trace, report) = score_given_trace_reconciled(
757/// base,
758/// &mut rng,
759/// sample(addr!("y"), Normal::new(0.0, 1.0).unwrap()),
760/// ).unwrap();
761/// assert_eq!(report.fresh_addresses, vec![addr!("y")]);
762/// assert_eq!(report.vanished_addresses, vec![addr!("x")]);
763/// assert!(trace.log_prior.is_finite());
764/// ```
765pub fn score_given_trace_reconciled<A, R: RngCore>(
766 base: Trace,
767 rng: &mut R,
768 model: Model<A>,
769) -> FugueResult<(A, Trace, ReconcileReport)> {
770 let mut error: Option<FugueError> = None;
771 let mut fresh_addresses: Vec<Address> = Vec::new();
772 // Snapshot base addresses up front so we can compute vanished ones after the
773 // run consumes the handler.
774 let base_addresses: Vec<Address> = base.choices.keys().cloned().collect();
775 let handler = ReconcilingScoreGivenTrace {
776 rng,
777 base,
778 trace: Trace::default(),
779 fresh: &mut fresh_addresses,
780 error: &mut error,
781 };
782 let (a, trace) = run(handler, model);
783 if let Some(e) = error {
784 return Err(e);
785 }
786 // Vanished = present in the base trace but not visited by this model run.
787 let vanished_addresses: Vec<Address> = base_addresses
788 .into_iter()
789 .filter(|addr| !trace.choices.contains_key(addr))
790 .collect();
791 Ok((
792 a,
793 trace,
794 ReconcileReport {
795 fresh_addresses,
796 vanished_addresses,
797 },
798 ))
799}
800
801#[cfg(test)]
802mod tests {
803 use super::*;
804 use crate::addr;
805 use crate::core::distribution::*;
806 use crate::core::model::{observe, sample, ModelExt};
807 use rand::rngs::StdRng;
808 use rand::SeedableRng;
809
810 /// EA-as-PPL F3 acceptance: grafting a prefix block between two executions
811 /// of the same model, then re-scoring, yields exactly the weight a
812 /// from-scratch replay of the spliced assignment would produce — the
813 /// observable contract that "surgery + re-score" is exact.
814 #[test]
815 fn test_graft_rescore_equality() {
816 let model_fn = || {
817 sample(addr!("gene", 0), Normal::new(0.0, 1.0).unwrap()).and_then(|g0| {
818 sample(addr!("gene", 1), Normal::new(0.0, 1.0).unwrap()).and_then(move |g1| {
819 observe(addr!("y"), Normal::new(g0 + g1, 0.5).unwrap(), 1.0)
820 .map(move |_| (g0, g1))
821 })
822 })
823 };
824 let mut rng = StdRng::seed_from_u64(21);
825 let (_, ta) = crate::runtime::handler::run(
826 PriorHandler {
827 rng: &mut rng,
828 trace: Trace::default(),
829 },
830 model_fn(),
831 );
832 let (_, tb) = crate::runtime::handler::run(
833 PriorHandler {
834 rng: &mut rng,
835 trace: Trace::default(),
836 },
837 model_fn(),
838 );
839
840 // Splice tb's "gene#0" block into ta, then re-score.
841 let mut spliced = ta.clone();
842 spliced.graft_prefix("gene\u{23}0", &tb); // "gene#0"
843 let (_, rescored) = crate::runtime::handler::run(
844 ScoreGivenTrace {
845 base: spliced,
846 trace: Trace::default(),
847 },
848 model_fn(),
849 );
850
851 // From-scratch expectation: hand-build the same assignment and score it.
852 let mut manual = Trace::default();
853 manual.insert_choice(
854 addr!("gene", 0),
855 tb.choices[&addr!("gene", 0)].value.clone(),
856 0.0,
857 );
858 manual.insert_choice(
859 addr!("gene", 1),
860 ta.choices[&addr!("gene", 1)].value.clone(),
861 0.0,
862 );
863 let (_, expected) = crate::runtime::handler::run(
864 ScoreGivenTrace {
865 base: manual,
866 trace: Trace::default(),
867 },
868 model_fn(),
869 );
870 assert!((rescored.total_log_weight() - expected.total_log_weight()).abs() < 1e-12);
871 }
872
873 #[test]
874 fn prior_handler_samples_and_accumulates() {
875 let mut rng = StdRng::seed_from_u64(7);
876 let (_val, trace) = crate::runtime::handler::run(
877 PriorHandler {
878 rng: &mut rng,
879 trace: Trace::default(),
880 },
881 sample(addr!("x"), Normal::new(0.0, 1.0).unwrap())
882 .and_then(|x| observe(addr!("y"), Normal::new(x, 1.0).unwrap(), 0.5)),
883 );
884 assert!(trace.choices.contains_key(&addr!("x")));
885 assert!(trace.log_prior.is_finite());
886 assert!(trace.log_likelihood.is_finite());
887 }
888
889 #[test]
890 fn replay_handler_reuses_values() {
891 let mut rng = StdRng::seed_from_u64(8);
892 let ((), base) = crate::runtime::handler::run(
893 PriorHandler {
894 rng: &mut rng,
895 trace: Trace::default(),
896 },
897 sample(addr!("x"), Normal::new(0.0, 1.0).unwrap()).map(|_| ()),
898 );
899
900 let ((), replayed) = crate::runtime::handler::run(
901 ReplayHandler {
902 rng: &mut rng,
903 base: base.clone(),
904 trace: Trace::default(),
905 },
906 sample(addr!("x"), Normal::new(0.0, 1.0).unwrap()).map(|_| ()),
907 );
908
909 let x_base = base.get_f64(&addr!("x")).unwrap();
910 let x_replay = replayed.get_f64(&addr!("x")).unwrap();
911 assert_eq!(x_base, x_replay);
912 }
913
914 #[test]
915 fn score_given_trace_scores_fixed_values() {
916 let mut rng = StdRng::seed_from_u64(9);
917 let (_a, base) = crate::runtime::handler::run(
918 PriorHandler {
919 rng: &mut rng,
920 trace: Trace::default(),
921 },
922 sample(addr!("x"), Normal::new(0.0, 1.0).unwrap()),
923 );
924
925 let (_a2, scored) = crate::runtime::handler::run(
926 ScoreGivenTrace {
927 base: base.clone(),
928 trace: Trace::default(),
929 },
930 sample(addr!("x"), Normal::new(0.0, 1.0).unwrap()),
931 );
932
933 // Should have same value and finite log_prior
934 assert_eq!(scored.get_f64(&addr!("x")), base.get_f64(&addr!("x")));
935 assert!(scored.log_prior.is_finite());
936 }
937
938 #[test]
939 fn safe_variants_handle_mismatches() {
940 // Build base trace with x as f64, then attempt to replay as bool
941 let mut rng = StdRng::seed_from_u64(10);
942 let (_a, base) = crate::runtime::handler::run(
943 PriorHandler {
944 rng: &mut rng,
945 trace: Trace::default(),
946 },
947 sample(addr!("x"), Normal::new(0.0, 1.0).unwrap()),
948 );
949
950 // SafeReplayHandler should sample fresh value for bool and continue
951 let (_b, t1) = crate::runtime::handler::run(
952 SafeReplayHandler {
953 rng: &mut rng,
954 base: base.clone(),
955 trace: Trace::default(),
956 warn_on_mismatch: true,
957 },
958 sample(addr!("x"), Bernoulli::new(0.5).unwrap()),
959 );
960 assert!(t1.log_prior.is_finite());
961
962 // SafeScoreGivenTrace should mark as invalid by adding -inf
963 let (_c, t2) = crate::runtime::handler::run(
964 SafeScoreGivenTrace {
965 base: base.clone(),
966 trace: Trace::default(),
967 warn_on_error: true,
968 },
969 sample(addr!("x"), Bernoulli::new(0.5).unwrap()),
970 );
971 assert!(t2.log_prior.is_infinite());
972 }
973
974 #[test]
975 fn handlers_cover_all_types_sample_and_observe() {
976 // Model with multiple types
977 let model = sample(addr!("f"), Normal::new(0.0, 1.0).unwrap())
978 .and_then(|_| sample(addr!("b"), Bernoulli::new(0.6).unwrap()))
979 .and_then(|_| sample(addr!("u64"), Poisson::new(3.0).unwrap()))
980 .and_then(|_| sample(addr!("usz"), Categorical::new(vec![0.3, 0.7]).unwrap()))
981 .and_then(|_| observe(addr!("f_obs"), Normal::new(0.0, 1.0).unwrap(), 0.1))
982 .and_then(|_| observe(addr!("b_obs"), Bernoulli::new(0.4).unwrap(), true))
983 .and_then(|_| observe(addr!("u64_obs"), Poisson::new(2.0).unwrap(), 1))
984 .and_then(|_| {
985 observe(
986 addr!("usz_obs"),
987 Categorical::new(vec![0.5, 0.5]).unwrap(),
988 1,
989 )
990 });
991
992 let (_a, t) = crate::runtime::handler::run(
993 PriorHandler {
994 rng: &mut StdRng::seed_from_u64(100),
995 trace: Trace::default(),
996 },
997 model,
998 );
999 assert!(t.get_f64(&addr!("f")).is_some());
1000 assert!(t.get_bool(&addr!("b")).is_some());
1001 assert!(t.get_u64(&addr!("u64")).is_some());
1002 assert!(t.get_usize(&addr!("usz")).is_some());
1003 assert!(t.log_likelihood.is_finite());
1004
1005 // Build base and score given trace for all types
1006 let base = t.clone();
1007 let (_sv, scored) = crate::runtime::handler::run(
1008 ScoreGivenTrace {
1009 base: base.clone(),
1010 trace: Trace::default(),
1011 },
1012 sample(addr!("f"), Normal::new(0.0, 1.0).unwrap())
1013 .and_then(|_| sample(addr!("b"), Bernoulli::new(0.6).unwrap()))
1014 .and_then(|_| sample(addr!("u64"), Poisson::new(3.0).unwrap()))
1015 .and_then(|_| sample(addr!("usz"), Categorical::new(vec![0.3, 0.7]).unwrap())),
1016 );
1017 assert!(scored.log_prior.is_finite());
1018
1019 // Safe replay mismatches for integer/categorical types
1020 let (_sv2, safe) = crate::runtime::handler::run(
1021 SafeReplayHandler {
1022 rng: &mut StdRng::seed_from_u64(101),
1023 base: base.clone(),
1024 trace: Trace::default(),
1025 warn_on_mismatch: true,
1026 },
1027 sample(addr!("u64"), Bernoulli::new(0.5).unwrap()),
1028 );
1029 assert!(safe.log_prior.is_finite());
1030 }
1031
1032 #[test]
1033 fn safe_score_given_trace_warn_flag_branches() {
1034 let mut rng = StdRng::seed_from_u64(102);
1035 let (_a, base) = crate::runtime::handler::run(
1036 PriorHandler {
1037 rng: &mut rng,
1038 trace: Trace::default(),
1039 },
1040 sample(addr!("x"), Normal::new(0.0, 1.0).unwrap()),
1041 );
1042 // warn_on_error = false
1043 let (_b, t_false) = crate::runtime::handler::run(
1044 SafeScoreGivenTrace {
1045 base: base.clone(),
1046 trace: Trace::default(),
1047 warn_on_error: false,
1048 },
1049 sample(addr!("x"), Bernoulli::new(0.5).unwrap()),
1050 );
1051 assert!(t_false.log_prior.is_infinite());
1052
1053 // warn_on_error = true
1054 let (_c, t_true) = crate::runtime::handler::run(
1055 SafeScoreGivenTrace {
1056 base: base.clone(),
1057 trace: Trace::default(),
1058 warn_on_error: true,
1059 },
1060 sample(addr!("x"), Bernoulli::new(0.5).unwrap()),
1061 );
1062 assert!(t_true.log_prior.is_infinite());
1063 }
1064
1065 #[test]
1066 #[should_panic]
1067 fn replay_handler_panics_on_type_mismatch() {
1068 // Base has f64, replay expects bool -> panic as designed
1069 let mut rng = StdRng::seed_from_u64(103);
1070 let (_a, base) = crate::runtime::handler::run(
1071 PriorHandler {
1072 rng: &mut rng,
1073 trace: Trace::default(),
1074 },
1075 sample(addr!("x"), Normal::new(0.0, 1.0).unwrap()),
1076 );
1077 let (_b, _t) = crate::runtime::handler::run(
1078 ReplayHandler {
1079 rng: &mut rng,
1080 base: base.clone(),
1081 trace: Trace::default(),
1082 },
1083 sample(addr!("x"), Bernoulli::new(0.5).unwrap()),
1084 );
1085 }
1086
1087 #[test]
1088 fn safe_replay_handler_samples_fresh_for_missing_address() {
1089 let mut rng = StdRng::seed_from_u64(104);
1090 // Base trace without address "z"
1091 let base = Trace::default();
1092 let (_a, t) = crate::runtime::handler::run(
1093 SafeReplayHandler {
1094 rng: &mut rng,
1095 base,
1096 trace: Trace::default(),
1097 warn_on_mismatch: true,
1098 },
1099 sample(addr!("z"), Normal::new(0.0, 1.0).unwrap()),
1100 );
1101 assert!(t.get_f64(&addr!("z")).is_some());
1102 }
1103}