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#[derive(Copy, Clone, Debug, PartialEq, Eq)]
13pub enum NormalizationStrategy {
14 Hermitian,
16 LinearStatistics,
18 Hybrid,
20 General,
22}
23
24#[derive(Clone, Debug, PartialEq, Eq)]
26pub enum NormalizationFallbackReason {
27 NonScalarIntensity,
29 UnsupportedMixedOperation {
31 node: ExprId,
33 operation: &'static str,
35 },
36 ExpansionBudgetExceeded {
38 budget: usize,
40 },
41}
42
43#[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 pub fn strategy(&self) -> NormalizationStrategy {
56 self.strategy
57 }
58
59 pub fn basis_count(&self) -> usize {
61 self.basis_count
62 }
63
64 pub fn coherent_group_count(&self) -> usize {
66 self.coherent_group_count
67 }
68
69 pub fn has_residual(&self) -> bool {
71 self.has_residual
72 }
73
74 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#[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 pub fn diagnostics(&self) -> &NormalizationDiagnostics {
182 &self.diagnostics
183 }
184
185 pub fn proven_nonnegative(&self) -> bool {
187 self.proven_nonnegative
188 }
189
190 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 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 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}