From aca8cb361ea0f2b1941fd7154d3dde23b6f7621f Mon Sep 17 00:00:00 2001 From: Lukas Simon Date: Wed, 28 Oct 2026 15:36:55 +0100 Subject: [PATCH] Retain source fixed widths in legacy tree and bridge currents --- src/pyamplicol/loops/graph_basis.py | 11 ++++++++++- src/pyamplicol/loops/physical_source.py | 12 ++++++++++++ tests/unit/test_loop_graph_basis.py | 21 +++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/pyamplicol/loops/graph_basis.py b/src/pyamplicol/loops/graph_basis.py index 9093453a2..1262604e6 100644 --- a/src/pyamplicol/loops/graph_basis.py +++ b/src/pyamplicol/loops/graph_basis.py @@ -274,7 +274,12 @@ def _global_factor(graph, builder, current, library): def routed_edge_denominator(edge, library): - """Materialize the dependency's exact propagator denominator locally.""" + """Materialize a tree/bridge denominator with its source fixed width. + + Integral-family denominators remain separate real-mass inputs. The graph + dependency supplies p^2-m^2 but does not include the particle's width. + """ + from symbolica import E from symbolica.community import hepkit as h from symbolica.community import tensor as t @@ -289,6 +294,10 @@ def routed_edge_denominator(edge, library): .to_expression() .replace(h.Symbols.denominator()(edge_id, momentum, mass, value), value) ) + particle = edge.particle + if particle.mass != 0 and particle.width_parameter != "ZERO": + width = symbols.qualified(f"UFO::{particle.width_parameter}") + expression += E("1i") * particle.mass * width return t.TensorExpression(expression).to_tensor(library)[0] diff --git a/src/pyamplicol/loops/physical_source.py b/src/pyamplicol/loops/physical_source.py index 4d98dd314..65b5e2e4d 100644 --- a/src/pyamplicol/loops/physical_source.py +++ b/src/pyamplicol/loops/physical_source.py @@ -161,6 +161,18 @@ def source_bare_program( "physical source masses require declared parameter symbols" ) symbols[symbol.get_name()] = source_parameters[symbol] + if ( + not edge.is_external + and edge.id not in graph.loop_edges + and edge.particle.mass != 0 + and edge.particle.width_parameter != "ZERO" + ): + width = symbol_registry.qualified(f"UFO::{edge.particle.width_parameter}") + if width not in source_parameters: + raise NotImplementedError( + "physical source widths require declared parameter symbols" + ) + symbols[width.get_name()] = source_parameters[width] outputs = tuple((name, 0) for name in sorted(set(symbols.values()))) plan = structure.scalar_bindings(outputs) positions = {name: index for index, (name, _power) in enumerate(outputs)} diff --git a/tests/unit/test_loop_graph_basis.py b/tests/unit/test_loop_graph_basis.py index 8cccf46ad..ce431e4fd 100644 --- a/tests/unit/test_loop_graph_basis.py +++ b/tests/unit/test_loop_graph_basis.py @@ -3,11 +3,14 @@ from __future__ import annotations +from types import SimpleNamespace + import pytest from pyamplicol.loops.graph_basis import ( build_color_loop_recurrence, build_color_tree_recurrence, + routed_edge_denominator, ) from pyamplicol.loops.graphs import build_loop_process from pyamplicol.loops.recurrence import build_open_loop_recurrence @@ -15,6 +18,24 @@ pytestmark = pytest.mark.loop_fast +@pytest.mark.parametrize("width", ["ZERO", "W", "renamed_width"]) +def test_tree_bridge_denominator_retains_symbolic_fixed_width(width): + from symbolica import E + from symbolica.community import tensor as t + + mass = E("UFO::M") + edge = SimpleNamespace( + particle=SimpleNamespace(mass=mass, width_parameter=width), + denominator_expression=lambda **_: t.TensorExpression(E("s") - mass**2), + ) + actual = routed_edge_denominator(edge, t.TensorLibrary.hep_lib_atom()) + expected = E("s") - mass**2 + if width != "ZERO": + expected += E("1i") * mass * E(f"UFO::{width}") + assert (actual - expected).expand() == 0 + assert actual.replace(mass, 0) == E("s") + + @pytest.fixture(scope="module") def native(): s = pytest.importorskip("symbolica")