Skip to main content

laddu_physics/
channel.rs

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)]
15/// A named reaction graph whose edges are particles and whose vertices are
16/// production or decay processes.
17pub struct Channel {
18    name: String,
19    edges: IndexMap<String, Edge>,
20    vertices: IndexMap<String, Vertex>,
21}
22
23impl Channel {
24    /// Construct an empty channel with the supplied name.
25    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    /// Return the channel name.
34    pub fn name(&self) -> &str {
35        &self.name
36    }
37
38    /// Create or edit a named particle edge.
39    ///
40    /// # Panics
41    ///
42    /// Panics only if the internal edge map fails to return an entry that was
43    /// just inserted.
44    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    /// Create or edit a named interaction vertex.
55    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    /// Retrieve a vertex view which evaluates quantities in that vertex's rest frame.
67    ///
68    /// # Errors
69    ///
70    /// Returns [`LadduPhysicsError`] when `name` does not identify a vertex in
71    /// this channel.
72    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    /// Iterate over particle edges in insertion order.
81    pub fn edges(&self) -> impl Iterator<Item = &Edge> {
82        self.edges.values()
83    }
84
85    /// Iterate over interaction vertices in insertion order.
86    pub fn vertices(&self) -> impl Iterator<Item = &Vertex> {
87        self.vertices.values()
88    }
89
90    /// Iterate over edges consumed by the graph but not produced by any vertex.
91    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    /// Validate all channel vertices.
108    ///
109    /// Consumers such as event generators call this automatically.
110    ///
111    /// # Errors
112    ///
113    /// Returns [`LadduPhysicsError`] when a vertex references missing or
114    /// duplicate edges, the reaction graph is inconsistent, or an initial edge
115    /// lacks a valid momentum source.
116    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    /// Return the optional particle properties attached to an edge.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`LadduPhysicsError`] when `edge` is not in this channel.
139    pub fn properties(&self, edge: &str) -> LadduPhysicsResult<Option<&ParticleProperties>> {
140        Ok(self.require_edge(edge)?.properties.as_ref())
141    }
142
143    /// Return the particle definition attached to an edge.
144    ///
145    /// # Errors
146    ///
147    /// Returns [`LadduPhysicsError`] when `edge` is unknown or has no particle
148    /// properties.
149    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    /// Construct the symbolic four-momentum of an edge.
156    ///
157    /// When no explicit expression is attached, momentum conservation is used
158    /// to infer a uniquely missing momentum at a connected vertex.
159    ///
160    /// # Errors
161    ///
162    /// Returns [`LadduPhysicsError`] when the edge is unknown or its momentum
163    /// cannot be uniquely resolved from the channel.
164    pub fn p4(&self, edge: &str) -> LadduPhysicsResult<Vec4> {
165        self.resolve_p4(edge, &mut Vec::new())
166    }
167
168    /// Construct the symbolic three-momentum of an edge.
169    ///
170    /// # Errors
171    ///
172    /// Returns [`LadduPhysicsError`] when the edge's four-momentum cannot be
173    /// resolved.
174    pub fn vec3(&self, edge: &str) -> LadduPhysicsResult<Vec3> {
175        Ok(self.p4(edge)?.vec3())
176    }
177
178    /// Construct the invariant-mass expression for an edge.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`LadduPhysicsError`] when the edge's four-momentum cannot be
183    /// resolved.
184    pub fn mass(&self, edge: &str) -> LadduPhysicsResult<Expr> {
185        Ok(self.p4(edge)?.m())
186    }
187
188    /// Construct the invariant mass squared of an edge.
189    ///
190    /// # Errors
191    ///
192    /// Returns [`LadduPhysicsError`] when the edge's four-momentum cannot be
193    /// resolved.
194    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)]
407/// A particle line in a [`Channel`].
408pub 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    /// Return the edge name.
430    pub fn name(&self) -> &str {
431        &self.name
432    }
433
434    /// Return whether this edge has an explicitly assigned four-momentum.
435    pub fn has_explicit_p4(&self) -> bool {
436        self.p4.is_some()
437    }
438
439    /// Return the particle properties attached to this edge, if present.
440    pub fn properties(&self) -> Option<&ParticleProperties> {
441        self.properties.as_ref()
442    }
443
444    /// Return whether generated events retain this edge as an output particle.
445    pub fn is_output(&self) -> bool {
446        self.output
447    }
448
449    /// Return the mass proposal used to generate this edge, if configured.
450    pub fn mass_proposal(&self) -> Option<&MassProposal> {
451        self.mass_proposal.as_ref()
452    }
453
454    /// Return the initial-state momentum source, if configured.
455    pub fn initial_momentum(&self) -> Option<&InitialMomentum> {
456        self.initial_momentum.as_ref()
457    }
458}
459
460/// Mutable builder handle for an [`Edge`] in a channel.
461pub struct EdgeHandle<'a> {
462    edge: &'a mut Edge,
463}
464
465impl EdgeHandle<'_> {
466    /// Mutable access to the underlying [`Edge`].
467    pub fn edge_mut(&mut self) -> &mut Edge {
468        self.edge
469    }
470    /// Assign an explicit symbolic four-momentum.
471    pub fn p4(&mut self, p4: impl Into<Vec4>) -> &mut Self {
472        self.edge.p4 = Some(p4.into());
473        self
474    }
475
476    /// Attach particle properties.
477    pub fn properties(&mut self, properties: &ParticleProperties) -> &mut Self {
478        self.edge.properties = Some(properties.clone());
479        self
480    }
481
482    /// Retain this edge in generated event output.
483    pub fn output(&mut self) -> &mut Self {
484        self.edge.output = true;
485        self
486    }
487
488    /// Use this edge internally during generation without retaining it.
489    pub fn generated_only(&mut self) -> &mut Self {
490        self.edge.output = false;
491        self
492    }
493
494    /// Assign a generated invariant-mass proposal.
495    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    /// Assign an initial-state momentum source.
501    pub fn initial(&mut self, source: InitialMomentum) -> &mut Self {
502        self.edge.initial_momentum = Some(source);
503        self
504    }
505
506    /// Assign a fixed initial-state four-momentum in `(E, px, py, pz)` order.
507    pub fn initial_p4(&mut self, p4: RealVec4) -> &mut Self {
508        self.initial(InitialMomentum::p4(p4))
509    }
510
511    /// Assign a fixed initial-state three-momentum.
512    pub fn initial_momentum(&mut self, momentum: RealVec3) -> &mut Self {
513        self.initial(InitialMomentum::momentum(momentum))
514    }
515
516    /// Assign a fixed energy and direction for an initial-state edge.
517    pub fn initial_energy_direction(&mut self, energy: f64, direction: RealVec3) -> &mut Self {
518        self.initial(InitialMomentum::energy_direction(energy, direction))
519    }
520
521    /// Assign a sampled energy source and fixed direction for an initial-state edge.
522    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)]
532/// A production, scattering, or decay vertex in a [`Channel`].
533pub 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    /// Return the vertex name.
551    pub fn name(&self) -> &str {
552        &self.name
553    }
554
555    /// Return the names of incoming edges.
556    pub fn incoming(&self) -> &[String] {
557        &self.incoming
558    }
559
560    /// Return the names of outgoing edges.
561    pub fn outgoing(&self) -> &[String] {
562        &self.outgoing
563    }
564
565    /// Return the configured event-generation proposal, if any.
566    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
592/// Mutable builder handle for a [`Vertex`] in a channel.
593pub struct VertexHandle<'a> {
594    channel: &'a mut Channel,
595    name: String,
596}
597
598impl VertexHandle<'_> {
599    /// Assign the proposal used to generate this vertex.
600    ///
601    /// # Panics
602    ///
603    /// Panics if the vertex associated with this live handle is missing from
604    /// the channel.
605    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    /// Replace the vertex's incoming edge list.
615    ///
616    /// # Panics
617    ///
618    /// Panics if the vertex associated with this live handle is missing from
619    /// the channel.
620    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    /// Replace the vertex's outgoing edge list.
634    ///
635    /// # Panics
636    ///
637    /// Panics if the vertex associated with this live handle is missing from
638    /// the channel.
639    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    /// Validate that every referenced edge exists and occurs only once.
653    ///
654    /// # Errors
655    ///
656    /// Returns [`LadduPhysicsError`] when the vertex references an unknown or
657    /// duplicate edge or otherwise violates channel topology.
658    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)]
671/// Read-only access to channel expressions evaluated in a vertex rest frame.
672pub struct VertexView<'a> {
673    channel: &'a Channel,
674    name: String,
675}
676
677impl<'a> VertexView<'a> {
678    /// Return the underlying vertex definition.
679    ///
680    /// # Panics
681    ///
682    /// Panics if the vertex associated with this view is missing from the
683    /// channel.
684    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    /// Construct an edge four-momentum boosted into this vertex's rest frame.
692    ///
693    /// # Errors
694    ///
695    /// Returns [`LadduPhysicsError`] when the edge, frame path, or required
696    /// momenta cannot be resolved.
697    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    /// Construct an edge three-momentum in this vertex's rest frame.
720    ///
721    /// # Errors
722    ///
723    /// Returns [`LadduPhysicsError`] when the edge's rest-frame momentum cannot
724    /// be resolved.
725    pub fn vec3(&self, edge: &str) -> LadduPhysicsResult<Vec3> {
726        Ok(self.p4(edge)?.vec3())
727    }
728
729    /// Construct the cosine of an edge's polar angle about the supplied axis.
730    ///
731    /// # Errors
732    ///
733    /// Returns [`LadduPhysicsError`] when the edge's rest-frame momentum cannot
734    /// be resolved.
735    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    /// Construct an edge's polar angle about the supplied axis.
742    ///
743    /// # Errors
744    ///
745    /// Returns [`LadduPhysicsError`] when the edge's rest-frame momentum cannot
746    /// be resolved.
747    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    /// Construct an edge's azimuthal angle in the supplied coordinate frame.
752    ///
753    /// # Errors
754    ///
755    /// Returns [`LadduPhysicsError`] when the edge's rest-frame momentum cannot
756    /// be resolved.
757    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    /// Construct one of the Mandelstam invariants for a two-to-two vertex.
767    ///
768    /// # Errors
769    ///
770    /// Returns [`LadduPhysicsError`] when this is not a two-to-two vertex or
771    /// the required edge momenta cannot be resolved.
772    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    /// Construct the Mandelstam `s` invariant.
817    ///
818    /// # Errors
819    ///
820    /// Returns [`LadduPhysicsError`] when this is not a two-to-two vertex or
821    /// the required edge momenta cannot be resolved.
822    pub fn s(&self) -> LadduPhysicsResult<Expr> {
823        self.mandelstam(MandelstamChannel::S)
824    }
825
826    /// Construct the Mandelstam `t` invariant.
827    ///
828    /// # Errors
829    ///
830    /// Returns [`LadduPhysicsError`] when this is not a two-to-two vertex or
831    /// the required edge momenta cannot be resolved.
832    pub fn t(&self) -> LadduPhysicsResult<Expr> {
833        self.mandelstam(MandelstamChannel::T)
834    }
835
836    /// Construct the Mandelstam `u` invariant.
837    ///
838    /// # Errors
839    ///
840    /// Returns [`LadduPhysicsError`] when this is not a two-to-two vertex or
841    /// the required edge momenta cannot be resolved.
842    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(&params).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}