Skip to main content

laddu_compile/
normalization.rs

1use laddu_expr::{
2    BinaryOp, ExprGraph, ExprGraphRebuilder, ExprId, ExprMetadata, ExprNode, ExprSourceKind,
3    UnaryOp, ValueKind,
4};
5use num::complex::Complex64;
6
7use crate::{CompileError, CompileResult, CompiledModel, GraphFacts, graph_utils::compact_to_root};
8
9const DEFAULT_EXPANSION_BUDGET: usize = 4_096;
10
11/// Compiler-selected family of accepted-normalization implementation.
12#[derive(Copy, Clone, Debug, PartialEq, Eq)]
13pub enum NormalizationStrategy {
14    /// Coherent groups represented by packed Hermitian statistics.
15    Hermitian,
16    /// A general exact sum of parameter coefficients times event bases.
17    LinearStatistics,
18    /// Exact sufficient statistics plus a nonseparable additive residual.
19    Hybrid,
20    /// Ordinary event reduction.
21    General,
22}
23
24/// Stable reason that compiler-native normalization was not selected.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub enum NormalizationFallbackReason {
27    /// The model root is not a real scalar.
28    NonScalarIntensity,
29    /// An operation mixed parameter and event dependence without an exact rule.
30    UnsupportedMixedOperation {
31        /// Offending optimized graph node.
32        node: ExprId,
33        /// Operation category.
34        operation: &'static str,
35    },
36    /// Exact symbolic distribution exceeded the compiler expansion budget.
37    ExpansionBudgetExceeded {
38        /// Maximum permitted number of sufficient-statistic terms.
39        budget: usize,
40    },
41}
42
43/// Stable summary of compiler-native normalization analysis.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct NormalizationDiagnostics {
46    strategy: NormalizationStrategy,
47    basis_count: usize,
48    coherent_group_count: usize,
49    has_residual: bool,
50    fallback_reason: Option<NormalizationFallbackReason>,
51}
52
53impl NormalizationDiagnostics {
54    /// Returns the compiler-selected candidate family.
55    pub fn strategy(&self) -> NormalizationStrategy {
56        self.strategy
57    }
58
59    /// Returns the number of event-basis statistics in the candidate.
60    pub fn basis_count(&self) -> usize {
61        self.basis_count
62    }
63
64    /// Returns the number of recognized coherent squared-norm groups.
65    pub fn coherent_group_count(&self) -> usize {
66        self.coherent_group_count
67    }
68
69    /// Returns whether evaluation retains a general additive residual.
70    pub fn has_residual(&self) -> bool {
71        self.has_residual
72    }
73
74    /// Returns the structured general-path reason, when present.
75    pub fn fallback_reason(&self) -> Option<&NormalizationFallbackReason> {
76        self.fallback_reason.as_ref()
77    }
78}
79
80#[derive(Copy, Clone, Debug)]
81struct SeparableTerm {
82    coefficient: ExprId,
83    basis: ExprId,
84}
85
86/// Exact compiler artifact consumed by execution backends.
87///
88/// This is a workspace-internal contract shared with `laddu-runtime`. It is
89/// public only because Rust crate boundaries do not provide workspace-scoped
90/// visibility. Downstream users should use [`NormalizationDiagnostics`]
91/// instead of depending on this artifact's representation or methods.
92#[doc(hidden)]
93#[derive(Clone, Debug)]
94pub struct NormalizationPlan {
95    graph: ExprGraph,
96    terms: Vec<SeparableTerm>,
97    residual: Option<ExprId>,
98    diagnostics: NormalizationDiagnostics,
99    proven_nonnegative: bool,
100}
101
102impl NormalizationPlan {
103    pub(crate) fn analyze_disabled(graph: &ExprGraph) -> Self {
104        Self::general(
105            graph,
106            NormalizationFallbackReason::UnsupportedMixedOperation {
107                node: graph.root(),
108                operation: "normalization analysis disabled",
109            },
110        )
111    }
112
113    pub(crate) fn analyze(graph: &ExprGraph, facts: &GraphFacts) -> Self {
114        if !matches!(
115            facts.get(graph.root()).map(|facts| facts.value_kind),
116            Some(ValueKind::Real | ValueKind::Complex)
117        ) {
118            return Self::general(graph, NormalizationFallbackReason::NonScalarIntensity);
119        }
120
121        let decomposition = NormalizationAnalyzer::new(graph, facts, DEFAULT_EXPANSION_BUDGET)
122            .analyze(graph.root());
123
124        if decomposition.terms.is_empty() {
125            return Self::general(
126                graph,
127                decomposition.last_failure().cloned().unwrap_or(
128                    NormalizationFallbackReason::UnsupportedMixedOperation {
129                        node: graph.root(),
130                        operation: "root",
131                    },
132                ),
133            );
134        }
135
136        let built = decomposition
137            .build(graph)
138            .expect("normalization decomposition emits a valid graph");
139        let terms = built.terms;
140        let residual = built.residual;
141        let strategy = if residual.is_some() {
142            NormalizationStrategy::Hybrid
143        } else if built.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: built.coherent_groups,
152            has_residual: residual.is_some(),
153            fallback_reason: built.last_failure,
154        };
155        Self {
156            graph: built.graph,
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_to_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 builder = NormalizationGraphBuilder::new(&self.graph);
222        let mut products = Vec::with_capacity(self.terms.len());
223        for (term, statistic) in self.terms.iter().zip(statistics) {
224            let constant = builder.constant(*statistic);
225            let coefficient = builder.source(term.coefficient);
226            products.push(builder.product(&[constant, coefficient]));
227        }
228        let sum = builder.sum(&products);
229        let root = builder.unary(UnaryOp::Real, sum);
230        let graph = builder.finish(root)?;
231        CompiledModel::from_graph_without_normalization(compact_to_root(&graph, root)?)
232    }
233
234    /// Builds the nonseparable residual model, when present.
235    ///
236    /// # Errors
237    ///
238    /// Returns a compiler error if the residual graph cannot be lowered.
239    pub fn residual_model(&self) -> CompileResult<Option<CompiledModel>> {
240        self.residual
241            .map(|root| {
242                CompiledModel::from_graph_without_normalization(compact_to_root(&self.graph, root)?)
243            })
244            .transpose()
245    }
246}
247
248fn proves_nonnegative(graph: &ExprGraph, facts: &GraphFacts, id: ExprId) -> bool {
249    match graph.node(id).expect("normalization node exists") {
250        ExprNode::RealConst(value) => value.is_finite() && *value >= 0.0,
251        ExprNode::ComplexConst(value) => value.im == 0.0 && value.re.is_finite() && value.re >= 0.0,
252        ExprNode::Unary {
253            op: UnaryOp::NormSqr,
254            ..
255        } => true,
256        ExprNode::Unary {
257            op: UnaryOp::Real,
258            input,
259        } => proves_nonnegative(graph, facts, *input),
260        ExprNode::Unary {
261            op: UnaryOp::PowI(power),
262            input,
263        } => {
264            *power >= 0
265                && power % 2 == 0
266                && facts
267                    .get(*input)
268                    .is_some_and(|facts| facts.value_kind == ValueKind::Real)
269        }
270        ExprNode::Binary {
271            op: BinaryOp::Add,
272            lhs,
273            rhs,
274        } => proves_nonnegative(graph, facts, *lhs) && proves_nonnegative(graph, facts, *rhs),
275        ExprNode::Binary {
276            op: BinaryOp::Mul,
277            lhs,
278            rhs,
279        } => proves_nonnegative(graph, facts, *lhs) && proves_nonnegative(graph, facts, *rhs),
280        ExprNode::NaryAdd { terms } => terms
281            .iter()
282            .all(|term| proves_nonnegative(graph, facts, *term)),
283        ExprNode::NaryMul { factors } => factors
284            .iter()
285            .all(|factor| proves_nonnegative(graph, facts, *factor)),
286        _ => false,
287    }
288}
289
290#[derive(Copy, Clone, Debug, PartialEq, Eq)]
291enum DecompositionNode {
292    Source(ExprId),
293    Generated(usize),
294}
295
296#[derive(Clone, Debug)]
297enum GraphOperation {
298    Constant(Complex64),
299    Unary {
300        op: UnaryOp,
301        input: DecompositionNode,
302    },
303    Binary {
304        op: BinaryOp,
305        lhs: DecompositionNode,
306        rhs: DecompositionNode,
307    },
308    Product(Vec<DecompositionNode>),
309    Sum(Vec<DecompositionNode>),
310}
311
312#[derive(Clone, Debug)]
313struct AnalyzedTerm {
314    coefficient: DecompositionNode,
315    basis: DecompositionNode,
316}
317
318#[derive(Clone, Debug)]
319struct Decomposition {
320    operations: Vec<GraphOperation>,
321    terms: Vec<AnalyzedTerm>,
322    residual: Option<DecompositionNode>,
323    coherent_groups: usize,
324    failures: Vec<NormalizationFallbackReason>,
325}
326
327impl Decomposition {
328    fn last_failure(&self) -> Option<&NormalizationFallbackReason> {
329        self.failures.last()
330    }
331
332    fn build(self, graph: &ExprGraph) -> CompileResult<BuiltDecomposition> {
333        let mut builder = NormalizationGraphBuilder::new(graph);
334        let mut generated = Vec::with_capacity(self.operations.len());
335        for operation in self.operations {
336            let resolve = |node: DecompositionNode| match node {
337                DecompositionNode::Source(id) => builder.source(id),
338                DecompositionNode::Generated(index) => generated[index],
339            };
340            let id = match operation {
341                GraphOperation::Constant(value) => builder.constant(value),
342                GraphOperation::Unary { op, input } => {
343                    let input = resolve(input);
344                    builder.unary(op, input)
345                }
346                GraphOperation::Binary { op, lhs, rhs } => {
347                    let lhs = resolve(lhs);
348                    let rhs = resolve(rhs);
349                    builder.binary(op, lhs, rhs)
350                }
351                GraphOperation::Product(factors) => {
352                    let factors = factors.into_iter().map(resolve).collect::<Vec<_>>();
353                    builder.product(&factors)
354                }
355                GraphOperation::Sum(terms) => {
356                    let terms = terms.into_iter().map(resolve).collect::<Vec<_>>();
357                    builder.sum(&terms)
358                }
359            };
360            generated.push(id);
361        }
362        let resolve = |node: DecompositionNode| match node {
363            DecompositionNode::Source(id) => builder.source(id),
364            DecompositionNode::Generated(index) => generated[index],
365        };
366        let terms = self
367            .terms
368            .into_iter()
369            .map(|term| SeparableTerm {
370                coefficient: resolve(term.coefficient),
371                basis: resolve(term.basis),
372            })
373            .collect();
374        let residual = self.residual.map(resolve);
375        let root = builder.source(ExprId::from_index(0));
376        Ok(BuiltDecomposition {
377            graph: builder.finish(root)?,
378            terms,
379            residual,
380            coherent_groups: self.coherent_groups,
381            last_failure: self.failures.last().cloned(),
382        })
383    }
384}
385
386struct BuiltDecomposition {
387    graph: ExprGraph,
388    terms: Vec<SeparableTerm>,
389    residual: Option<ExprId>,
390    coherent_groups: usize,
391    last_failure: Option<NormalizationFallbackReason>,
392}
393
394#[derive(Copy, Clone, Debug)]
395struct ExpansionBudget(usize);
396
397impl ExpansionBudget {
398    fn ensure(self, count: usize) -> Result<(), NormalizationFallbackReason> {
399        if count <= self.0 {
400            Ok(())
401        } else {
402            Err(self.exceeded())
403        }
404    }
405
406    fn product_count(self, lhs: usize, rhs: usize) -> Result<usize, NormalizationFallbackReason> {
407        let count = lhs.checked_mul(rhs).ok_or_else(|| self.exceeded())?;
408        self.ensure(count)?;
409        Ok(count)
410    }
411
412    fn packed_triangle_count(self, count: usize) -> Result<usize, NormalizationFallbackReason> {
413        let next = count.checked_add(1).ok_or_else(|| self.exceeded())?;
414        let packed = count.checked_mul(next).ok_or_else(|| self.exceeded())? / 2;
415        self.ensure(packed)?;
416        Ok(packed)
417    }
418
419    fn exceeded(self) -> NormalizationFallbackReason {
420        NormalizationFallbackReason::ExpansionBudgetExceeded { budget: self.0 }
421    }
422}
423
424struct NormalizationAnalyzer<'a> {
425    graph: &'a ExprGraph,
426    facts: &'a GraphFacts,
427    operations: Vec<GraphOperation>,
428    one: DecompositionNode,
429    budget: ExpansionBudget,
430    coherent_groups: usize,
431}
432
433impl<'a> NormalizationAnalyzer<'a> {
434    fn new(graph: &'a ExprGraph, facts: &'a GraphFacts, budget: usize) -> Self {
435        let mut analyzer = Self {
436            graph,
437            facts,
438            operations: Vec::new(),
439            one: DecompositionNode::Source(graph.root()),
440            budget: ExpansionBudget(budget),
441            coherent_groups: 0,
442        };
443        analyzer.one = analyzer.constant(Complex64::new(1.0, 0.0));
444        analyzer
445    }
446
447    fn analyze(mut self, root: ExprId) -> Decomposition {
448        let mut terms = Vec::new();
449        let mut residuals = Vec::new();
450        let mut failures = Vec::new();
451        for root in self.additive_roots(root) {
452            match self.decompose(root) {
453                Ok(mut extracted) => terms.append(&mut extracted),
454                Err(reason) => {
455                    failures.push(reason);
456                    residuals.push(DecompositionNode::Source(root));
457                }
458            }
459        }
460        let residual = self.sum(&residuals);
461        Decomposition {
462            operations: self.operations,
463            terms,
464            residual,
465            coherent_groups: self.coherent_groups,
466            failures,
467        }
468    }
469
470    fn dependency(&self, id: ExprId) -> crate::DependencyFacts {
471        self.facts.get(id).expect("facts are complete").dependency
472    }
473
474    fn additive_roots(&self, root: ExprId) -> Vec<ExprId> {
475        match self.graph.node(root).expect("normalization node exists") {
476            ExprNode::NaryAdd { terms } => terms.clone(),
477            ExprNode::Binary {
478                op: BinaryOp::Add,
479                lhs,
480                rhs,
481            } => {
482                let mut roots = self.additive_roots(*lhs);
483                roots.extend(self.additive_roots(*rhs));
484                roots
485            }
486            _ => vec![root],
487        }
488    }
489
490    fn decompose(&mut self, id: ExprId) -> Result<Vec<AnalyzedTerm>, NormalizationFallbackReason> {
491        let dependency = self.dependency(id);
492        if !dependency.depends_on_event {
493            return Ok(vec![AnalyzedTerm {
494                coefficient: DecompositionNode::Source(id),
495                basis: self.one,
496            }]);
497        }
498        if !dependency.depends_on_free_params {
499            return Ok(vec![AnalyzedTerm {
500                coefficient: self.one,
501                basis: DecompositionNode::Source(id),
502            }]);
503        }
504
505        match self
506            .graph
507            .node(id)
508            .expect("normalization node exists")
509            .clone()
510        {
511            ExprNode::Binary { op, lhs, rhs } => self.decompose_binary(id, op, lhs, rhs),
512            ExprNode::NaryAdd { terms } => {
513                let mut result = Vec::new();
514                for term in terms {
515                    result.extend(self.decompose(term)?);
516                    self.ensure_budget(&result)?;
517                }
518                Ok(result)
519            }
520            ExprNode::NaryMul { factors } => {
521                let mut result = vec![AnalyzedTerm {
522                    coefficient: self.one,
523                    basis: self.one,
524                }];
525                for factor in factors {
526                    let factor_terms = self.decompose(factor)?;
527                    result = self.multiply_terms(&result, &factor_terms)?;
528                }
529                Ok(result)
530            }
531            ExprNode::Unary { op, input } => self.decompose_unary(id, op, input),
532            _ => Err(self.unsupported(id, "structured mixed operation")),
533        }
534    }
535
536    fn decompose_binary(
537        &mut self,
538        id: ExprId,
539        op: BinaryOp,
540        lhs: ExprId,
541        rhs: ExprId,
542    ) -> Result<Vec<AnalyzedTerm>, NormalizationFallbackReason> {
543        match op {
544            BinaryOp::Add => {
545                let mut terms = self.decompose(lhs)?;
546                terms.extend(self.decompose(rhs)?);
547                self.ensure_budget(&terms)?;
548                Ok(terms)
549            }
550            BinaryOp::Sub => {
551                let mut terms = self.decompose(lhs)?;
552                for mut term in self.decompose(rhs)? {
553                    term.coefficient = self.unary(UnaryOp::Neg, term.coefficient);
554                    terms.push(term);
555                }
556                self.ensure_budget(&terms)?;
557                Ok(terms)
558            }
559            BinaryOp::Mul => {
560                let left = self.decompose(lhs)?;
561                let right = self.decompose(rhs)?;
562                self.multiply_terms(&left, &right)
563            }
564            BinaryOp::Div => {
565                let denominator = self.dependency(rhs);
566                let mut terms = self.decompose(lhs)?;
567                if !denominator.depends_on_event {
568                    for term in &mut terms {
569                        term.coefficient = self.binary(
570                            BinaryOp::Div,
571                            term.coefficient,
572                            DecompositionNode::Source(rhs),
573                        );
574                    }
575                    Ok(terms)
576                } else if !denominator.depends_on_free_params {
577                    for term in &mut terms {
578                        term.basis =
579                            self.binary(BinaryOp::Div, term.basis, DecompositionNode::Source(rhs));
580                    }
581                    Ok(terms)
582                } else {
583                    Err(self.unsupported(id, "mixed division"))
584                }
585            }
586            BinaryOp::Atan2 => Err(self.unsupported(id, "atan2")),
587        }
588    }
589
590    fn decompose_unary(
591        &mut self,
592        id: ExprId,
593        op: UnaryOp,
594        input: ExprId,
595    ) -> Result<Vec<AnalyzedTerm>, NormalizationFallbackReason> {
596        match op {
597            UnaryOp::Neg => {
598                let mut terms = self.decompose(input)?;
599                for term in &mut terms {
600                    term.coefficient = self.unary(UnaryOp::Neg, term.coefficient);
601                }
602                Ok(terms)
603            }
604            UnaryOp::Conj => {
605                let mut terms = self.decompose(input)?;
606                for term in &mut terms {
607                    term.coefficient = self.unary(UnaryOp::Conj, term.coefficient);
608                    term.basis = self.unary(UnaryOp::Conj, term.basis);
609                }
610                Ok(terms)
611            }
612            UnaryOp::NormSqr => {
613                self.coherent_groups += 1;
614                let terms = self.decompose(input)?;
615                let packed_len = self.budget.packed_triangle_count(terms.len())?;
616                let two = self.constant(Complex64::new(2.0, 0.0));
617                let mut packed = Vec::with_capacity(packed_len);
618                for (row, left) in terms.iter().enumerate() {
619                    for (column, right) in terms.iter().enumerate().skip(row) {
620                        let right_coefficient = self.unary(UnaryOp::Conj, right.coefficient);
621                        let right_basis = self.unary(UnaryOp::Conj, right.basis);
622                        let mut coefficient = self.product(&[left.coefficient, right_coefficient]);
623                        if column != row {
624                            coefficient = self.product(&[two, coefficient]);
625                        }
626                        packed.push(AnalyzedTerm {
627                            coefficient,
628                            basis: self.product(&[left.basis, right_basis]),
629                        });
630                    }
631                }
632                Ok(packed)
633            }
634            UnaryOp::PowI(power) if power >= 0 => {
635                let base = self.decompose(input)?;
636                let mut result = vec![AnalyzedTerm {
637                    coefficient: self.one,
638                    basis: self.one,
639                }];
640                for _ in 0..power {
641                    result = self.multiply_terms(&result, &base)?;
642                }
643                Ok(result)
644            }
645            UnaryOp::Real | UnaryOp::Imag => self.decompose_projection(op, input),
646            UnaryOp::Sqrt
647            | UnaryOp::Exp
648            | UnaryOp::Sin
649            | UnaryOp::Cos
650            | UnaryOp::Log
651            | UnaryOp::PowI(_) => Err(self.unsupported(id, "nonlinear unary operation")),
652        }
653    }
654
655    fn decompose_projection(
656        &mut self,
657        op: UnaryOp,
658        input: ExprId,
659    ) -> Result<Vec<AnalyzedTerm>, NormalizationFallbackReason> {
660        let terms = self.decompose(input)?;
661        let mut result = Vec::with_capacity(terms.len() * 2);
662        let factor = if op == UnaryOp::Real {
663            Complex64::new(0.5, 0.0)
664        } else {
665            Complex64::new(0.0, -0.5)
666        };
667        let conjugate_factor = if op == UnaryOp::Real { factor } else { -factor };
668        let factor = self.constant(factor);
669        let conjugate_factor = self.constant(conjugate_factor);
670        for term in terms {
671            let coefficient = self.product(&[factor, term.coefficient]);
672            result.push(AnalyzedTerm {
673                coefficient,
674                basis: term.basis,
675            });
676            let conjugated_coefficient = self.unary(UnaryOp::Conj, term.coefficient);
677            let conjugated_basis = self.unary(UnaryOp::Conj, term.basis);
678            let coefficient = self.product(&[conjugate_factor, conjugated_coefficient]);
679            result.push(AnalyzedTerm {
680                coefficient,
681                basis: conjugated_basis,
682            });
683        }
684        self.ensure_budget(&result)?;
685        Ok(result)
686    }
687
688    fn multiply_terms(
689        &mut self,
690        lhs: &[AnalyzedTerm],
691        rhs: &[AnalyzedTerm],
692    ) -> Result<Vec<AnalyzedTerm>, NormalizationFallbackReason> {
693        let count = self.budget.product_count(lhs.len(), rhs.len())?;
694        let mut result = Vec::with_capacity(count);
695        for lhs in lhs {
696            for rhs in rhs {
697                let coefficient = self.product(&[lhs.coefficient, rhs.coefficient]);
698                let basis = self.product(&[lhs.basis, rhs.basis]);
699                result.push(AnalyzedTerm { coefficient, basis });
700            }
701        }
702        Ok(result)
703    }
704
705    fn ensure_budget(&self, terms: &[AnalyzedTerm]) -> Result<(), NormalizationFallbackReason> {
706        self.budget.ensure(terms.len())
707    }
708
709    fn unsupported(&self, node: ExprId, operation: &'static str) -> NormalizationFallbackReason {
710        NormalizationFallbackReason::UnsupportedMixedOperation { node, operation }
711    }
712
713    fn constant(&mut self, value: Complex64) -> DecompositionNode {
714        self.push(GraphOperation::Constant(value))
715    }
716
717    fn unary(&mut self, op: UnaryOp, input: DecompositionNode) -> DecompositionNode {
718        self.push(GraphOperation::Unary { op, input })
719    }
720
721    fn binary(
722        &mut self,
723        op: BinaryOp,
724        lhs: DecompositionNode,
725        rhs: DecompositionNode,
726    ) -> DecompositionNode {
727        self.push(GraphOperation::Binary { op, lhs, rhs })
728    }
729
730    fn product(&mut self, factors: &[DecompositionNode]) -> DecompositionNode {
731        match factors {
732            [] => self.one,
733            [only] => *only,
734            _ => self.push(GraphOperation::Product(factors.to_vec())),
735        }
736    }
737
738    fn sum(&mut self, roots: &[DecompositionNode]) -> Option<DecompositionNode> {
739        match roots {
740            [] => None,
741            [only] => Some(*only),
742            _ => Some(self.push(GraphOperation::Sum(roots.to_vec()))),
743        }
744    }
745
746    fn push(&mut self, operation: GraphOperation) -> DecompositionNode {
747        let id = DecompositionNode::Generated(self.operations.len());
748        self.operations.push(operation);
749        id
750    }
751}
752
753struct NormalizationGraphBuilder {
754    rebuild: ExprGraphRebuilder<ExprId>,
755}
756
757impl NormalizationGraphBuilder {
758    fn new(graph: &ExprGraph) -> Self {
759        let mut rebuild = ExprGraphRebuilder::with_capacity(graph.nodes().len());
760        for index in 0..graph.nodes().len() {
761            let old_id = ExprId::from_index(index);
762            let node = graph
763                .node(old_id)
764                .expect("normalization graph node exists")
765                .map_children(|child| {
766                    rebuild
767                        .remapped(&child)
768                        .expect("validated expression graphs emit children before parents")
769                });
770            let metadata = graph
771                .metadata(old_id)
772                .expect("normalization graph metadata is complete")
773                .clone();
774            rebuild.emit(old_id, node, metadata);
775        }
776        Self { rebuild }
777    }
778
779    fn source(&self, id: ExprId) -> ExprId {
780        self.rebuild
781            .remapped(&id)
782            .expect("normalization source node was copied")
783    }
784
785    fn constant(&mut self, value: Complex64) -> ExprId {
786        self.emit(ExprNode::from_folded_const(value), ExprSourceKind::Const)
787    }
788
789    fn unary(&mut self, op: UnaryOp, input: ExprId) -> ExprId {
790        self.emit(ExprNode::Unary { op, input }, ExprSourceKind::Unary)
791    }
792
793    fn binary(&mut self, op: BinaryOp, lhs: ExprId, rhs: ExprId) -> ExprId {
794        self.emit(ExprNode::Binary { op, lhs, rhs }, ExprSourceKind::Binary)
795    }
796
797    fn product(&mut self, factors: &[ExprId]) -> ExprId {
798        match factors {
799            [] => self.constant(Complex64::new(1.0, 0.0)),
800            [only] => *only,
801            _ => self.emit(
802                ExprNode::NaryMul {
803                    factors: factors.to_vec(),
804                },
805                ExprSourceKind::Binary,
806            ),
807        }
808    }
809
810    fn sum(&mut self, terms: &[ExprId]) -> ExprId {
811        match terms {
812            [] => self.constant(Complex64::new(0.0, 0.0)),
813            [only] => *only,
814            _ => self.emit(
815                ExprNode::NaryAdd {
816                    terms: terms.to_vec(),
817                },
818                ExprSourceKind::Binary,
819            ),
820        }
821    }
822
823    fn emit(&mut self, node: ExprNode, source: ExprSourceKind) -> ExprId {
824        self.rebuild.emit_anonymous(node, ExprMetadata::new(source))
825    }
826
827    fn finish(self, root: ExprId) -> CompileResult<ExprGraph> {
828        Ok(self.rebuild.finish(root)?)
829    }
830}
831
832#[cfg(test)]
833mod tests {
834    use laddu_expr::{Expr, complex, event_scalar, parameter, polar_complex};
835
836    use super::*;
837
838    fn diagnostics(expression: &Expr) -> NormalizationDiagnostics {
839        CompiledModel::from_expr(expression)
840            .unwrap()
841            .normalization_diagnostics()
842            .clone()
843    }
844
845    fn decomposition(expression: &Expr, budget: usize) -> Decomposition {
846        let graph = expression.to_graph();
847        let facts = GraphFacts::analyze(&graph);
848        NormalizationAnalyzer::new(&graph, &facts, budget).analyze(graph.root())
849    }
850
851    #[test]
852    fn extracts_rectangular_and_polar_coherent_models() {
853        let basis = complex(event_scalar("x"), event_scalar("y"));
854        let rectangular = (complex(parameter!("re"), parameter!("im")) * basis.clone()).norm_sqr();
855        let polar = (polar_complex(parameter!("mag"), parameter!("phase")) * basis).norm_sqr();
856        for model in [&rectangular, &polar] {
857            let diagnostics = diagnostics(model);
858            assert_eq!(
859                diagnostics.strategy(),
860                NormalizationStrategy::Hermitian,
861                "{diagnostics:?}"
862            );
863            assert!(!diagnostics.has_residual());
864            assert!(diagnostics.basis_count() >= 1);
865        }
866    }
867
868    #[test]
869    fn decomposes_separable_and_nonseparable_additive_parts() {
870        let x = event_scalar("x");
871        let scale = Expr::from(parameter!("scale"));
872        let expression = scale.clone() * x.clone() + (scale * x).sin();
873        let diagnostics = diagnostics(&expression);
874        assert_eq!(diagnostics.strategy(), NormalizationStrategy::Hybrid);
875        assert!(diagnostics.has_residual());
876    }
877
878    #[test]
879    fn classifies_binary_operations_before_building_a_graph() {
880        let parameter = Expr::from(parameter!("scale"));
881        let event = event_scalar("x");
882        let mixed = parameter.clone() * event.clone();
883        let cases = [
884            ("add", parameter.clone() + event.clone(), 2, false),
885            ("sub", parameter.clone() - event.clone(), 2, false),
886            ("mul", mixed.clone() * mixed.clone(), 1, false),
887            (
888                "parameter divisor",
889                mixed.clone() / parameter.clone(),
890                1,
891                false,
892            ),
893            ("event divisor", mixed.clone() / event.clone(), 1, false),
894            (
895                "mixed divisor",
896                mixed.clone() / (parameter + event),
897                0,
898                true,
899            ),
900        ];
901
902        for (name, expression, expected_terms, has_failure) in cases {
903            let decomposition = decomposition(&expression, DEFAULT_EXPANSION_BUDGET);
904            assert_eq!(decomposition.terms.len(), expected_terms, "{name}");
905            assert_eq!(!decomposition.failures.is_empty(), has_failure, "{name}");
906        }
907    }
908
909    #[test]
910    fn classifies_unary_operations_before_building_a_graph() {
911        let mixed = Expr::from(parameter!("scale")) * event_scalar("x");
912        let cases = [
913            ("neg", -mixed.clone(), 1, 0),
914            ("conj", mixed.clone().conj(), 1, 0),
915            ("norm_sqr", mixed.clone().norm_sqr(), 1, 1),
916            ("powi", mixed.clone().powi(2), 1, 0),
917            ("real", mixed.clone().real(), 2, 0),
918            ("imag", mixed.clone().imag(), 2, 0),
919            ("sin", mixed.sin(), 0, 0),
920        ];
921
922        for (name, expression, expected_terms, coherent_groups) in cases {
923            let decomposition = decomposition(&expression, DEFAULT_EXPANSION_BUDGET);
924            assert_eq!(decomposition.terms.len(), expected_terms, "{name}");
925            assert_eq!(decomposition.coherent_groups, coherent_groups, "{name}");
926            assert_eq!(
927                decomposition.failures.is_empty(),
928                expected_terms > 0,
929                "{name}"
930            );
931        }
932    }
933
934    #[test]
935    fn expansion_budget_checks_exact_boundaries_and_overflow() {
936        let budget = ExpansionBudget(6);
937        assert_eq!(budget.product_count(2, 3).unwrap(), 6);
938        assert_eq!(budget.packed_triangle_count(3).unwrap(), 6);
939        assert_eq!(budget.ensure(6), Ok(()));
940
941        for result in [
942            budget.product_count(2, 4),
943            budget.packed_triangle_count(4),
944            budget.product_count(usize::MAX, 2),
945            budget.packed_triangle_count(usize::MAX),
946        ] {
947            assert_eq!(
948                result,
949                Err(NormalizationFallbackReason::ExpansionBudgetExceeded { budget: 6 })
950            );
951        }
952    }
953
954    #[test]
955    fn fallback_diagnostics_preserve_the_last_unsupported_root() {
956        let mixed = Expr::from(parameter!("scale")) * event_scalar("x");
957        let expression = mixed.clone().sin() + mixed.cos();
958        let graph = expression.to_graph();
959        let facts = GraphFacts::analyze(&graph);
960        let roots = NormalizationAnalyzer::new(&graph, &facts, DEFAULT_EXPANSION_BUDGET)
961            .additive_roots(graph.root());
962        let decomposition = NormalizationAnalyzer::new(&graph, &facts, DEFAULT_EXPANSION_BUDGET)
963            .analyze(graph.root());
964
965        assert_eq!(decomposition.failures.len(), 2);
966        assert_eq!(
967            decomposition.last_failure(),
968            Some(&NormalizationFallbackReason::UnsupportedMixedOperation {
969                node: roots[1],
970                operation: "nonlinear unary operation",
971            })
972        );
973    }
974}