diff --git a/README.md b/README.md index adb7cfa4..b9ef5019 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,15 @@ The convention is all outgoing with physical charges `t = tau / sqrt(2)`; endpoint-pairing phases belong in the supplied tensor coefficients. Overlap matrices are Gram matrices and must not be multiplied as charge-action matrices. This is color algebra, not a model or numerical amplitude generator. +`ColorConnection::apply_connected` extends an existing exact tensor sum while +preserving its complex coefficients, Laurent powers and root-two normalization. +`GluonSplittingColorPair` supplies daughter-spectator insertions after `g -> q qbar` +and the symmetric-adjoint pair `D_parent . T_spectator`, with +`(D^c)_AB = d^{AcB}` (no factor of `i`). For the physical splitting tensor +`S_ij,B = t^B_ij`, its raw daughter overlaps include `T_R=1/2`; the symmetric +pair is exactly twice their difference, with no extra `T_R`. The parent must be +adjoint and the spectator a distinct colored leg; callers supply fresh daughter +and emission labels. Mixed spin densities and kinematic kernels remain separate. The `color::network` module also lowers exact Spenso SU(3) vertex expressions (`f`, `t`, chains, traces and color metrics) to typed matrix products and contracts their internal indices with `reduce_network`. It retains complete diff --git a/rust/crates/rusticol-core/src/color/mod.rs b/rust/crates/rusticol-core/src/color/mod.rs index c7e3fc82..20c1c1e0 100644 --- a/rust/crates/rusticol-core/src/color/mod.rs +++ b/rust/crates/rusticol-core/src/color/mod.rs @@ -21,6 +21,8 @@ use symbolica::domains::{float::Complex, rational::Rational}; use crate::{RusticolError, RusticolResult}; pub mod network; +mod splitting; +pub use splitting::{GluonSplittingColorPair, SplitDaughterColorTerms}; #[cfg(test)] mod tests; mod trace; @@ -259,12 +261,34 @@ impl ColorConnection { tensor: &ColorTensor, coefficient: ExactColorCoefficient, ) -> RusticolResult { - tensor.validate_legs(&self.initial_legs)?; - let mut terms = vec![TensorTerm { - coefficient, - tensor: tensor.clone(), - nc_power: 0, - }]; + self.apply_connected(&ConnectedColorTensor::new( + self.initial_legs.clone(), + vec![TensorTerm { + coefficient, + tensor: tensor.clone(), + nc_power: 0, + }], + 0, + )?) + } + + /// Apply this ordered connection to an existing exact linear combination. + /// Complex coefficients, Laurent powers and the input's common inverse + /// square root of two normalization are preserved. + pub fn apply_connected( + &self, + tensor: &ConnectedColorTensor, + ) -> RusticolResult { + if tensor.legs != self.initial_legs { + return Err(invalid( + "connection input must have identical typed color legs", + )); + } + let normalization_power = tensor + .normalization_power + .checked_add(self.order) + .ok_or_else(|| invalid("color normalization power overflows"))?; + let mut terms = tensor.terms.clone(); for &emission in &self.emissions { let mut transformed = Vec::new(); for term in terms { @@ -278,7 +302,7 @@ impl ColorConnection { } terms = combine_terms(transformed)?; } - ConnectedColorTensor::new(self.output_legs.clone(), terms, self.order) + ConnectedColorTensor::new(self.output_legs.clone(), terms, normalization_power) } } diff --git a/rust/crates/rusticol-core/src/color/splitting.rs b/rust/crates/rusticol-core/src/color/splitting.rs new file mode 100644 index 00000000..23545ae8 --- /dev/null +++ b/rust/crates/rusticol-core/src/color/splitting.rs @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: 0BSD + +//! Exact daughter-charge insertions through an all-outgoing gluon splitting. + +use super::*; + +/// Raw daughter-spectator insertions, including the splitting norm `T_R=1/2`. +/// +/// Each entry is ``, with +/// `S_ij,B=t^B_ij`. The entries retain directed complex phases and all input +/// normalizations. Dividing either entry by `T_R` multiplies it by two. +#[derive(Clone, Debug, PartialEq)] +pub struct SplitDaughterColorTerms { + pub quark: ColorNcTerms, + pub antiquark: ColorNcTerms, +} + +impl SplitDaughterColorTerms { + /// ``, where + /// `(D^c)_AB=d^{AcB}` has no factor of `i`. + /// + /// The splitting identities are `S† T_q^c S / T_R=(T_adj^c+D^c)/2` + /// and `S† T_qbar^c S / T_R=(T_adj^c-D^c)/2`. Consequently this is + /// exactly `2*(quark-antiquark)`, with no additional `T_R` factor. + pub fn symmetric_adjoint_nc_terms(&self) -> ColorNcTerms { + let mut terms = ColorNcTerms::new(); + for (source, factor) in [(&self.quark, 2), (&self.antiquark, -2)] { + for (&power, coefficient) in source { + add_coefficient( + &mut terms, + power, + scale(coefficient, &Rational::from(factor)), + ); + } + } + terms.retain(|_, coefficient| !is_zero(coefficient)); + terms + } +} + +/// A typed symmetric-adjoint pair obtained from exact splitting connections. +/// +/// The parent is adjoint; the spectator is a distinct colored external leg. +/// Daughter and emitted-adjoint labels must be distinct and fresh relative to +/// every input leg. All legs and charges use the all-outgoing convention. +/// This object supplies only color matrix elements: mixed spin densities and +/// their kinematic splitting kernels remain the caller's responsibility. +#[derive(Clone, Debug)] +pub struct GluonSplittingColorPair { + quark: ColorConnection, + antiquark: ColorConnection, + spectator: ColorConnection, +} + +impl GluonSplittingColorPair { + pub fn new( + initial_legs: Vec, + parent: i64, + spectator: i64, + quark: i64, + antiquark: i64, + emitted: i64, + ) -> RusticolResult { + if parent == spectator { + return Err(invalid("splitting parent and spectator must be distinct")); + } + if !initial_legs + .iter() + .any(|leg| leg.label == spectator && leg.representation != ColorRepresentation::Singlet) + { + return Err(invalid("splitting spectator must be an input colored leg")); + } + let connection = |emitter| { + ColorConnection::new( + initial_legs.clone(), + vec![ + ColorEmission::SplitGluon { + parent, + quark, + antiquark, + }, + ColorEmission::EmitGluon { emitter, emitted }, + ], + ) + }; + Ok(Self { + quark: connection(quark)?, + antiquark: connection(antiquark)?, + spectator: connection(spectator)?, + }) + } + + /// Raw overlaps before division by `T_R`, for arbitrary complex input + /// tensors with matching typed legs. An odd combined inverse-sqrt(2) + /// input normalization cannot be represented by rational Laurent terms and + /// is rejected by the common overlap implementation. + pub fn raw_daughter_nc_terms( + &self, + bra: &ConnectedColorTensor, + ket: &ConnectedColorTensor, + ) -> RusticolResult { + let spectator = self.spectator.apply_connected(ket)?; + Ok(SplitDaughterColorTerms { + quark: self + .quark + .apply_connected(bra)? + .overlap_nc_terms(&spectator)?, + antiquark: self + .antiquark + .apply_connected(bra)? + .overlap_nc_terms(&spectator)?, + }) + } + + /// Full complex Laurent matrix element of `D_parent . T_spectator`. + /// No color averaging, real projection, or extra `T_R` factor is applied. + pub fn symmetric_adjoint_nc_terms( + &self, + bra: &ConnectedColorTensor, + ket: &ConnectedColorTensor, + ) -> RusticolResult { + Ok(self + .raw_daughter_nc_terms(bra, ket)? + .symmetric_adjoint_nc_terms()) + } +} diff --git a/rust/crates/rusticol-core/src/color/tests.rs b/rust/crates/rusticol-core/src/color/tests.rs index 0846651d..eb319a02 100644 --- a/rust/crates/rusticol-core/src/color/tests.rs +++ b/rust/crates/rusticol-core/src/color/tests.rs @@ -309,3 +309,202 @@ fn invalid_connections_and_mismatched_domains_are_errors() { Adjoint ); } + +fn ufo_qqg(value: &str) -> ConnectedColorTensor { + use network::{UfoColorIndex, reduce_network, ufo_color_network}; + let expression = symbolica::atom::Atom::parse(value, "UFO", Default::default()).unwrap(); + reduce_network( + &ufo_color_network(&expression, &[Antifundamental, Fundamental, Adjoint]).unwrap(), + vec![ + leg(1, Fundamental), + leg(2, Antifundamental), + leg(3, Adjoint), + ], + &BTreeMap::from([ + (UfoColorIndex::Leg(1), 1), + (UfoColorIndex::Leg(2), 2), + (UfoColorIndex::Leg(3), 3), + ]), + ) + .unwrap() +} + +#[test] +fn splitting_color_matches_direct_symmetric_ufo_network() { + // This oracle inserts d^{AcB} and t^c directly, independently of the + // splitting connections. The physical-t basis has norm (Nc²-1)/2. + let base = ufo_qqg("T(3,2,1)"); + let direct_d = ufo_qqg("d(3,-1,-2)*T(-1,2,-3)*T(-2,-3,1)"); + let pair = GluonSplittingColorPair::new(base.legs().to_vec(), 3, 1, 4, 5, 6).unwrap(); + let raw = pair.raw_daughter_nc_terms(&base, &base).unwrap(); + assert_eq!(raw.quark, polynomial(&[(1, -1, 4), (-1, 1, 4)])); + assert_eq!( + raw.antiquark, + polynomial(&[(3, -1, 8), (1, 3, 8), (-1, -1, 4)]) + ); + let result = pair.symmetric_adjoint_nc_terms(&base, &base).unwrap(); + assert_eq!(result, base.overlap_nc_terms(&direct_d).unwrap()); + assert_eq!(result, polynomial(&[(3, 1, 4), (1, -5, 4), (-1, 1, 1)])); + assert_eq!(evaluate_nc_terms(&result, 3).unwrap(), real(10, 3)); + assert_eq!(evaluate_nc_terms(&result, 2).unwrap(), real(0, 1)); + let anti_pair = GluonSplittingColorPair::new(base.legs().to_vec(), 3, 2, 4, 5, 6).unwrap(); + assert_eq!( + evaluate_nc_terms( + &anti_pair.symmetric_adjoint_nc_terms(&base, &base).unwrap(), + 3 + ) + .unwrap(), + real(-10, 3) + ); +} + +#[test] +fn splitting_color_normalized_daughter_sum_is_parent_dipole() { + let legs = vec![ + leg(1, Fundamental), + leg(2, Antifundamental), + leg(3, Adjoint), + leg(4, Adjoint), + ]; + let forward = ColorTensor::new(vec![string(1, &[3, 4], 2)], vec![]); + let reverse = ColorTensor::new(vec![string(1, &[4, 3], 2)], vec![]); + let bra = ConnectedColorTensor::new( + legs.clone(), + vec![ + TensorTerm { + coefficient: coefficient(1, 2), + tensor: forward.clone(), + nc_power: 2, + }, + TensorTerm { + coefficient: coefficient(2, -1), + tensor: reverse.clone(), + nc_power: -1, + }, + ], + 3, + ) + .unwrap(); + let ket = ConnectedColorTensor::new( + legs.clone(), + vec![ + TensorTerm { + coefficient: coefficient(3, -1), + tensor: reverse, + nc_power: -3, + }, + TensorTerm { + coefficient: coefficient(-1, 3), + tensor: forward, + nc_power: 1, + }, + ], + 1, + ) + .unwrap(); + for spectator in [1, 2, 4] { + let pair = GluonSplittingColorPair::new(legs.clone(), 3, spectator, 5, 6, 7).unwrap(); + let raw = pair.raw_daughter_nc_terms(&bra, &ket).unwrap(); + let mut normalized_sum = ColorNcTerms::new(); + for terms in [&raw.quark, &raw.antiquark] { + for (&power, value) in terms { + add_coefficient(&mut normalized_sum, power, scale(value, &Rational::from(2))); + } + } + normalized_sum.retain(|_, value| !is_zero(value)); + let parent = ColorConnection::new(legs.clone(), vec![emit(3, 7)]).unwrap(); + let spectator = ColorConnection::new(legs.clone(), vec![emit(spectator, 7)]).unwrap(); + assert_eq!( + normalized_sum, + parent + .apply_connected(&bra) + .unwrap() + .overlap_nc_terms(&spectator.apply_connected(&ket).unwrap()) + .unwrap() + ); + } +} + +#[test] +fn splitting_color_preserves_directed_phases_laurent_powers_and_normalizations() { + let base = ufo_qqg("T(3,2,1)"); + let input = |value, nc_power| { + ConnectedColorTensor::new( + base.legs().to_vec(), + vec![TensorTerm { + coefficient: value, + tensor: base.terms()[0].tensor.clone(), + nc_power, + }], + base.normalization_power() + 2, + ) + .unwrap() + }; + let bra = input(coefficient(1, 2), 2); + let ket = input(coefficient(3, -1), -1); + let pair = GluonSplittingColorPair::new(base.legs().to_vec(), 3, 1, -1, -2, -3).unwrap(); + let result = pair.symmetric_adjoint_nc_terms(&bra, &ket).unwrap(); + // conj(1+2i)*(3-i)=1-7i, Laurent shift 2-1=1, and four extra + // inverse-sqrt(2) powers give a factor 1/4 relative to physical t. + let expected: ColorNcTerms = [(4, 1, 16), (2, -5, 16), (0, 1, 4)] + .into_iter() + .map(|(power, numerator, denominator)| { + ( + power, + Complex::new( + Rational::from((numerator, denominator)), + Rational::from((-7 * numerator, denominator)), + ), + ) + }) + .collect(); + assert_eq!(result, expected); + assert_eq!( + evaluate_nc_terms(&result, 3).unwrap(), + Complex::new(Rational::from((5, 2)), Rational::from((-35, 2)),) + ); + let reversed = pair.symmetric_adjoint_nc_terms(&ket, &bra).unwrap(); + for (&power, value) in &result { + assert_eq!( + reversed.get(&power).unwrap(), + &Complex::new(value.re.clone(), -value.im.clone()) + ); + } +} + +#[test] +fn splitting_color_rejects_invalid_domains_and_normalizations() { + let base = ufo_qqg("T(3,2,1)"); + let legs = base.legs().to_vec(); + for (parent, spectator, q, qbar, emitted) in [ + (1, 2, 4, 5, 6), // parent is fundamental + (3, 3, 4, 5, 6), // coincident parent and spectator + (3, 9, 4, 5, 6), // absent spectator + (3, 1, 3, 5, 6), // a retired label is not fresh + (3, 1, 4, 4, 6), // daughters coincide + (3, 1, 4, 5, 4), // emission reuses a daughter + ] { + assert!( + GluonSplittingColorPair::new(legs.clone(), parent, spectator, q, qbar, emitted) + .is_err() + ); + } + let mut singlet_legs = legs.clone(); + singlet_legs.push(leg(9, Singlet)); + assert!(GluonSplittingColorPair::new(singlet_legs, 3, 9, 4, 5, 6).is_err()); + let pair = GluonSplittingColorPair::new(legs.clone(), 3, 1, 4, 5, 6).unwrap(); + let odd = ConnectedColorTensor::new(legs.clone(), base.terms().to_vec(), 0).unwrap(); + assert!(pair.symmetric_adjoint_nc_terms(&base, &odd).is_err()); + let overflow = + ConnectedColorTensor::new(legs.clone(), base.terms().to_vec(), u32::MAX).unwrap(); + assert!(pair.symmetric_adjoint_nc_terms(&overflow, &base).is_err()); + let (other_legs, other_tensor) = qq(); + let other = image(&other_legs, &other_tensor, vec![]); + assert!(pair.symmetric_adjoint_nc_terms(&base, &other).is_err()); + assert!( + ColorConnection::new(other_legs, vec![]) + .unwrap() + .apply_connected(&base) + .is_err() + ); +}