Skip to main content

laddu_compile/
normalization.rs

1use laddu_expr::{
2    BinaryOp, ExprGraph, ExprId, ExprMetadata, ExprNode, ExprSourceKind, UnaryOp, ValueKind,
3};
4use num::complex::Complex64;
5
6use crate::{CompileError, CompileResult, CompiledModel, GraphFacts};
7
8const DEFAULT_EXPANSION_BUDGET: usize = 4_096;
9
10/// Compiler-selected family of accepted-normalization implementation.
11#[derive(Copy, Clone, Debug, PartialEq, Eq)]
12pub enum NormalizationStrategy {
13    /// Coherent groups represented by packed Hermitian statistics.
14    Hermitian,
15    /// A general exact sum of parameter coefficients times event bases.
16    LinearStatistics,
17    /// Exact sufficient statistics plus a nonseparable additive residual.
18    Hybrid,
19    /// Ordinary event reduction.
20    General,
21}
22
23/// Stable reason that compiler-native normalization was not selected.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub enum NormalizationFallbackReason {
26    /// The model root is not a real scalar.
27    NonScalarIntensity,
28    /// An operation mixed parameter and event dependence without an exact rule.
29    UnsupportedMixedOperation {
30        /// Offending optimized graph node.
31        node: ExprId,
32        /// Operation category.
33        operation: &'static str,
34    },
35    /// Exact symbolic distribution exceeded the compiler expansion budget.
36    ExpansionBudgetExceeded {
37        /// Maximum permitted number of sufficient-statistic terms.
38        budget: usize,
39    },
40}
41
42/// Stable summary of compiler-native normalization analysis.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct NormalizationDiagnostics {
45    strategy: NormalizationStrategy,
46    basis_count: usize,
47    coherent_group_count: usize,
48    has_residual: bool,
49    fallback_reason: Option<NormalizationFallbackReason>,
50}
51
52impl NormalizationDiagnostics {
53    /// Returns the compiler-selected candidate family.
54    pub fn strategy(&self) -> NormalizationStrategy {
55        self.strategy
56    }
57
58    /// Returns the number of event-basis statistics in the candidate.
59    pub fn basis_count(&self) -> usize {
60        self.basis_count
61    }
62
63    /// Returns the number of recognized coherent squared-norm groups.
64    pub fn coherent_group_count(&self) -> usize {
65        self.coherent_group_count
66    }
67
68    /// Returns whether evaluation retains a general additive residual.
69    pub fn has_residual(&self) -> bool {
70        self.has_residual
71    }
72
73    /// Returns the structured general-path reason, when present.
74    pub fn fallback_reason(&self) -> Option<&NormalizationFallbackReason> {
75        self.fallback_reason.as_ref()
76    }
77}
78
79#[derive(Copy, Clone, Debug)]
80struct SeparableTerm {
81    coefficient: ExprId,
82    basis: ExprId,
83}
84
85/// Exact compiler artifact consumed by execution backends.
86#[doc(hidden)]
87#[derive(Clone, Debug)]
88pub struct NormalizationPlan {
89    graph: ExprGraph,
90    terms: Vec<SeparableTerm>,
91    residual: Option<ExprId>,
92    diagnostics: NormalizationDiagnostics,
93    proven_nonnegative: bool,
94}
95
96impl NormalizationPlan {
97    pub(crate) fn analyze_disabled(graph: &ExprGraph) -> Self {
98        Self::general(
99            graph,
100            NormalizationFallbackReason::UnsupportedMixedOperation {
101                node: graph.root(),
102                operation: "normalization analysis disabled",
103            },
104        )
105    }
106
107    pub(crate) fn analyze(graph: &ExprGraph, facts: &GraphFacts) -> Self {
108        if !matches!(
109            facts.get(graph.root()).map(|facts| facts.value_kind),
110            Some(ValueKind::Real | ValueKind::Complex)
111        ) {
112            return Self::general(graph, NormalizationFallbackReason::NonScalarIntensity);
113        }
114
115        let mut analyzer = Analyzer::new(graph, facts, DEFAULT_EXPANSION_BUDGET);
116        let mut terms = Vec::new();
117        let mut residuals = Vec::new();
118        for root in analyzer.additive_roots(graph.root()) {
119            match analyzer.decompose(root) {
120                Ok(mut extracted) => terms.append(&mut extracted),
121                Err(reason) => {
122                    analyzer.last_reason = Some(reason);
123                    residuals.push(root);
124                }
125            }
126        }
127
128        if terms.is_empty() {
129            return Self::general(
130                graph,
131                analyzer.last_reason.unwrap_or(
132                    NormalizationFallbackReason::UnsupportedMixedOperation {
133                        node: graph.root(),
134                        operation: "root",
135                    },
136                ),
137            );
138        }
139
140        let residual = analyzer.sum_roots(&residuals);
141        let strategy = if residual.is_some() {
142            NormalizationStrategy::Hybrid
143        } else if analyzer.coherent_groups > 0 {
144            NormalizationStrategy::Hermitian
145        } else {
146            NormalizationStrategy::LinearStatistics
147        };
148        let diagnostics = NormalizationDiagnostics {
149            strategy,
150            basis_count: terms.len(),
151            coherent_group_count: analyzer.coherent_groups,
152            has_residual: residual.is_some(),
153            fallback_reason: analyzer.last_reason.clone(),
154        };
155        Self {
156            graph: analyzer.finish(),
157            terms,
158            residual,
159            diagnostics,
160            proven_nonnegative: proves_nonnegative(graph, facts, graph.root()),
161        }
162    }
163
164    fn general(graph: &ExprGraph, reason: NormalizationFallbackReason) -> Self {
165        Self {
166            graph: graph.clone(),
167            terms: Vec::new(),
168            residual: None,
169            diagnostics: NormalizationDiagnostics {
170                strategy: NormalizationStrategy::General,
171                basis_count: 0,
172                coherent_group_count: 0,
173                has_residual: false,
174                fallback_reason: Some(reason),
175            },
176            proven_nonnegative: false,
177        }
178    }
179
180    /// Returns stable compiler diagnostics.
181    pub fn diagnostics(&self) -> &NormalizationDiagnostics {
182        &self.diagnostics
183    }
184
185    /// Returns whether the source graph proves every event value nonnegative.
186    pub fn proven_nonnegative(&self) -> bool {
187        self.proven_nonnegative
188    }
189
190    /// Builds one event-only compiled model per statistic.
191    ///
192    /// # Errors
193    ///
194    /// Returns a compiler error if an extracted basis graph cannot be lowered.
195    pub fn basis_models(&self) -> CompileResult<Vec<CompiledModel>> {
196        self.terms
197            .iter()
198            .map(|term| {
199                CompiledModel::from_graph_without_normalization(compact_root(
200                    &self.graph,
201                    term.basis,
202                ))
203            })
204            .collect()
205    }
206
207    /// Builds the parameter-only scalar contraction for accumulated statistics.
208    ///
209    /// # Errors
210    ///
211    /// Returns a compiler error if the statistic count is incompatible with
212    /// this plan or the coefficient graph cannot be lowered.
213    pub fn evaluator_model(&self, statistics: &[Complex64]) -> CompileResult<CompiledModel> {
214        if statistics.len() != self.terms.len() {
215            return Err(CompileError::InvalidExecutablePlan(format!(
216                "normalization expected {} statistics, got {}",
217                self.terms.len(),
218                statistics.len()
219            )));
220        }
221        let mut nodes = self.graph.nodes().to_vec();
222        let mut metadata = graph_metadata(&self.graph);
223        let mut products = Vec::with_capacity(self.terms.len());
224        for (term, statistic) in self.terms.iter().zip(statistics) {
225            let constant = push_node(
226                &mut nodes,
227                &mut metadata,
228                ExprNode::from_folded_const(*statistic),
229                ExprSourceKind::Const,
230            );
231            products.push(push_node(
232                &mut nodes,
233                &mut metadata,
234                ExprNode::NaryMul {
235                    factors: vec![constant, term.coefficient],
236                },
237                ExprSourceKind::Binary,
238            ));
239        }
240        let sum = push_node(
241            &mut nodes,
242            &mut metadata,
243            ExprNode::NaryAdd { terms: products },
244            ExprSourceKind::Binary,
245        );
246        let root = push_node(
247            &mut nodes,
248            &mut metadata,
249            ExprNode::Unary {
250                op: UnaryOp::Real,
251                input: sum,
252            },
253            ExprSourceKind::Unary,
254        );
255        let graph = ExprGraph::from_parts(root, nodes, metadata)?;
256        CompiledModel::from_graph_without_normalization(compact_root(&graph, root))
257    }
258
259    /// Builds the nonseparable residual model, when present.
260    ///
261    /// # Errors
262    ///
263    /// Returns a compiler error if the residual graph cannot be lowered.
264    pub fn residual_model(&self) -> CompileResult<Option<CompiledModel>> {
265        self.residual
266            .map(|root| {
267                CompiledModel::from_graph_without_normalization(compact_root(&self.graph, root))
268            })
269            .transpose()
270    }
271}
272
273fn proves_nonnegative(graph: &ExprGraph, facts: &GraphFacts, id: ExprId) -> bool {
274    match graph.node(id).expect("normalization node exists") {
275        ExprNode::RealConst(value) => value.is_finite() && *value >= 0.0,
276        ExprNode::ComplexConst(value) => value.im == 0.0 && value.re.is_finite() && value.re >= 0.0,
277        ExprNode::Unary {
278            op: UnaryOp::NormSqr,
279            ..
280        } => true,
281        ExprNode::Unary {
282            op: UnaryOp::Real,
283            input,
284        } => proves_nonnegative(graph, facts, *input),
285        ExprNode::Unary {
286            op: UnaryOp::PowI(power),
287            input,
288        } => {
289            *power >= 0
290                && power % 2 == 0
291                && facts
292                    .get(*input)
293                    .is_some_and(|facts| facts.value_kind == ValueKind::Real)
294        }
295        ExprNode::Binary {
296            op: BinaryOp::Add,
297            lhs,
298            rhs,
299        } => proves_nonnegative(graph, facts, *lhs) && proves_nonnegative(graph, facts, *rhs),
300        ExprNode::Binary {
301            op: BinaryOp::Mul,
302            lhs,
303            rhs,
304        } => proves_nonnegative(graph, facts, *lhs) && proves_nonnegative(graph, facts, *rhs),
305        ExprNode::NaryAdd { terms } => terms
306            .iter()
307            .all(|term| proves_nonnegative(graph, facts, *term)),
308        ExprNode::NaryMul { factors } => factors
309            .iter()
310            .all(|factor| proves_nonnegative(graph, facts, *factor)),
311        _ => false,
312    }
313}
314
315struct Analyzer<'a> {
316    facts: &'a GraphFacts,
317    nodes: Vec<ExprNode>,
318    metadata: Vec<ExprMetadata>,
319    one: ExprId,
320    budget: usize,
321    coherent_groups: usize,
322    last_reason: Option<NormalizationFallbackReason>,
323}
324
325impl<'a> Analyzer<'a> {
326    fn new(graph: &ExprGraph, facts: &'a GraphFacts, budget: usize) -> Self {
327        let mut nodes = graph.nodes().to_vec();
328        let mut metadata = graph_metadata(graph);
329        let one = push_node(
330            &mut nodes,
331            &mut metadata,
332            ExprNode::RealConst(1.0),
333            ExprSourceKind::Const,
334        );
335        Self {
336            facts,
337            nodes,
338            metadata,
339            one,
340            budget,
341            coherent_groups: 0,
342            last_reason: None,
343        }
344    }
345
346    fn finish(&self) -> ExprGraph {
347        ExprGraph::from_parts(
348            ExprId::from_index(0),
349            self.nodes.clone(),
350            self.metadata.clone(),
351        )
352        .expect("augmented normalization graph is valid")
353    }
354
355    fn dependency(&self, id: ExprId) -> crate::DependencyFacts {
356        if id.index() < self.facts.nodes().len() {
357            return self.facts.get(id).expect("facts are complete").dependency;
358        }
359        self.nodes[id.index()].child_ids().into_iter().fold(
360            crate::DependencyFacts::per_compile(),
361            |dependency, child| dependency.union(self.dependency(child)),
362        )
363    }
364
365    fn additive_roots(&self, root: ExprId) -> Vec<ExprId> {
366        match &self.nodes[root.index()] {
367            ExprNode::NaryAdd { terms } => terms.clone(),
368            ExprNode::Binary {
369                op: BinaryOp::Add,
370                lhs,
371                rhs,
372            } => {
373                let mut roots = self.additive_roots(*lhs);
374                roots.extend(self.additive_roots(*rhs));
375                roots
376            }
377            _ => vec![root],
378        }
379    }
380
381    fn decompose(&mut self, id: ExprId) -> Result<Vec<SeparableTerm>, NormalizationFallbackReason> {
382        let dependency = self.dependency(id);
383        if !dependency.depends_on_event {
384            return Ok(vec![SeparableTerm {
385                coefficient: id,
386                basis: self.one,
387            }]);
388        }
389        if !dependency.depends_on_free_params {
390            return Ok(vec![SeparableTerm {
391                coefficient: self.one,
392                basis: id,
393            }]);
394        }
395
396        match self.nodes[id.index()].clone() {
397            ExprNode::Binary { op, lhs, rhs } => self.decompose_binary(id, op, lhs, rhs),
398            ExprNode::NaryAdd { terms } => {
399                let mut result = Vec::new();
400                for term in terms {
401                    result.extend(self.decompose(term)?);
402                    self.ensure_budget(&result)?;
403                }
404                Ok(result)
405            }
406            ExprNode::NaryMul { factors } => {
407                let mut result = vec![SeparableTerm {
408                    coefficient: self.one,
409                    basis: self.one,
410                }];
411                for factor in factors {
412                    let factor_terms = self.decompose(factor)?;
413                    result = self.multiply_terms(&result, &factor_terms)?;
414                }
415                Ok(result)
416            }
417            ExprNode::Unary { op, input } => self.decompose_unary(id, op, input),
418            _ => Err(self.unsupported(id, "structured mixed operation")),
419        }
420    }
421
422    fn decompose_binary(
423        &mut self,
424        id: ExprId,
425        op: BinaryOp,
426        lhs: ExprId,
427        rhs: ExprId,
428    ) -> Result<Vec<SeparableTerm>, NormalizationFallbackReason> {
429        match op {
430            BinaryOp::Add => {
431                let mut terms = self.decompose(lhs)?;
432                terms.extend(self.decompose(rhs)?);
433                self.ensure_budget(&terms)?;
434                Ok(terms)
435            }
436            BinaryOp::Sub => {
437                let mut terms = self.decompose(lhs)?;
438                for mut term in self.decompose(rhs)? {
439                    term.coefficient = self.unary(UnaryOp::Neg, term.coefficient);
440                    terms.push(term);
441                }
442                self.ensure_budget(&terms)?;
443                Ok(terms)
444            }
445            BinaryOp::Mul => {
446                let left = self.decompose(lhs)?;
447                let right = self.decompose(rhs)?;
448                self.multiply_terms(&left, &right)
449            }
450            BinaryOp::Div => {
451                let denominator = self.dependency(rhs);
452                let mut terms = self.decompose(lhs)?;
453                if !denominator.depends_on_event {
454                    for term in &mut terms {
455                        term.coefficient = self.binary(BinaryOp::Div, term.coefficient, rhs);
456                    }
457                    Ok(terms)
458                } else if !denominator.depends_on_free_params {
459                    for term in &mut terms {
460                        term.basis = self.binary(BinaryOp::Div, term.basis, rhs);
461                    }
462                    Ok(terms)
463                } else {
464                    Err(self.unsupported(id, "mixed division"))
465                }
466            }
467            BinaryOp::Atan2 => Err(self.unsupported(id, "atan2")),
468        }
469    }
470
471    fn decompose_unary(
472        &mut self,
473        id: ExprId,
474        op: UnaryOp,
475        input: ExprId,
476    ) -> Result<Vec<SeparableTerm>, NormalizationFallbackReason> {
477        match op {
478            UnaryOp::Neg => {
479                let mut terms = self.decompose(input)?;
480                for term in &mut terms {
481                    term.coefficient = self.unary(UnaryOp::Neg, term.coefficient);
482                }
483                Ok(terms)
484            }
485            UnaryOp::Conj => {
486                let mut terms = self.decompose(input)?;
487                for term in &mut terms {
488                    term.coefficient = self.unary(UnaryOp::Conj, term.coefficient);
489                    term.basis = self.unary(UnaryOp::Conj, term.basis);
490                }
491                Ok(terms)
492            }
493            UnaryOp::NormSqr => {
494                self.coherent_groups += 1;
495                let terms = self.decompose(input)?;
496                let packed_len = terms.len().saturating_mul(terms.len().saturating_add(1)) / 2;
497                if packed_len > self.budget {
498                    return Err(NormalizationFallbackReason::ExpansionBudgetExceeded {
499                        budget: self.budget,
500                    });
501                }
502                let two = self.constant(Complex64::new(2.0, 0.0));
503                let mut packed = Vec::with_capacity(packed_len);
504                for (row, left) in terms.iter().enumerate() {
505                    for (column, right) in terms.iter().enumerate().skip(row) {
506                        let right_coefficient = self.unary(UnaryOp::Conj, right.coefficient);
507                        let right_basis = self.unary(UnaryOp::Conj, right.basis);
508                        let mut coefficient = self.product(&[left.coefficient, right_coefficient]);
509                        if column != row {
510                            coefficient = self.product(&[two, coefficient]);
511                        }
512                        packed.push(SeparableTerm {
513                            coefficient,
514                            basis: self.product(&[left.basis, right_basis]),
515                        });
516                    }
517                }
518                Ok(packed)
519            }
520            UnaryOp::PowI(power) if power >= 0 => {
521                let base = self.decompose(input)?;
522                let mut result = vec![SeparableTerm {
523                    coefficient: self.one,
524                    basis: self.one,
525                }];
526                for _ in 0..power {
527                    result = self.multiply_terms(&result, &base)?;
528                }
529                Ok(result)
530            }
531            UnaryOp::Real | UnaryOp::Imag => self.decompose_projection(op, input),
532            UnaryOp::Sqrt
533            | UnaryOp::Exp
534            | UnaryOp::Sin
535            | UnaryOp::Cos
536            | UnaryOp::Log
537            | UnaryOp::PowI(_) => Err(self.unsupported(id, "nonlinear unary operation")),
538        }
539    }
540
541    fn decompose_projection(
542        &mut self,
543        op: UnaryOp,
544        input: ExprId,
545    ) -> Result<Vec<SeparableTerm>, NormalizationFallbackReason> {
546        let terms = self.decompose(input)?;
547        let mut result = Vec::with_capacity(terms.len() * 2);
548        let factor = if op == UnaryOp::Real {
549            Complex64::new(0.5, 0.0)
550        } else {
551            Complex64::new(0.0, -0.5)
552        };
553        let conjugate_factor = if op == UnaryOp::Real { factor } else { -factor };
554        let factor = self.constant(factor);
555        let conjugate_factor = self.constant(conjugate_factor);
556        for term in terms {
557            let coefficient = self.product(&[factor, term.coefficient]);
558            result.push(SeparableTerm {
559                coefficient,
560                basis: term.basis,
561            });
562            let conjugated_coefficient = self.unary(UnaryOp::Conj, term.coefficient);
563            let conjugated_basis = self.unary(UnaryOp::Conj, term.basis);
564            let coefficient = self.product(&[conjugate_factor, conjugated_coefficient]);
565            result.push(SeparableTerm {
566                coefficient,
567                basis: conjugated_basis,
568            });
569        }
570        self.ensure_budget(&result)?;
571        Ok(result)
572    }
573
574    fn multiply_terms(
575        &mut self,
576        lhs: &[SeparableTerm],
577        rhs: &[SeparableTerm],
578    ) -> Result<Vec<SeparableTerm>, NormalizationFallbackReason> {
579        let count = lhs.len().saturating_mul(rhs.len());
580        if count > self.budget {
581            return Err(NormalizationFallbackReason::ExpansionBudgetExceeded {
582                budget: self.budget,
583            });
584        }
585        let mut result = Vec::with_capacity(count);
586        for lhs in lhs {
587            for rhs in rhs {
588                let coefficient = self.product(&[lhs.coefficient, rhs.coefficient]);
589                let basis = self.product(&[lhs.basis, rhs.basis]);
590                result.push(SeparableTerm { coefficient, basis });
591            }
592        }
593        Ok(result)
594    }
595
596    fn ensure_budget(&self, terms: &[SeparableTerm]) -> Result<(), NormalizationFallbackReason> {
597        if terms.len() > self.budget {
598            Err(NormalizationFallbackReason::ExpansionBudgetExceeded {
599                budget: self.budget,
600            })
601        } else {
602            Ok(())
603        }
604    }
605
606    fn unsupported(&self, node: ExprId, operation: &'static str) -> NormalizationFallbackReason {
607        NormalizationFallbackReason::UnsupportedMixedOperation { node, operation }
608    }
609
610    fn constant(&mut self, value: Complex64) -> ExprId {
611        self.push(ExprNode::from_folded_const(value), ExprSourceKind::Const)
612    }
613
614    fn unary(&mut self, op: UnaryOp, input: ExprId) -> ExprId {
615        self.push(ExprNode::Unary { op, input }, ExprSourceKind::Unary)
616    }
617
618    fn binary(&mut self, op: BinaryOp, lhs: ExprId, rhs: ExprId) -> ExprId {
619        self.push(ExprNode::Binary { op, lhs, rhs }, ExprSourceKind::Binary)
620    }
621
622    fn product(&mut self, factors: &[ExprId]) -> ExprId {
623        match factors {
624            [] => self.one,
625            [only] => *only,
626            _ => self.push(
627                ExprNode::NaryMul {
628                    factors: factors.to_vec(),
629                },
630                ExprSourceKind::Binary,
631            ),
632        }
633    }
634
635    fn sum_roots(&mut self, roots: &[ExprId]) -> Option<ExprId> {
636        match roots {
637            [] => None,
638            [only] => Some(*only),
639            _ => Some(self.push(
640                ExprNode::NaryAdd {
641                    terms: roots.to_vec(),
642                },
643                ExprSourceKind::Binary,
644            )),
645        }
646    }
647
648    fn push(&mut self, node: ExprNode, source: ExprSourceKind) -> ExprId {
649        push_node(&mut self.nodes, &mut self.metadata, node, source)
650    }
651}
652
653fn graph_metadata(graph: &ExprGraph) -> Vec<ExprMetadata> {
654    (0..graph.nodes().len())
655        .map(|index| {
656            graph
657                .metadata(ExprId::from_index(index))
658                .expect("normalization graph metadata is complete")
659                .clone()
660        })
661        .collect()
662}
663
664fn push_node(
665    nodes: &mut Vec<ExprNode>,
666    metadata: &mut Vec<ExprMetadata>,
667    node: ExprNode,
668    source: ExprSourceKind,
669) -> ExprId {
670    let id = ExprId::from_index(nodes.len());
671    nodes.push(node);
672    metadata.push(ExprMetadata::new(source));
673    id
674}
675
676fn compact_root(graph: &ExprGraph, root: ExprId) -> ExprGraph {
677    let mut required = vec![false; graph.nodes().len()];
678    mark_required(graph, root, &mut required);
679    let mut remap = vec![None; graph.nodes().len()];
680    let mut nodes = Vec::new();
681    let mut metadata = Vec::new();
682    for (index, node) in graph.nodes().iter().enumerate() {
683        if !required[index] {
684            continue;
685        }
686        let id = ExprId::from_index(nodes.len());
687        remap[index] = Some(id);
688        nodes.push(remap_node(node, &remap));
689        metadata.push(
690            graph
691                .metadata(ExprId::from_index(index))
692                .expect("normalization graph metadata is complete")
693                .clone(),
694        );
695    }
696    ExprGraph::from_parts(
697        remap[root.index()].expect("normalization root is required"),
698        nodes,
699        metadata,
700    )
701    .expect("compacted normalization graph is valid")
702}
703
704fn mark_required(graph: &ExprGraph, id: ExprId, required: &mut [bool]) {
705    if required[id.index()] {
706        return;
707    }
708    required[id.index()] = true;
709    for child in graph
710        .node(id)
711        .expect("normalization node exists")
712        .child_ids()
713    {
714        mark_required(graph, child, required);
715    }
716}
717
718fn remap_node(node: &ExprNode, remap: &[Option<ExprId>]) -> ExprNode {
719    let id = |id: ExprId| remap[id.index()].expect("children precede normalization nodes");
720    match node {
721        ExprNode::RealConst(value) => ExprNode::RealConst(*value),
722        ExprNode::ComplexConst(value) => ExprNode::ComplexConst(*value),
723        ExprNode::ScalarParam(parameter) => ExprNode::ScalarParam(parameter.clone()),
724        ExprNode::EventScalar(name) => ExprNode::EventScalar(name.clone()),
725        ExprNode::EventP4Component { name, component } => ExprNode::EventP4Component {
726            name: name.clone(),
727            component: *component,
728        },
729        ExprNode::Unary { op, input } => ExprNode::Unary {
730            op: *op,
731            input: id(*input),
732        },
733        ExprNode::Binary { op, lhs, rhs } => ExprNode::Binary {
734            op: *op,
735            lhs: id(*lhs),
736            rhs: id(*rhs),
737        },
738        ExprNode::NaryAdd { terms } => ExprNode::NaryAdd {
739            terms: terms.iter().map(|term| id(*term)).collect(),
740        },
741        ExprNode::NaryMul { factors } => ExprNode::NaryMul {
742            factors: factors.iter().map(|factor| id(*factor)).collect(),
743        },
744        ExprNode::Complex { re, im } => ExprNode::Complex {
745            re: id(*re),
746            im: id(*im),
747        },
748        ExprNode::Vector { elements } => ExprNode::Vector {
749            elements: elements.iter().map(|element| id(*element)).collect(),
750        },
751        ExprNode::Matrix {
752            rows,
753            cols,
754            elements,
755        } => ExprNode::Matrix {
756            rows: *rows,
757            cols: *cols,
758            elements: elements.iter().map(|element| id(*element)).collect(),
759        },
760        ExprNode::Component { input, index } => ExprNode::Component {
761            input: id(*input),
762            index: *index,
763        },
764        ExprNode::MatrixElement { input, row, col } => ExprNode::MatrixElement {
765            input: id(*input),
766            row: *row,
767            col: *col,
768        },
769        ExprNode::MatMul { lhs, rhs } => ExprNode::MatMul {
770            lhs: id(*lhs),
771            rhs: id(*rhs),
772        },
773        ExprNode::MatVec { matrix, vector } => ExprNode::MatVec {
774            matrix: id(*matrix),
775            vector: id(*vector),
776        },
777        ExprNode::Dot { lhs, rhs } => ExprNode::Dot {
778            lhs: id(*lhs),
779            rhs: id(*rhs),
780        },
781        ExprNode::Solve { matrix, rhs } => ExprNode::Solve {
782            matrix: id(*matrix),
783            rhs: id(*rhs),
784        },
785    }
786}
787
788#[cfg(test)]
789mod tests {
790    use laddu_expr::{Expr, complex, event_scalar, parameter, polar_complex};
791
792    use super::*;
793
794    fn diagnostics(expression: &Expr) -> NormalizationDiagnostics {
795        CompiledModel::from_expr(expression)
796            .unwrap()
797            .normalization_diagnostics()
798            .clone()
799    }
800
801    #[test]
802    fn extracts_rectangular_and_polar_coherent_models() {
803        let basis = complex(event_scalar("x"), event_scalar("y"));
804        let rectangular = (complex(parameter!("re"), parameter!("im")) * basis.clone()).norm_sqr();
805        let polar = (polar_complex(parameter!("mag"), parameter!("phase")) * basis).norm_sqr();
806        for model in [&rectangular, &polar] {
807            let diagnostics = diagnostics(model);
808            assert_eq!(
809                diagnostics.strategy(),
810                NormalizationStrategy::Hermitian,
811                "{diagnostics:?}"
812            );
813            assert!(!diagnostics.has_residual());
814            assert!(diagnostics.basis_count() >= 1);
815        }
816    }
817
818    #[test]
819    fn decomposes_separable_and_nonseparable_additive_parts() {
820        let x = event_scalar("x");
821        let scale = Expr::from(parameter!("scale"));
822        let expression = scale.clone() * x.clone() + (scale * x).sin();
823        let diagnostics = diagnostics(&expression);
824        assert_eq!(diagnostics.strategy(), NormalizationStrategy::Hybrid);
825        assert!(diagnostics.has_residual());
826    }
827}