1use std::collections::{HashSet, VecDeque};
2
3use indexmap::IndexMap;
4use laddu_expr::Expr;
5use serde::{Deserialize, Serialize};
6
7use crate::{
8 LadduPhysicsError, LadduPhysicsResult,
9 generation::{InitialMomentum, MassProposal, ScalarSource, VertexProposal},
10 quantum::{MandelstamChannel, ParticleProperties},
11 vectors::{RealVec3, RealVec4, Vec3, Vec4},
12};
13
14#[derive(Clone, Debug, Serialize, Deserialize)]
15pub struct Channel {
18 name: String,
19 edges: IndexMap<String, Edge>,
20 vertices: IndexMap<String, Vertex>,
21}
22
23impl Channel {
24 pub fn new(name: impl Into<String>) -> Self {
26 Self {
27 name: name.into(),
28 edges: IndexMap::new(),
29 vertices: IndexMap::new(),
30 }
31 }
32
33 pub fn name(&self) -> &str {
35 &self.name
36 }
37
38 pub fn edge(&mut self, name: impl Into<String>) -> EdgeHandle<'_> {
45 let name = name.into();
46 self.edges
47 .entry(name.clone())
48 .or_insert_with(|| Edge::new(name.clone()));
49 EdgeHandle {
50 edge: self.edges.get_mut(&name).expect("edge was just inserted"),
51 }
52 }
53
54 pub fn vertex(&mut self, name: impl Into<String>) -> VertexHandle<'_> {
56 let name = name.into();
57 self.vertices
58 .entry(name.clone())
59 .or_insert_with(|| Vertex::new(name.clone()));
60 VertexHandle {
61 channel: self,
62 name,
63 }
64 }
65
66 pub fn get_vertex(&self, name: &str) -> LadduPhysicsResult<VertexView<'_>> {
73 self.require_vertex(name)?;
74 Ok(VertexView {
75 channel: self,
76 name: name.to_owned(),
77 })
78 }
79
80 pub fn edges(&self) -> impl Iterator<Item = &Edge> {
82 self.edges.values()
83 }
84
85 pub fn vertices(&self) -> impl Iterator<Item = &Vertex> {
87 self.vertices.values()
88 }
89
90 pub fn initial_edges(&self) -> impl Iterator<Item = &Edge> {
92 let consumed = self
93 .vertices
94 .values()
95 .flat_map(|vertex| vertex.incoming.iter().map(String::as_str))
96 .collect::<HashSet<_>>();
97 let produced = self
98 .vertices
99 .values()
100 .flat_map(|vertex| vertex.outgoing.iter().map(String::as_str))
101 .collect::<HashSet<_>>();
102 self.edges
103 .values()
104 .filter(move |edge| consumed.contains(edge.name()) && !produced.contains(edge.name()))
105 }
106
107 pub fn validate(&self) -> LadduPhysicsResult<()> {
117 for name in self.vertices.keys() {
118 self.validate_vertex(name)?;
119 }
120 for edge in self.initial_edges() {
121 edge.initial_momentum
122 .as_ref()
123 .ok_or_else(|| {
124 LadduPhysicsError::invalid_relation(format!(
125 "initial edge `{}` has no momentum source",
126 edge.name()
127 ))
128 })?
129 .validate(edge.name(), edge.properties())?;
130 }
131 Ok(())
132 }
133
134 pub fn properties(&self, edge: &str) -> LadduPhysicsResult<Option<&ParticleProperties>> {
140 Ok(self.require_edge(edge)?.properties.as_ref())
141 }
142
143 pub fn particle(&self, edge: &str) -> LadduPhysicsResult<&ParticleProperties> {
150 self.properties(edge)?.ok_or_else(|| {
151 LadduPhysicsError::invalid_relation(format!("edge `{edge}` has no particle properties"))
152 })
153 }
154
155 pub fn p4(&self, edge: &str) -> LadduPhysicsResult<Vec4> {
165 self.resolve_p4(edge, &mut Vec::new())
166 }
167
168 pub fn vec3(&self, edge: &str) -> LadduPhysicsResult<Vec3> {
175 Ok(self.p4(edge)?.vec3())
176 }
177
178 pub fn mass(&self, edge: &str) -> LadduPhysicsResult<Expr> {
185 Ok(self.p4(edge)?.m())
186 }
187
188 pub fn s(&self, edge: &str) -> LadduPhysicsResult<Expr> {
195 Ok(self.p4(edge)?.m2())
196 }
197
198 fn require_edge(&self, name: &str) -> LadduPhysicsResult<&Edge> {
199 self.edges
200 .get(name)
201 .ok_or_else(|| LadduPhysicsError::invalid_relation(format!("unknown edge `{name}`")))
202 }
203
204 fn require_vertex(&self, name: &str) -> LadduPhysicsResult<&Vertex> {
205 self.vertices
206 .get(name)
207 .ok_or_else(|| LadduPhysicsError::invalid_relation(format!("unknown vertex `{name}`")))
208 }
209
210 fn resolve_p4(&self, edge: &str, stack: &mut Vec<String>) -> LadduPhysicsResult<Vec4> {
211 let edge_def = self.require_edge(edge)?;
212 if let Some(p4) = &edge_def.p4 {
213 return Ok(p4.clone());
214 }
215 if stack.iter().any(|candidate| candidate == edge) {
216 return Err(LadduPhysicsError::invalid_relation(format!(
217 "cyclic p4 inference involving `{edge}`"
218 )));
219 }
220 stack.push(edge.to_owned());
221
222 for priority in [
223 InferencePriority::ParentFromDaughters,
224 InferencePriority::ChildFromParents,
225 InferencePriority::AnySingleMissing,
226 ] {
227 let candidates = self.inference_candidates(edge, priority, stack)?;
228 if candidates.len() == 1 {
229 stack.pop();
230 return Ok(candidates[0].clone());
231 }
232 if candidates.len() > 1 {
233 stack.pop();
234 return Err(LadduPhysicsError::invalid_relation(format!(
235 "ambiguous p4 inference for edge `{edge}`"
236 )));
237 }
238 }
239
240 stack.pop();
241 Err(LadduPhysicsError::invalid_relation(format!(
242 "edge `{edge}` has no p4 and could not be inferred"
243 )))
244 }
245
246 fn inference_candidates(
247 &self,
248 edge: &str,
249 priority: InferencePriority,
250 stack: &mut Vec<String>,
251 ) -> LadduPhysicsResult<Vec<Vec4>> {
252 let mut out = Vec::new();
253 for vertex in self.vertices.values() {
254 if !vertex.contains(edge) || !vertex.matches_priority(edge, priority) {
255 continue;
256 }
257 if let Some(p4) = self.infer_from_vertex(edge, vertex, stack)? {
258 out.push(p4);
259 }
260 }
261 Ok(out)
262 }
263
264 fn infer_from_vertex(
265 &self,
266 edge: &str,
267 vertex: &Vertex,
268 stack: &mut Vec<String>,
269 ) -> LadduPhysicsResult<Option<Vec4>> {
270 if !vertex.contains(edge) {
271 return Ok(None);
272 }
273
274 let incoming = match self.sum_known_except(&vertex.incoming, edge, stack) {
275 Ok(incoming) => incoming,
276 Err(err) if is_unresolved_inference(&err) => return Ok(None),
277 Err(err) => return Err(err),
278 };
279 let outgoing = match self.sum_known_except(&vertex.outgoing, edge, stack) {
280 Ok(outgoing) => outgoing,
281 Err(err) if is_unresolved_inference(&err) => return Ok(None),
282 Err(err) => return Err(err),
283 };
284 if vertex.incoming.iter().any(|candidate| candidate == edge) {
285 Ok(Some(outgoing - incoming))
286 } else {
287 Ok(Some(incoming - outgoing))
288 }
289 }
290
291 fn sum_known_except(
292 &self,
293 edges: &[String],
294 except: &str,
295 stack: &mut Vec<String>,
296 ) -> LadduPhysicsResult<Vec4> {
297 let mut sum = Vec4::new(0.0, 0.0, 0.0, 0.0);
298 for edge in edges {
299 if edge == except {
300 continue;
301 }
302 sum = sum + self.resolve_p4(edge, stack)?;
303 }
304 Ok(sum)
305 }
306
307 fn frame_path_to_vertex(&self, target: &str) -> LadduPhysicsResult<FramePath> {
308 self.require_vertex(target)?;
309 let roots = self.root_vertices();
310 if roots.is_empty() {
311 return Err(LadduPhysicsError::invalid_relation(format!(
312 "could not find a root vertex for `{target}`"
313 )));
314 }
315
316 let mut matches = Vec::new();
317 for root in roots {
318 if let Some(path) = self.frame_path_from_root(&root, target) {
319 matches.push(FramePath { root, edges: path });
320 }
321 }
322
323 match matches.len() {
324 0 => Err(LadduPhysicsError::invalid_relation(format!(
325 "vertex `{target}` is not reachable from a root vertex"
326 ))),
327 1 => Ok(matches.remove(0)),
328 _ => Err(LadduPhysicsError::invalid_relation(format!(
329 "ambiguous frame path to vertex `{target}`"
330 ))),
331 }
332 }
333
334 fn root_vertices(&self) -> Vec<String> {
335 let produced_edges = self
336 .vertices
337 .values()
338 .flat_map(|vertex| vertex.outgoing.iter())
339 .collect::<HashSet<_>>();
340
341 self.vertices
342 .values()
343 .filter(|vertex| {
344 !vertex.incoming.is_empty()
345 && vertex
346 .incoming
347 .iter()
348 .all(|edge| !produced_edges.contains(edge))
349 })
350 .map(|vertex| vertex.name.clone())
351 .collect()
352 }
353
354 fn frame_path_from_root(&self, root: &str, target: &str) -> Option<Vec<String>> {
355 let mut queue = VecDeque::from([(root.to_owned(), Vec::new())]);
356 let mut seen = HashSet::new();
357 let mut matches = Vec::new();
358
359 while let Some((vertex_name, path)) = queue.pop_front() {
360 if !seen.insert(vertex_name.clone()) {
361 continue;
362 }
363 if vertex_name == target {
364 matches.push(path);
365 continue;
366 }
367 let vertex = self.vertices.get(&vertex_name)?;
368 for edge in &vertex.outgoing {
369 for child in self
370 .vertices
371 .values()
372 .filter(|candidate| candidate.incoming.iter().any(|incoming| incoming == edge))
373 {
374 let mut child_path = path.clone();
375 child_path.push(edge.clone());
376 queue.push_back((child.name.clone(), child_path));
377 }
378 }
379 }
380
381 if matches.len() == 1 {
382 matches.pop()
383 } else {
384 None
385 }
386 }
387
388 fn vertex_incoming_p4(&self, vertex: &str) -> LadduPhysicsResult<Vec4> {
389 let vertex = self.require_vertex(vertex)?;
390 if vertex.incoming.is_empty() {
391 return Err(LadduPhysicsError::invalid_relation(format!(
392 "vertex `{}` has no incoming edges",
393 vertex.name()
394 )));
395 }
396 self.sum_known_except(&vertex.incoming, "", &mut Vec::new())
397 }
398}
399
400#[derive(Clone, Debug)]
401struct FramePath {
402 root: String,
403 edges: Vec<String>,
404}
405
406#[derive(Clone, Debug, Serialize, Deserialize)]
407pub struct Edge {
409 name: String,
410 p4: Option<Vec4>,
411 properties: Option<ParticleProperties>,
412 output: bool,
413 mass_proposal: Option<MassProposal>,
414 initial_momentum: Option<InitialMomentum>,
415}
416
417impl Edge {
418 fn new(name: String) -> Self {
419 Self {
420 name,
421 p4: None,
422 properties: None,
423 output: false,
424 mass_proposal: None,
425 initial_momentum: None,
426 }
427 }
428
429 pub fn name(&self) -> &str {
431 &self.name
432 }
433
434 pub fn has_explicit_p4(&self) -> bool {
436 self.p4.is_some()
437 }
438
439 pub fn properties(&self) -> Option<&ParticleProperties> {
441 self.properties.as_ref()
442 }
443
444 pub fn is_output(&self) -> bool {
446 self.output
447 }
448
449 pub fn mass_proposal(&self) -> Option<&MassProposal> {
451 self.mass_proposal.as_ref()
452 }
453
454 pub fn initial_momentum(&self) -> Option<&InitialMomentum> {
456 self.initial_momentum.as_ref()
457 }
458}
459
460pub struct EdgeHandle<'a> {
462 edge: &'a mut Edge,
463}
464
465impl EdgeHandle<'_> {
466 pub fn edge_mut(&mut self) -> &mut Edge {
468 self.edge
469 }
470 pub fn p4(&mut self, p4: impl Into<Vec4>) -> &mut Self {
472 self.edge.p4 = Some(p4.into());
473 self
474 }
475
476 pub fn properties(&mut self, properties: &ParticleProperties) -> &mut Self {
478 self.edge.properties = Some(properties.clone());
479 self
480 }
481
482 pub fn output(&mut self) -> &mut Self {
484 self.edge.output = true;
485 self
486 }
487
488 pub fn generated_only(&mut self) -> &mut Self {
490 self.edge.output = false;
491 self
492 }
493
494 pub fn mass_proposal(&mut self, proposal: impl Into<MassProposal>) -> &mut Self {
496 self.edge.mass_proposal = Some(proposal.into());
497 self
498 }
499
500 pub fn initial(&mut self, source: InitialMomentum) -> &mut Self {
502 self.edge.initial_momentum = Some(source);
503 self
504 }
505
506 pub fn initial_p4(&mut self, p4: RealVec4) -> &mut Self {
508 self.initial(InitialMomentum::p4(p4))
509 }
510
511 pub fn initial_momentum(&mut self, momentum: RealVec3) -> &mut Self {
513 self.initial(InitialMomentum::momentum(momentum))
514 }
515
516 pub fn initial_energy_direction(&mut self, energy: f64, direction: RealVec3) -> &mut Self {
518 self.initial(InitialMomentum::energy_direction(energy, direction))
519 }
520
521 pub fn initial_energy_source_direction(
523 &mut self,
524 energy: ScalarSource,
525 direction: RealVec3,
526 ) -> &mut Self {
527 self.initial(InitialMomentum::energy_source_direction(energy, direction))
528 }
529}
530
531#[derive(Clone, Debug, Serialize, Deserialize)]
532pub struct Vertex {
534 name: String,
535 incoming: Vec<String>,
536 outgoing: Vec<String>,
537 generation: Option<VertexProposal>,
538}
539
540impl Vertex {
541 fn new(name: String) -> Self {
542 Self {
543 name,
544 incoming: Vec::new(),
545 outgoing: Vec::new(),
546 generation: None,
547 }
548 }
549
550 pub fn name(&self) -> &str {
552 &self.name
553 }
554
555 pub fn incoming(&self) -> &[String] {
557 &self.incoming
558 }
559
560 pub fn outgoing(&self) -> &[String] {
562 &self.outgoing
563 }
564
565 pub fn generation(&self) -> Option<&VertexProposal> {
567 self.generation.as_ref()
568 }
569
570 fn contains(&self, edge: &str) -> bool {
571 self.incoming.iter().any(|candidate| candidate == edge)
572 || self.outgoing.iter().any(|candidate| candidate == edge)
573 }
574
575 fn all_edges(&self) -> impl Iterator<Item = &String> {
576 self.incoming.iter().chain(&self.outgoing)
577 }
578
579 fn matches_priority(&self, edge: &str, priority: InferencePriority) -> bool {
580 match priority {
581 InferencePriority::ParentFromDaughters => {
582 self.incoming.len() == 1 && self.incoming.iter().any(|candidate| candidate == edge)
583 }
584 InferencePriority::ChildFromParents => {
585 self.outgoing.len() == 1 && self.outgoing.iter().any(|candidate| candidate == edge)
586 }
587 InferencePriority::AnySingleMissing => true,
588 }
589 }
590}
591
592pub struct VertexHandle<'a> {
594 channel: &'a mut Channel,
595 name: String,
596}
597
598impl VertexHandle<'_> {
599 pub fn generation(&mut self, proposal: impl Into<VertexProposal>) -> &mut Self {
606 self.channel
607 .vertices
608 .get_mut(&self.name)
609 .expect("vertex handle references an existing vertex")
610 .generation = Some(proposal.into());
611 self
612 }
613
614 pub fn incoming(&mut self, edges: impl IntoIterator<Item = impl AsRef<str>>) -> &mut Self {
621 let edges = edges
622 .into_iter()
623 .map(|edge| edge.as_ref().to_owned())
624 .collect::<Vec<_>>();
625 self.channel
626 .vertices
627 .get_mut(&self.name)
628 .expect("vertex handle references an existing vertex")
629 .incoming = edges;
630 self
631 }
632
633 pub fn outgoing(&mut self, edges: impl IntoIterator<Item = impl AsRef<str>>) -> &mut Self {
640 let edges = edges
641 .into_iter()
642 .map(|edge| edge.as_ref().to_owned())
643 .collect::<Vec<_>>();
644 self.channel
645 .vertices
646 .get_mut(&self.name)
647 .expect("vertex handle references an existing vertex")
648 .outgoing = edges;
649 self
650 }
651
652 pub fn validate(&self) -> LadduPhysicsResult<()> {
659 self.channel.validate_vertex(&self.name)
660 }
661}
662
663#[derive(Clone, Copy, Debug, Eq, PartialEq)]
664enum InferencePriority {
665 ParentFromDaughters,
666 ChildFromParents,
667 AnySingleMissing,
668}
669
670#[derive(Clone, Debug)]
671pub struct VertexView<'a> {
673 channel: &'a Channel,
674 name: String,
675}
676
677impl<'a> VertexView<'a> {
678 pub fn vertex(&self) -> &'a Vertex {
685 self.channel
686 .vertices
687 .get(&self.name)
688 .expect("vertex view references an existing vertex")
689 }
690
691 pub fn p4(&self, edge: &str) -> LadduPhysicsResult<Vec4> {
698 let frame_path = self.channel.frame_path_to_vertex(&self.name)?;
699 let overall = self.channel.vertex_incoming_p4(&frame_path.root)?;
700 let mut boosts = vec![-&overall.beta()];
701 let mut p4 = self.channel.p4(edge)?;
702 for beta in &boosts {
703 p4 = p4.boost(beta);
704 }
705
706 for frame_edge in frame_path.edges {
707 let mut frame_p4 = self.channel.p4(&frame_edge)?;
708 for beta in &boosts {
709 frame_p4 = frame_p4.boost(beta);
710 }
711 let beta = -&frame_p4.beta();
712 p4 = p4.boost(&beta);
713 boosts.push(beta);
714 }
715
716 Ok(p4)
717 }
718
719 pub fn vec3(&self, edge: &str) -> LadduPhysicsResult<Vec3> {
726 Ok(self.p4(edge)?.vec3())
727 }
728
729 pub fn costheta(&self, edge: &str, z_axis: Vec3, _y_hint: Vec3) -> LadduPhysicsResult<Expr> {
736 let p = self.vec3(edge)?;
737 let z = z_axis.unit();
738 Ok(p.dot(&z) / p.mag())
739 }
740
741 pub fn theta(&self, edge: &str, z_axis: Vec3, y_hint: Vec3) -> LadduPhysicsResult<Expr> {
748 Ok(self.costheta(edge, z_axis, y_hint)?.acos())
749 }
750
751 pub fn phi(&self, edge: &str, z_axis: Vec3, y_hint: Vec3) -> LadduPhysicsResult<Expr> {
758 let p = self.vec3(edge)?;
759 let z = z_axis.unit();
760 let z_component = &z * &y_hint.dot(&z);
761 let y = (y_hint - z_component).unit();
762 let x = y.cross(&z);
763 Ok(laddu_expr::atan2(p.dot(&y), p.dot(&x)))
764 }
765
766 pub fn mandelstam(&self, channel: MandelstamChannel) -> LadduPhysicsResult<Expr> {
773 let vertex = self.vertex();
774 if vertex.incoming.len() != 2 || vertex.outgoing.len() != 2 {
775 return Err(LadduPhysicsError::invalid_relation(format!(
776 "vertex `{}` is not 2-to-2",
777 vertex.name()
778 )));
779 }
780 let pairs = match channel {
781 MandelstamChannel::S => [
782 (&vertex.incoming[0], &vertex.incoming[1], PairOp::Sum),
783 (&vertex.outgoing[0], &vertex.outgoing[1], PairOp::Sum),
784 ],
785 MandelstamChannel::T => [
786 (&vertex.incoming[0], &vertex.outgoing[0], PairOp::Difference),
787 (&vertex.incoming[1], &vertex.outgoing[1], PairOp::Difference),
788 ],
789 MandelstamChannel::U => [
790 (&vertex.incoming[0], &vertex.outgoing[1], PairOp::Difference),
791 (&vertex.incoming[1], &vertex.outgoing[0], PairOp::Difference),
792 ],
793 };
794 let mut best = None;
795 for (lhs, rhs, op) in pairs {
796 let Ok(expr) = self.pair_mandelstam(lhs, rhs, op) else {
797 continue;
798 };
799 let score = usize::from(self.channel.edge_is_explicit(lhs))
800 + usize::from(self.channel.edge_is_explicit(rhs));
801 if best
802 .as_ref()
803 .is_none_or(|(best_score, _)| score > *best_score)
804 {
805 best = Some((score, expr));
806 }
807 }
808 best.map(|(_, expr)| expr).ok_or_else(|| {
809 LadduPhysicsError::invalid_relation(format!(
810 "could not construct {channel} for vertex `{}`",
811 vertex.name()
812 ))
813 })
814 }
815
816 pub fn s(&self) -> LadduPhysicsResult<Expr> {
823 self.mandelstam(MandelstamChannel::S)
824 }
825
826 pub fn t(&self) -> LadduPhysicsResult<Expr> {
833 self.mandelstam(MandelstamChannel::T)
834 }
835
836 pub fn u(&self) -> LadduPhysicsResult<Expr> {
843 self.mandelstam(MandelstamChannel::U)
844 }
845
846 fn pair_mandelstam(&self, lhs: &str, rhs: &str, op: PairOp) -> LadduPhysicsResult<Expr> {
847 let lhs = self.channel.p4(lhs)?;
848 let rhs = self.channel.p4(rhs)?;
849 Ok(match op {
850 PairOp::Sum => (lhs + rhs).m2(),
851 PairOp::Difference => (lhs - rhs).m2(),
852 })
853 }
854}
855
856#[derive(Clone, Copy, Debug)]
857enum PairOp {
858 Sum,
859 Difference,
860}
861
862fn is_unresolved_inference(err: &LadduPhysicsError) -> bool {
863 matches!(err, LadduPhysicsError::InvalidRelation { relation } if relation.contains("could not be inferred"))
864}
865
866impl Channel {
867 fn validate_vertex(&self, name: &str) -> LadduPhysicsResult<()> {
868 let vertex = self.require_vertex(name)?;
869 let mut seen = HashSet::new();
870 for edge in vertex.all_edges() {
871 self.require_edge(edge)?;
872 if !seen.insert(edge) {
873 return Err(LadduPhysicsError::invalid_relation(format!(
874 "edge `{edge}` appears more than once in vertex `{name}`"
875 )));
876 }
877 }
878 Ok(())
879 }
880
881 fn edge_is_explicit(&self, edge: &str) -> bool {
882 self.edges.get(edge).is_some_and(Edge::has_explicit_p4)
883 }
884}
885
886#[cfg(test)]
887mod tests {
888 use approx::assert_relative_eq;
889 use laddu_compile::CompiledModel;
890 use laddu_runtime::CpuBackend;
891
892 use super::*;
893 use crate::vectors::{RealVec3, RealVec4};
894
895 fn eval(expr: Expr) -> f64 {
896 let model = CompiledModel::from_expr(&expr).unwrap();
897 let params = model.params().default_values();
898 CpuBackend.prepare(&model).evaluate(¶ms).unwrap().re
899 }
900
901 fn p4(px: f64, py: f64, pz: f64, e: f64) -> Vec4 {
902 RealVec4::new(e, px, py, pz).into()
903 }
904
905 fn expr_p4(value: RealVec4) -> Vec4 {
906 value.into()
907 }
908
909 fn assert_vec4_close(actual: Vec4, expected: RealVec4) {
910 assert_relative_eq!(eval(actual.px()), expected.px(), epsilon = 1e-12);
911 assert_relative_eq!(eval(actual.py()), expected.py(), epsilon = 1e-12);
912 assert_relative_eq!(eval(actual.pz()), expected.pz(), epsilon = 1e-12);
913 assert_relative_eq!(eval(actual.e()), expected.e(), epsilon = 1e-12);
914 }
915
916 #[test]
917 fn infers_parent_p4_from_decay_vertex() {
918 let mut channel = Channel::new("KsKs");
919 channel.edge("ks1").p4(p4(0.0, 0.0, 1.0, 1.0));
920 channel.edge("ks2").p4(p4(0.0, 0.0, -1.0, 1.0));
921 channel.edge("x");
922 channel
923 .vertex("x_decay")
924 .incoming(["x"])
925 .outgoing(["ks1", "ks2"])
926 .validate()
927 .unwrap();
928
929 assert_relative_eq!(eval(channel.mass("x").unwrap()), 2.0);
930 }
931
932 #[test]
933 fn infers_single_missing_vertex_edge_from_conservation() {
934 let mut channel = Channel::new("KsKs");
935 channel.edge("beam").p4(p4(0.0, 0.0, 3.0, 5.0));
936 channel.edge("x").p4(p4(0.0, 0.0, 1.0, 3.0));
937 channel.edge("recoil").p4(p4(0.0, 0.0, 2.0, 3.0));
938 channel.edge("target");
939 channel
940 .vertex("production")
941 .incoming(["beam", "target"])
942 .outgoing(["x", "recoil"])
943 .validate()
944 .unwrap();
945
946 let target = channel.p4("target").unwrap();
947 assert_relative_eq!(eval(target.px()), 0.0);
948 assert_relative_eq!(eval(target.py()), 0.0);
949 assert_relative_eq!(eval(target.pz()), 0.0);
950 assert_relative_eq!(eval(target.e()), 1.0);
951 }
952
953 #[test]
954 fn vertex_angles_use_supplied_axes_in_vertex_rest_frame() {
955 let mut channel = Channel::new("simple");
956 channel.edge("x").p4(p4(0.0, 0.0, 0.0, 2.0));
957 channel.edge("a").p4(p4(1.0, 0.0, 0.0, 1.0));
958 channel.edge("b").p4(p4(-1.0, 0.0, 0.0, 1.0));
959 channel
960 .vertex("x_decay")
961 .incoming(["x"])
962 .outgoing(["a", "b"])
963 .validate()
964 .unwrap();
965 let vertex = channel.get_vertex("x_decay").unwrap();
966
967 assert_relative_eq!(
968 eval(vertex.costheta("a", Vec3::z(), Vec3::y()).unwrap()),
969 0.0
970 );
971 assert_relative_eq!(
972 eval(vertex.theta("a", Vec3::z(), Vec3::y()).unwrap()),
973 std::f64::consts::FRAC_PI_2
974 );
975 assert_relative_eq!(eval(vertex.phi("a", Vec3::z(), Vec3::y()).unwrap()), 0.0);
976 }
977
978 #[test]
979 fn vertex_p4_boosts_through_overall_com_and_graph_path() {
980 let beta_to_lab = RealVec3::new(0.0, 0.0, 0.6);
981 let x_com = RealVec4::new(2.0, 0.5, 0.0, 0.0);
982 let recoil_com = RealVec4::new(1.7, -0.5, 0.2, 0.0);
983 let x_lab = x_com.boost(&beta_to_lab);
984 let recoil_lab = recoil_com.boost(&beta_to_lab);
985
986 let mut channel = Channel::new("KsKs");
987 channel.edge("beam").p4(p4(0.0, 0.0, 3.0, 4.0));
988 channel.edge("target").p4(p4(0.0, 0.0, 0.0, 1.0));
989 channel.edge("x").p4(expr_p4(x_lab));
990 channel.edge("recoil").p4(expr_p4(recoil_lab));
991 channel.edge("ks1").p4(p4(0.25, 0.0, 0.0, 1.0));
992 channel.edge("ks2").p4(p4(0.25, 0.0, 0.0, 1.0));
993 channel
994 .vertex("production")
995 .incoming(["beam", "target"])
996 .outgoing(["x", "recoil"])
997 .validate()
998 .unwrap();
999 channel
1000 .vertex("x_decay")
1001 .incoming(["x"])
1002 .outgoing(["ks1", "ks2"])
1003 .validate()
1004 .unwrap();
1005
1006 let expected = recoil_com.boost(&(-x_com.beta().unwrap()));
1007 assert_vec4_close(
1008 channel.get_vertex("x_decay").unwrap().p4("recoil").unwrap(),
1009 expected,
1010 );
1011 assert_vec4_close(
1012 channel.get_vertex("production").unwrap().p4("x").unwrap(),
1013 x_com,
1014 );
1015 }
1016
1017 #[test]
1018 fn mandelstam_helpers_choose_available_explicit_formulae() {
1019 let mut channel = Channel::new("production");
1020 channel.edge("beam").p4(p4(0.0, 0.0, 3.0, 5.0));
1021 channel.edge("x").p4(p4(0.0, 0.0, 1.0, 3.0));
1022 channel.edge("recoil").p4(p4(0.0, 0.0, 2.0, 3.0));
1023 channel.edge("target");
1024 channel
1025 .vertex("production")
1026 .incoming(["beam", "target"])
1027 .outgoing(["x", "recoil"])
1028 .validate()
1029 .unwrap();
1030 let vertex = channel.get_vertex("production").unwrap();
1031
1032 assert_relative_eq!(eval(vertex.s().unwrap()), 27.0);
1033 assert_relative_eq!(eval(vertex.t().unwrap()), 0.0);
1034 assert_relative_eq!(eval(vertex.u().unwrap()), 3.0);
1035 }
1036
1037 #[test]
1038 fn mandelstam_helpers_reject_non_two_to_two_vertices() {
1039 let mut channel = Channel::new("decay");
1040 channel.edge("x").p4(p4(0.0, 0.0, 0.0, 2.0));
1041 channel.edge("a").p4(p4(1.0, 0.0, 0.0, 1.0));
1042 channel.edge("b").p4(p4(-1.0, 0.0, 0.0, 1.0));
1043 channel
1044 .vertex("x_decay")
1045 .incoming(["x"])
1046 .outgoing(["a", "b"])
1047 .validate()
1048 .unwrap();
1049
1050 assert!(channel.get_vertex("x_decay").unwrap().s().is_err());
1051 }
1052
1053 #[test]
1054 fn p4_inference_reports_cycles_and_ambiguity() {
1055 let mut cyclic = Channel::new("cyclic");
1056 cyclic.edge("a");
1057 cyclic.edge("b");
1058 cyclic.vertex("ab").incoming(["a"]).outgoing(["b"]);
1059 cyclic.vertex("ba").incoming(["b"]).outgoing(["a"]);
1060 assert!(matches!(
1061 cyclic.p4("a"),
1062 Err(LadduPhysicsError::InvalidRelation { relation })
1063 if relation.contains("cyclic p4 inference")
1064 ));
1065
1066 let mut ambiguous = Channel::new("ambiguous");
1067 ambiguous.edge("x");
1068 ambiguous.edge("a").p4(p4(0.0, 0.0, 1.0, 1.0));
1069 ambiguous.edge("b").p4(p4(0.0, 0.0, -1.0, 1.0));
1070 ambiguous.edge("c").p4(p4(1.0, 0.0, 0.0, 1.0));
1071 ambiguous.edge("d").p4(p4(-1.0, 0.0, 0.0, 1.0));
1072 ambiguous.vertex("ab").incoming(["x"]).outgoing(["a", "b"]);
1073 ambiguous.vertex("cd").incoming(["x"]).outgoing(["c", "d"]);
1074 assert!(matches!(
1075 ambiguous.p4("x"),
1076 Err(LadduPhysicsError::InvalidRelation { relation })
1077 if relation.contains("ambiguous p4 inference")
1078 ));
1079 }
1080
1081 #[test]
1082 fn edges_store_particle_properties_for_later_hypothesis_generation() {
1083 let mut channel = Channel::new("KsKs");
1084 channel
1085 .edge("ks1")
1086 .properties(&ParticleProperties::unknown().with_name("K_S"));
1087
1088 assert_eq!(
1089 channel.properties("ks1").unwrap().unwrap().name().unwrap(),
1090 "K_S"
1091 );
1092 }
1093
1094 #[test]
1095 fn channel_validation_requires_a_source_for_every_initial_edge() {
1096 let mut channel = Channel::new("decay");
1097 channel
1098 .edge("parent")
1099 .properties(&ParticleProperties::unknown().with_mass(2.0));
1100 channel
1101 .edge("a")
1102 .properties(&ParticleProperties::unknown().with_mass(0.2));
1103 channel
1104 .edge("b")
1105 .properties(&ParticleProperties::unknown().with_mass(0.4));
1106 channel
1107 .vertex("decay")
1108 .incoming(["parent"])
1109 .outgoing(["a", "b"]);
1110
1111 assert!(matches!(
1112 channel.validate(),
1113 Err(LadduPhysicsError::InvalidRelation { relation })
1114 if relation.contains("initial edge `parent` has no momentum source")
1115 ));
1116 }
1117
1118 #[test]
1119 fn channel_validation_accepts_annotated_initial_edges() {
1120 let mut channel = Channel::new("production");
1121 channel
1122 .edge("beam")
1123 .properties(&ParticleProperties::unknown().with_mass(0.0))
1124 .initial_energy_source_direction(ScalarSource::uniform(8.0, 9.0), RealVec3::z());
1125 channel
1126 .edge("target")
1127 .properties(&ParticleProperties::unknown().with_mass(1.0))
1128 .initial_momentum(RealVec3::default());
1129 channel
1130 .edge("x")
1131 .properties(&ParticleProperties::unknown().with_mass(1.5));
1132 channel
1133 .edge("recoil")
1134 .properties(&ParticleProperties::unknown().with_mass(1.0));
1135 channel
1136 .vertex("production")
1137 .incoming(["beam", "target"])
1138 .outgoing(["x", "recoil"]);
1139
1140 channel.validate().unwrap();
1141 }
1142
1143 #[test]
1144 fn channels_round_trip_generation_annotations_through_serde() {
1145 let mut channel = Channel::new("decay");
1146 channel
1147 .edge("parent")
1148 .properties(&ParticleProperties::unknown().with_mass(2.0))
1149 .initial_p4(RealVec4::new(2.0, 0.0, 0.0, 0.0));
1150 channel
1151 .edge("a")
1152 .properties(&ParticleProperties::unknown().with_mass(0.2))
1153 .mass_proposal(0.1..0.3);
1154 channel
1155 .edge("b")
1156 .properties(&ParticleProperties::unknown().with_mass(0.4));
1157 channel
1158 .vertex("decay")
1159 .incoming(["parent"])
1160 .outgoing(["a", "b"])
1161 .generation(VertexProposal::TwoBodyDecay);
1162
1163 let encoded = serde_json::to_string(&channel).unwrap();
1164 let decoded: Channel = serde_json::from_str(&encoded).unwrap();
1165
1166 decoded.validate().unwrap();
1167 assert!(
1168 decoded
1169 .require_vertex("decay")
1170 .unwrap()
1171 .generation()
1172 .is_some()
1173 );
1174 assert!(
1175 decoded
1176 .require_edge("parent")
1177 .unwrap()
1178 .initial_momentum()
1179 .is_some()
1180 );
1181 assert!(matches!(
1182 decoded.require_edge("a").unwrap().mass_proposal(),
1183 Some(MassProposal::Uniform {
1184 low: 0.1,
1185 high: 0.3
1186 })
1187 ));
1188 }
1189}