Skip to main content

fugue_evo/inference/
effect_handlers.rs

1//! Fugue `Handler`s and operation hooks for evolutionary operators
2//!
3//! This module contains two distinct kinds of type:
4//!
5//! 1. **Genuine [`fugue::Handler`] implementations** ([`TraceScoringHandler`],
6//!    [`RecordingHandler`]).  These implement Fugue's real effect-handler
7//!    contract (`on_sample_*` → `log_prior`, `on_observe_*` → `log_likelihood`,
8//!    `on_factor` → `log_factors`, `finish` → [`Trace`]) and can therefore be
9//!    driven with [`fugue::runtime::handler::run`] to score or replay a model
10//!    with correct log-weight bookkeeping.  [`TraceScoringHandler`] is the
11//!    handler that [`crate::inference::model::EvolutionModel`]
12//!    uses to inject `factor(β·f(x))` into a genome's trace.
13//!
14//! 2. **Operation hooks** ([`LoggingHook`], [`RateLimitingHook`],
15//!    [`ConditionalHook`], …).  These are *not* Fugue handlers — they are
16//!    plain before/after callbacks used to observe, log, or rate-limit the
17//!    trace-based genetic operators in [`super::trace_operators`].  They do not
18//!    participate in probabilistic scoring; the probability mass of an operator
19//!    result is obtained by scoring it with [`TraceScoringHandler`] (or with
20//!    [`crate::inference::model::EvolutionModel::to_weighted_trace`]).
21
22use std::collections::HashMap;
23use std::sync::{Arc, Mutex};
24
25use fugue::runtime::handler::Handler;
26#[cfg(test)]
27use fugue::{addr, sample, Normal};
28use fugue::{Address, ChoiceValue, Distribution, Trace};
29use rand::Rng;
30
31use super::trace_operators::{CrossoverMask, MutationSelector};
32use crate::error::GenomeError;
33use crate::genome::trace_genome::TraceGenome;
34
35/// A genuine [`fugue::Handler`] that scores a *fixed* trace of choices.
36///
37/// This is the evolutionary counterpart of Fugue's own
38/// [`fugue::runtime::interpreters::ScoreGivenTrace`]: it starts from an existing
39/// [`Trace`] (typically `genome.to_trace()`), never samples fresh values, and
40/// accumulates log-weights with the correct bookkeeping:
41///
42/// - `on_sample_*` looks the value up in the base trace and adds its
43///   `dist.log_prob(value)` to `log_prior`,
44/// - `on_observe_*` adds `dist.log_prob(value)` to `log_likelihood`,
45/// - `on_factor` adds `logw` to `log_factors`,
46/// - `finish` returns the (mutated) trace, whose choices are preserved.
47///
48/// Running the model `factor(logw)` through this handler therefore produces a
49/// trace with `total_log_weight() == logw` whose choice map still equals the
50/// original genome — this is exactly how "fitness as likelihood" is injected
51/// into a genome's probability mass.
52pub struct TraceScoringHandler {
53    /// The working trace. Seed it with the genome's choices to score them.
54    pub trace: Trace,
55}
56
57impl TraceScoringHandler {
58    /// Create a scoring handler seeded with `base`'s choices.
59    ///
60    /// The accumulators of `base` are reset to zero so that the resulting
61    /// `total_log_weight()` reflects only the effects executed by the model.
62    pub fn new(base: Trace) -> Self {
63        Self {
64            trace: Trace {
65                choices: base.choices,
66                log_prior: 0.0,
67                log_likelihood: 0.0,
68                log_factors: 0.0,
69            },
70        }
71    }
72
73    fn score_sample<T: Copy + Default>(
74        &mut self,
75        addr: &Address,
76        dist: &dyn Distribution<T>,
77        extract: impl Fn(&ChoiceValue) -> Option<T>,
78    ) -> T {
79        match self.trace.choices.get(addr).and_then(|c| extract(&c.value)) {
80            Some(v) => {
81                self.trace.log_prior += dist.log_prob(&v);
82                v
83            }
84            None => {
85                // Site absent from the base trace: mark the trace invalid rather
86                // than fabricating a value, mirroring SafeScoreGivenTrace.
87                self.trace.log_prior += f64::NEG_INFINITY;
88                T::default()
89            }
90        }
91    }
92}
93
94impl Handler for TraceScoringHandler {
95    fn on_sample_f64(&mut self, addr: &Address, dist: &dyn Distribution<f64>) -> f64 {
96        self.score_sample(addr, dist, ChoiceValue::as_f64)
97    }
98
99    fn on_sample_bool(&mut self, addr: &Address, dist: &dyn Distribution<bool>) -> bool {
100        self.score_sample(addr, dist, ChoiceValue::as_bool)
101    }
102
103    fn on_sample_u64(&mut self, addr: &Address, dist: &dyn Distribution<u64>) -> u64 {
104        self.score_sample(addr, dist, ChoiceValue::as_u64)
105    }
106
107    fn on_sample_usize(&mut self, addr: &Address, dist: &dyn Distribution<usize>) -> usize {
108        self.score_sample(addr, dist, ChoiceValue::as_usize)
109    }
110
111    fn on_observe_f64(&mut self, _: &Address, dist: &dyn Distribution<f64>, value: f64) {
112        self.trace.log_likelihood += dist.log_prob(&value);
113    }
114
115    fn on_observe_bool(&mut self, _: &Address, dist: &dyn Distribution<bool>, value: bool) {
116        self.trace.log_likelihood += dist.log_prob(&value);
117    }
118
119    fn on_observe_u64(&mut self, _: &Address, dist: &dyn Distribution<u64>, value: u64) {
120        self.trace.log_likelihood += dist.log_prob(&value);
121    }
122
123    fn on_observe_usize(&mut self, _: &Address, dist: &dyn Distribution<usize>, value: usize) {
124        self.trace.log_likelihood += dist.log_prob(&value);
125    }
126
127    fn on_factor(&mut self, logw: f64) {
128        self.trace.log_factors += logw;
129    }
130
131    fn finish(self) -> Trace {
132        self.trace
133    }
134}
135
136/// A genuine [`fugue::Handler`] that records the sequence of sampled sites while
137/// **delegating all effect handling** (and therefore all log-weight bookkeeping)
138/// to an inner handler.
139///
140/// This is a real Poutine-style *trace* effect: it observes the execution
141/// without changing its semantics.  Because every effect is forwarded to
142/// `inner`, the resulting trace has exactly the same `log_prior`,
143/// `log_likelihood`, and `log_factors` the inner handler would have produced on
144/// its own — only now the ordered list of `(address, value)` sample events is
145/// also available via `RecordingHandler::events`.
146pub struct RecordingHandler<H: Handler> {
147    inner: H,
148    events: Arc<Mutex<Vec<(Address, ChoiceValue)>>>,
149}
150
151impl<H: Handler> RecordingHandler<H> {
152    /// Wrap `inner`, recording every sampled site into `sink`.
153    pub fn new(inner: H, sink: Arc<Mutex<Vec<(Address, ChoiceValue)>>>) -> Self {
154        Self {
155            inner,
156            events: sink,
157        }
158    }
159
160    fn record(&self, addr: &Address, value: ChoiceValue) {
161        self.events.lock().unwrap().push((addr.clone(), value));
162    }
163}
164
165impl<H: Handler> Handler for RecordingHandler<H> {
166    fn on_sample_f64(&mut self, addr: &Address, dist: &dyn Distribution<f64>) -> f64 {
167        let v = self.inner.on_sample_f64(addr, dist);
168        self.record(addr, ChoiceValue::F64(v));
169        v
170    }
171
172    fn on_sample_bool(&mut self, addr: &Address, dist: &dyn Distribution<bool>) -> bool {
173        let v = self.inner.on_sample_bool(addr, dist);
174        self.record(addr, ChoiceValue::Bool(v));
175        v
176    }
177
178    fn on_sample_u64(&mut self, addr: &Address, dist: &dyn Distribution<u64>) -> u64 {
179        let v = self.inner.on_sample_u64(addr, dist);
180        self.record(addr, ChoiceValue::U64(v));
181        v
182    }
183
184    fn on_sample_usize(&mut self, addr: &Address, dist: &dyn Distribution<usize>) -> usize {
185        let v = self.inner.on_sample_usize(addr, dist);
186        self.record(addr, ChoiceValue::Usize(v));
187        v
188    }
189
190    fn on_observe_f64(&mut self, addr: &Address, dist: &dyn Distribution<f64>, value: f64) {
191        self.inner.on_observe_f64(addr, dist, value);
192    }
193
194    fn on_observe_bool(&mut self, addr: &Address, dist: &dyn Distribution<bool>, value: bool) {
195        self.inner.on_observe_bool(addr, dist, value);
196    }
197
198    fn on_observe_u64(&mut self, addr: &Address, dist: &dyn Distribution<u64>, value: u64) {
199        self.inner.on_observe_u64(addr, dist, value);
200    }
201
202    fn on_observe_usize(&mut self, addr: &Address, dist: &dyn Distribution<usize>, value: usize) {
203        self.inner.on_observe_usize(addr, dist, value);
204    }
205
206    fn on_factor(&mut self, logw: f64) {
207        self.inner.on_factor(logw);
208    }
209
210    fn finish(self) -> Trace {
211        self.inner.finish()
212    }
213}
214
215/// Record of a mutation operation
216#[derive(Clone, Debug)]
217pub struct MutationRecord {
218    /// Generation when mutation occurred
219    pub generation: usize,
220    /// Addresses that were mutated
221    pub mutated_addresses: Vec<Address>,
222    /// Original values before mutation
223    pub original_values: HashMap<Address, ChoiceValue>,
224    /// New values after mutation
225    pub new_values: HashMap<Address, ChoiceValue>,
226}
227
228/// Record of a crossover operation
229#[derive(Clone, Debug)]
230pub struct CrossoverRecord {
231    /// Generation when crossover occurred
232    pub generation: usize,
233    /// Addresses from parent1
234    pub from_parent1: Vec<Address>,
235    /// Addresses from parent2
236    pub from_parent2: Vec<Address>,
237}
238
239/// Record of a selection operation
240#[derive(Clone, Debug)]
241pub struct SelectionRecord {
242    /// Generation when selection occurred
243    pub generation: usize,
244    /// Indices of selected individuals
245    pub selected_indices: Vec<usize>,
246    /// Fitness values of selected individuals
247    pub selected_fitness: Vec<f64>,
248}
249
250/// Operation hook for the trace-based mutation operator.
251///
252/// NOTE: this is a plain before/after callback, **not** a [`fugue::Handler`].
253/// It cannot intercept a Fugue model; it only observes / gates the
254/// [`super::trace_operators`] mutation operator.
255pub trait MutationHook: Send + Sync {
256    /// Called before mutation is applied.
257    /// Returns true if mutation should proceed, false to skip.
258    fn before_mutation(&self, trace: &Trace, generation: usize) -> bool;
259
260    /// Called after mutation is applied
261    fn after_mutation(&self, original: &Trace, mutated: &Trace, record: &MutationRecord);
262
263    /// Optionally modify the mutation sites before mutation occurs
264    fn modify_sites(
265        &self,
266        sites: std::collections::HashSet<Address>,
267        _trace: &Trace,
268    ) -> std::collections::HashSet<Address> {
269        sites // Default: no modification
270    }
271}
272
273/// Operation hook for the trace-based crossover operator.
274///
275/// NOTE: a plain before/after callback, **not** a [`fugue::Handler`].
276pub trait CrossoverHook: Send + Sync {
277    /// Called before crossover is applied.
278    /// Returns true if crossover should proceed, false to skip.
279    fn before_crossover(&self, parent1: &Trace, parent2: &Trace, generation: usize) -> bool;
280
281    /// Called after crossover is applied
282    fn after_crossover(
283        &self,
284        parent1: &Trace,
285        parent2: &Trace,
286        child1: &Trace,
287        child2: &Trace,
288        record: &CrossoverRecord,
289    );
290}
291
292/// Operation hook for a selection operator.
293///
294/// NOTE: a plain before/after callback, **not** a [`fugue::Handler`].
295pub trait SelectionHook: Send + Sync {
296    /// Called before selection
297    fn before_selection(&self, population_size: usize, generation: usize);
298
299    /// Called after selection
300    fn after_selection(&self, record: &SelectionRecord);
301
302    /// Optionally modify selection probabilities
303    fn modify_probabilities(&self, probabilities: Vec<f64>) -> Vec<f64> {
304        probabilities // Default: no modification
305    }
306}
307
308/// A hook that logs all evolutionary operations
309#[derive(Clone, Debug, Default)]
310pub struct LoggingHook {
311    /// Mutation records
312    pub mutations: Arc<Mutex<Vec<MutationRecord>>>,
313    /// Crossover records
314    pub crossovers: Arc<Mutex<Vec<CrossoverRecord>>>,
315    /// Selection records
316    pub selections: Arc<Mutex<Vec<SelectionRecord>>>,
317}
318
319impl LoggingHook {
320    /// Create a new logging hook
321    pub fn new() -> Self {
322        Self::default()
323    }
324
325    /// Get all mutation records
326    pub fn get_mutations(&self) -> Vec<MutationRecord> {
327        self.mutations.lock().unwrap().clone()
328    }
329
330    /// Get all crossover records
331    pub fn get_crossovers(&self) -> Vec<CrossoverRecord> {
332        self.crossovers.lock().unwrap().clone()
333    }
334
335    /// Get all selection records
336    pub fn get_selections(&self) -> Vec<SelectionRecord> {
337        self.selections.lock().unwrap().clone()
338    }
339
340    /// Clear all records
341    pub fn clear(&self) {
342        self.mutations.lock().unwrap().clear();
343        self.crossovers.lock().unwrap().clear();
344        self.selections.lock().unwrap().clear();
345    }
346}
347
348impl MutationHook for LoggingHook {
349    fn before_mutation(&self, _trace: &Trace, _generation: usize) -> bool {
350        true // Always allow
351    }
352
353    fn after_mutation(&self, _original: &Trace, _mutated: &Trace, record: &MutationRecord) {
354        self.mutations.lock().unwrap().push(record.clone());
355    }
356}
357
358impl CrossoverHook for LoggingHook {
359    fn before_crossover(&self, _parent1: &Trace, _parent2: &Trace, _generation: usize) -> bool {
360        true // Always allow
361    }
362
363    fn after_crossover(
364        &self,
365        _parent1: &Trace,
366        _parent2: &Trace,
367        _child1: &Trace,
368        _child2: &Trace,
369        record: &CrossoverRecord,
370    ) {
371        self.crossovers.lock().unwrap().push(record.clone());
372    }
373}
374
375impl SelectionHook for LoggingHook {
376    fn before_selection(&self, _population_size: usize, _generation: usize) {}
377
378    fn after_selection(&self, record: &SelectionRecord) {
379        self.selections.lock().unwrap().push(record.clone());
380    }
381}
382
383/// A hook that rate-limits operations
384#[derive(Clone, Debug)]
385pub struct RateLimitingHook {
386    /// Maximum mutations per generation
387    pub max_mutations: usize,
388    /// Maximum crossovers per generation
389    pub max_crossovers: usize,
390    /// Current mutation count for this generation
391    mutation_count: Arc<Mutex<(usize, usize)>>, // (generation, count)
392    /// Current crossover count for this generation
393    crossover_count: Arc<Mutex<(usize, usize)>>,
394}
395
396impl RateLimitingHook {
397    /// Create a new rate limiting hook
398    pub fn new(max_mutations: usize, max_crossovers: usize) -> Self {
399        Self {
400            max_mutations,
401            max_crossovers,
402            mutation_count: Arc::new(Mutex::new((0, 0))),
403            crossover_count: Arc::new(Mutex::new((0, 0))),
404        }
405    }
406
407    /// Reset counters for a new generation
408    pub fn reset(&self, generation: usize) {
409        *self.mutation_count.lock().unwrap() = (generation, 0);
410        *self.crossover_count.lock().unwrap() = (generation, 0);
411    }
412}
413
414impl MutationHook for RateLimitingHook {
415    fn before_mutation(&self, _trace: &Trace, generation: usize) -> bool {
416        let mut count = self.mutation_count.lock().unwrap();
417        if count.0 != generation {
418            *count = (generation, 0);
419        }
420        if count.1 < self.max_mutations {
421            count.1 += 1;
422            true
423        } else {
424            false
425        }
426    }
427
428    fn after_mutation(&self, _original: &Trace, _mutated: &Trace, _record: &MutationRecord) {}
429}
430
431impl CrossoverHook for RateLimitingHook {
432    fn before_crossover(&self, _parent1: &Trace, _parent2: &Trace, generation: usize) -> bool {
433        let mut count = self.crossover_count.lock().unwrap();
434        if count.0 != generation {
435            *count = (generation, 0);
436        }
437        if count.1 < self.max_crossovers {
438            count.1 += 1;
439            true
440        } else {
441            false
442        }
443    }
444
445    fn after_crossover(
446        &self,
447        _parent1: &Trace,
448        _parent2: &Trace,
449        _child1: &Trace,
450        _child2: &Trace,
451        _record: &CrossoverRecord,
452    ) {
453    }
454}
455
456/// A hook that conditionally blocks operations based on a predicate
457pub struct ConditionalHook<F>
458where
459    F: Fn(usize) -> bool + Send + Sync,
460{
461    /// Predicate that determines if operation should proceed
462    pub predicate: F,
463}
464
465impl<F> ConditionalHook<F>
466where
467    F: Fn(usize) -> bool + Send + Sync,
468{
469    /// Create a new conditional hook
470    pub fn new(predicate: F) -> Self {
471        Self { predicate }
472    }
473}
474
475impl<F> MutationHook for ConditionalHook<F>
476where
477    F: Fn(usize) -> bool + Send + Sync,
478{
479    fn before_mutation(&self, _trace: &Trace, generation: usize) -> bool {
480        (self.predicate)(generation)
481    }
482
483    fn after_mutation(&self, _original: &Trace, _mutated: &Trace, _record: &MutationRecord) {}
484}
485
486impl<F> CrossoverHook for ConditionalHook<F>
487where
488    F: Fn(usize) -> bool + Send + Sync,
489{
490    fn before_crossover(&self, _parent1: &Trace, _parent2: &Trace, generation: usize) -> bool {
491        (self.predicate)(generation)
492    }
493
494    fn after_crossover(
495        &self,
496        _parent1: &Trace,
497        _parent2: &Trace,
498        _child1: &Trace,
499        _child2: &Trace,
500        _record: &CrossoverRecord,
501    ) {
502    }
503}
504
505/// Composition of multiple mutation hooks
506pub struct ComposedMutationHook {
507    hooks: Vec<Box<dyn MutationHook>>,
508}
509
510impl ComposedMutationHook {
511    /// Create a new composed hook
512    pub fn new() -> Self {
513        Self { hooks: Vec::new() }
514    }
515
516    /// Add a hook to the composition
517    pub fn add<H: MutationHook + 'static>(mut self, hook: H) -> Self {
518        self.hooks.push(Box::new(hook));
519        self
520    }
521}
522
523impl Default for ComposedMutationHook {
524    fn default() -> Self {
525        Self::new()
526    }
527}
528
529impl MutationHook for ComposedMutationHook {
530    fn before_mutation(&self, trace: &Trace, generation: usize) -> bool {
531        // All hooks must agree
532        self.hooks
533            .iter()
534            .all(|h| h.before_mutation(trace, generation))
535    }
536
537    fn after_mutation(&self, original: &Trace, mutated: &Trace, record: &MutationRecord) {
538        for hook in &self.hooks {
539            hook.after_mutation(original, mutated, record);
540        }
541    }
542
543    fn modify_sites(
544        &self,
545        mut sites: std::collections::HashSet<Address>,
546        trace: &Trace,
547    ) -> std::collections::HashSet<Address> {
548        for hook in &self.hooks {
549            sites = hook.modify_sites(sites, trace);
550        }
551        sites
552    }
553}
554
555/// Composition of multiple crossover hooks
556pub struct ComposedCrossoverHook {
557    hooks: Vec<Box<dyn CrossoverHook>>,
558}
559
560impl ComposedCrossoverHook {
561    /// Create a new composed hook
562    pub fn new() -> Self {
563        Self { hooks: Vec::new() }
564    }
565
566    /// Add a hook to the composition
567    pub fn add<H: CrossoverHook + 'static>(mut self, hook: H) -> Self {
568        self.hooks.push(Box::new(hook));
569        self
570    }
571}
572
573impl Default for ComposedCrossoverHook {
574    fn default() -> Self {
575        Self::new()
576    }
577}
578
579impl CrossoverHook for ComposedCrossoverHook {
580    fn before_crossover(&self, parent1: &Trace, parent2: &Trace, generation: usize) -> bool {
581        self.hooks
582            .iter()
583            .all(|h| h.before_crossover(parent1, parent2, generation))
584    }
585
586    fn after_crossover(
587        &self,
588        parent1: &Trace,
589        parent2: &Trace,
590        child1: &Trace,
591        child2: &Trace,
592        record: &CrossoverRecord,
593    ) {
594        for hook in &self.hooks {
595            hook.after_crossover(parent1, parent2, child1, child2, record);
596        }
597    }
598}
599
600/// Hooked mutation operator that integrates with operation hooks.
601///
602/// The produced child trace only rearranges/mutates *values*. It carries no
603/// fabricated per-choice log-probabilities: resampled sites are written with a
604/// neutral `logp` of `0.0` (rather than the stale log-prob of the pre-mutation
605/// value), and the trace's `log_prior`/`log_likelihood`/`log_factors`
606/// accumulators are left at zero. To obtain the child's probability mass under
607/// the Boltzmann posterior, score it with [`TraceScoringHandler`] or
608/// [`crate::inference::model::EvolutionModel::to_weighted_trace`].
609pub fn hooked_mutate_trace<G, S, H, R>(
610    genome: &G,
611    selector: &S,
612    mutation_fn: impl Fn(&Address, &ChoiceValue, &mut R) -> ChoiceValue,
613    hook: &H,
614    generation: usize,
615    rng: &mut R,
616) -> Result<G, GenomeError>
617where
618    G: TraceGenome,
619    S: MutationSelector,
620    H: MutationHook,
621    R: Rng,
622{
623    let trace = genome.to_trace();
624
625    // Check if mutation should proceed
626    if !hook.before_mutation(&trace, generation) {
627        return G::from_trace(&trace);
628    }
629
630    // Select and potentially modify mutation sites
631    let mut mutation_sites = selector.select_sites(&trace, rng);
632    mutation_sites = hook.modify_sites(mutation_sites, &trace);
633
634    let mut new_trace = Trace::default();
635    let mut original_values = HashMap::new();
636    let mut new_values = HashMap::new();
637
638    for (addr, choice) in &trace.choices {
639        if mutation_sites.contains(addr) {
640            original_values.insert(addr.clone(), choice.value.clone());
641            let mutated = mutation_fn(addr, &choice.value, rng);
642            new_values.insert(addr.clone(), mutated.clone());
643            // Resampled site: do NOT copy the stale logp of the old value.
644            new_trace.insert_choice(addr.clone(), mutated, 0.0);
645        } else {
646            // Unchanged site: preserve its recorded logp.
647            new_trace.insert_choice(addr.clone(), choice.value.clone(), choice.logp);
648        }
649    }
650
651    // Create mutation record
652    let record = MutationRecord {
653        generation,
654        mutated_addresses: mutation_sites.into_iter().collect(),
655        original_values,
656        new_values,
657    };
658
659    hook.after_mutation(&trace, &new_trace, &record);
660
661    G::from_trace(&new_trace)
662}
663
664/// Hooked crossover operator that integrates with operation hooks.
665///
666/// Like [`hooked_mutate_trace`], the produced child traces only recombine
667/// *values* and carry a neutral `logp` of `0.0`; score them with
668/// [`TraceScoringHandler`] to obtain probability mass.
669pub fn hooked_crossover_traces<G, M, H, R>(
670    parent1: &G,
671    parent2: &G,
672    mask: &M,
673    hook: &H,
674    generation: usize,
675    _rng: &mut R,
676) -> Result<(G, G), GenomeError>
677where
678    G: TraceGenome,
679    M: CrossoverMask,
680    H: CrossoverHook,
681    R: Rng,
682{
683    let trace1 = parent1.to_trace();
684    let trace2 = parent2.to_trace();
685
686    // Check if crossover should proceed
687    if !hook.before_crossover(&trace1, &trace2, generation) {
688        return Ok((G::from_trace(&trace1)?, G::from_trace(&trace2)?));
689    }
690
691    let mut child1_trace = Trace::default();
692    let mut child2_trace = Trace::default();
693    let mut from_parent1 = Vec::new();
694    let mut from_parent2 = Vec::new();
695
696    // Collect all addresses from both parents
697    let all_addresses: std::collections::HashSet<Address> = trace1
698        .choices
699        .keys()
700        .chain(trace2.choices.keys())
701        .cloned()
702        .collect();
703
704    for addr in all_addresses {
705        let (val_for_child1, val_for_child2) = if mask.from_parent1(&addr) {
706            from_parent1.push(addr.clone());
707            (
708                trace1
709                    .choices
710                    .get(&addr)
711                    .map(|c| c.value.clone())
712                    .unwrap_or(ChoiceValue::F64(0.0)),
713                trace2
714                    .choices
715                    .get(&addr)
716                    .map(|c| c.value.clone())
717                    .unwrap_or(ChoiceValue::F64(0.0)),
718            )
719        } else {
720            from_parent2.push(addr.clone());
721            (
722                trace2
723                    .choices
724                    .get(&addr)
725                    .map(|c| c.value.clone())
726                    .unwrap_or(ChoiceValue::F64(0.0)),
727                trace1
728                    .choices
729                    .get(&addr)
730                    .map(|c| c.value.clone())
731                    .unwrap_or(ChoiceValue::F64(0.0)),
732            )
733        };
734
735        child1_trace.insert_choice(addr.clone(), val_for_child1, 0.0);
736        child2_trace.insert_choice(addr, val_for_child2, 0.0);
737    }
738
739    // Create crossover record
740    let record = CrossoverRecord {
741        generation,
742        from_parent1,
743        from_parent2,
744    };
745
746    hook.after_crossover(&trace1, &trace2, &child1_trace, &child2_trace, &record);
747
748    let child1 = G::from_trace(&child1_trace)?;
749    let child2 = G::from_trace(&child2_trace)?;
750
751    Ok((child1, child2))
752}
753
754/// Statistics computed from hook records
755#[derive(Clone, Debug, Default)]
756pub struct OperationStatistics {
757    /// Total number of mutations
758    pub total_mutations: usize,
759    /// Total number of crossovers
760    pub total_crossovers: usize,
761    /// Average mutation sites per operation
762    pub avg_mutation_sites: f64,
763    /// Distribution of addresses from parent1 in crossovers
764    pub avg_parent1_contribution: f64,
765}
766
767impl OperationStatistics {
768    /// Compute statistics from a logging hook
769    pub fn from_hook(hook: &LoggingHook) -> Self {
770        let mutations = hook.get_mutations();
771        let crossovers = hook.get_crossovers();
772
773        let total_mutations = mutations.len();
774        let total_crossovers = crossovers.len();
775
776        let avg_mutation_sites = if total_mutations > 0 {
777            mutations
778                .iter()
779                .map(|r| r.mutated_addresses.len())
780                .sum::<usize>() as f64
781                / total_mutations as f64
782        } else {
783            0.0
784        };
785
786        let avg_parent1_contribution = if total_crossovers > 0 {
787            let total: f64 = crossovers
788                .iter()
789                .map(|r| {
790                    let total = r.from_parent1.len() + r.from_parent2.len();
791                    if total > 0 {
792                        r.from_parent1.len() as f64 / total as f64
793                    } else {
794                        0.5
795                    }
796                })
797                .sum();
798            total / total_crossovers as f64
799        } else {
800            0.0
801        };
802
803        Self {
804            total_mutations,
805            total_crossovers,
806            avg_mutation_sites,
807            avg_parent1_contribution,
808        }
809    }
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815    use crate::genome::real_vector::RealVector;
816    use crate::genome::trace_genome::TraceGenome;
817    use crate::inference::trace_operators::{
818        gaussian_mutation, UniformCrossoverMask, UniformMutationSelector,
819    };
820    use fugue::runtime::handler::run;
821    use fugue::{factor, ModelExt};
822    use rand::SeedableRng;
823
824    #[test]
825    fn test_trace_scoring_handler_injects_factor() {
826        // regression: EV-52 — factor() must land in log_factors, so total_log_weight == logw.
827        let genome = RealVector::new(vec![1.0, 2.0, 3.0]);
828        let base = genome.to_trace();
829        let (_r, trace) = run(TraceScoringHandler::new(base), factor(-4.5));
830        assert!((trace.log_factors - (-4.5)).abs() < 1e-12);
831        assert!((trace.total_log_weight() - (-4.5)).abs() < 1e-12);
832        // Choices are preserved.
833        assert_eq!(trace.get_f64(&addr!("gene", 0)), Some(1.0));
834        assert_eq!(trace.get_f64(&addr!("gene", 2)), Some(3.0));
835    }
836
837    #[test]
838    fn test_trace_scoring_handler_scores_prior() {
839        // Scoring a fixed sample site accumulates its log_prob into log_prior.
840        let mut base = Trace::default();
841        base.insert_choice(addr!("x"), ChoiceValue::F64(0.0), 0.0);
842        let (_v, trace) = run(
843            TraceScoringHandler::new(base),
844            sample(addr!("x"), Normal::new(0.0, 1.0).unwrap()),
845        );
846        // log N(0;0,1) = -0.5*ln(2π)
847        let expected = -0.5 * (2.0 * std::f64::consts::PI).ln();
848        assert!((trace.log_prior - expected).abs() < 1e-9);
849    }
850
851    #[test]
852    fn test_recording_handler_preserves_bookkeeping() {
853        // regression: EV-51 — a genuine fugue::Handler that records events must
854        // reproduce the SAME log-weights as the delegated handler.
855        let model = || {
856            sample(addr!("a"), Normal::new(0.0, 1.0).unwrap())
857                .and_then(|_| sample(addr!("b"), Normal::new(1.0, 2.0).unwrap()))
858                .and_then(|_| factor(-1.25))
859        };
860
861        let mut rng1 = rand::rngs::StdRng::from_seed([7u8; 32]);
862        let plain = fugue::runtime::interpreters::PriorHandler {
863            rng: &mut rng1,
864            trace: Trace::default(),
865        };
866        let (_a, plain_trace) = run(plain, model());
867
868        let mut rng2 = rand::rngs::StdRng::from_seed([7u8; 32]);
869        let sink = Arc::new(Mutex::new(Vec::new()));
870        let recording = RecordingHandler::new(
871            fugue::runtime::interpreters::PriorHandler {
872                rng: &mut rng2,
873                trace: Trace::default(),
874            },
875            sink.clone(),
876        );
877        let (_b, rec_trace) = run(recording, model());
878
879        // Same seed + delegation ⇒ identical bookkeeping.
880        assert!((plain_trace.log_prior - rec_trace.log_prior).abs() < 1e-12);
881        assert!((plain_trace.log_factors - rec_trace.log_factors).abs() < 1e-12);
882        // Both sample sites were recorded, in order.
883        let events = sink.lock().unwrap();
884        assert_eq!(events.len(), 2);
885        assert_eq!(events[0].0, addr!("a"));
886        assert_eq!(events[1].0, addr!("b"));
887    }
888
889    #[test]
890    fn test_hooked_mutate_does_not_copy_stale_logp() {
891        // regression: EV-51 — resampled sites must not carry the old value's logp.
892        let mut rng = rand::rngs::StdRng::from_seed([3u8; 32]);
893        // Build a genome trace whose choices carry a non-zero logp.
894        let genome = RealVector::new(vec![1.0, 2.0, 3.0]);
895        let mut seeded = genome.to_trace();
896        for c in seeded.choices.values_mut() {
897            c.logp = -12.34; // stale, meaningless log-prob
898        }
899        // A custom genome whose to_trace returns the seeded (non-zero-logp) trace
900        // is awkward to construct, so exercise the operator directly on the
901        // standard trace and assert the produced child has neutral logp.
902        let hook = LoggingHook::new();
903        let selector = UniformMutationSelector::new(1.0);
904        let mutation_fn = gaussian_mutation(0.5);
905        let mutated =
906            hooked_mutate_trace(&genome, &selector, mutation_fn, &hook, 0, &mut rng).unwrap();
907        let child_trace = mutated.to_trace();
908        for choice in child_trace.choices.values() {
909            assert_eq!(choice.logp, 0.0, "child trace logp must be neutral (0.0)");
910        }
911    }
912
913    #[test]
914    fn test_logging_hook_mutation() {
915        let mut rng = rand::thread_rng();
916        let genome = RealVector::new(vec![1.0, 2.0, 3.0]);
917
918        let hook = LoggingHook::new();
919        let selector = UniformMutationSelector::new(1.0); // Mutate all
920        let mutation_fn = gaussian_mutation(0.1);
921
922        let _mutated =
923            hooked_mutate_trace(&genome, &selector, mutation_fn, &hook, 0, &mut rng).unwrap();
924
925        let records = hook.get_mutations();
926        assert_eq!(records.len(), 1);
927        assert_eq!(records[0].generation, 0);
928        assert!(!records[0].mutated_addresses.is_empty());
929    }
930
931    #[test]
932    fn test_logging_hook_crossover() {
933        let mut rng = rand::thread_rng();
934        let parent1 = RealVector::new(vec![1.0, 2.0, 3.0]);
935        let parent2 = RealVector::new(vec![4.0, 5.0, 6.0]);
936
937        let hook = LoggingHook::new();
938        let trace1 = parent1.to_trace();
939        let mask = UniformCrossoverMask::balanced(&trace1, &mut rng);
940
941        let (_child1, _child2) =
942            hooked_crossover_traces(&parent1, &parent2, &mask, &hook, 0, &mut rng).unwrap();
943
944        let records = hook.get_crossovers();
945        assert_eq!(records.len(), 1);
946        assert_eq!(records[0].generation, 0);
947    }
948
949    #[test]
950    fn test_rate_limiting_hook() {
951        let mut rng = rand::thread_rng();
952        let genome = RealVector::new(vec![1.0, 2.0, 3.0]);
953
954        let hook = RateLimitingHook::new(2, 2);
955        let selector = UniformMutationSelector::new(1.0);
956        let mutation_fn = gaussian_mutation(0.1);
957
958        // First two mutations should succeed
959        for _ in 0..2 {
960            let result = hooked_mutate_trace(&genome, &selector, &mutation_fn, &hook, 0, &mut rng);
961            assert!(result.is_ok());
962        }
963
964        // Third mutation should be skipped (returns original)
965        let result = hooked_mutate_trace(&genome, &selector, &mutation_fn, &hook, 0, &mut rng);
966        assert!(result.is_ok());
967    }
968
969    #[test]
970    fn test_composed_hook() {
971        let mut rng = rand::thread_rng();
972        let genome = RealVector::new(vec![1.0, 2.0, 3.0]);
973
974        let logging = LoggingHook::new();
975        let rate_limit = RateLimitingHook::new(5, 5);
976
977        let composed = ComposedMutationHook::new()
978            .add(logging.clone())
979            .add(rate_limit);
980
981        let selector = UniformMutationSelector::new(1.0);
982        let mutation_fn = gaussian_mutation(0.1);
983
984        let _ = hooked_mutate_trace(&genome, &selector, mutation_fn, &composed, 0, &mut rng);
985
986        // Logging hook should have recorded the mutation
987        assert_eq!(logging.get_mutations().len(), 1);
988    }
989
990    #[test]
991    fn test_operation_statistics() {
992        let hook = LoggingHook::new();
993
994        // Manually add some records
995        hook.mutations.lock().unwrap().push(MutationRecord {
996            generation: 0,
997            mutated_addresses: vec![addr!("test", 0), addr!("test", 1)],
998            original_values: HashMap::new(),
999            new_values: HashMap::new(),
1000        });
1001
1002        let stats = OperationStatistics::from_hook(&hook);
1003        assert_eq!(stats.total_mutations, 1);
1004        assert_eq!(stats.avg_mutation_sites, 2.0);
1005    }
1006}