Skip to content

Crossing symmetry - #49

Draft
oliviermattelaer wants to merge 73 commits into
mainfrom
claude/fortran-cross-symmetry-3f13f3
Draft

Crossing symmetry#49
oliviermattelaer wants to merge 73 commits into
mainfrom
claude/fortran-cross-symmetry-3f13f3

Conversation

@oliviermattelaer

@oliviermattelaer oliviermattelaer commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Implementation:

  • New default (--no_crossing -> --use_crossing)
  • New default value (use_crossing is ON by default
    • @theoheimel we might want to backtrack that if you are not ready for madspace
  • encoding choice via larger range for FLAV_IDX
    • FLAV_IDX = cross*nflav + flav where flav is the previous flavor index to use
    • cross = flip1*(nexternal+1)+flip2, where flip1 is the position (1 encoding) of the final state particle that is flip with particle 1, and flip2 is the position of the final state particle flip with particle2 (0 means no flipping)
      • encoding fail to capture all possible crossing (see dedicated post below).
  • change NHEL that instead of a lookup table (complicated for crossing) is a radix encoding of the helicity
  • change color index such that it also correspond to an encoding
  • supported in standalone_fortran
    • check_sa.f does contains a demo code (not active) on how to use such feature
    • f2py compatible getting the index from the PDG requested
    • can be check via the command "check crossing p p > j j j"
    • C symmetry of helicity are now checked
  • supported in standalone_cpp
    • check_sa.cc does contains a demo code (not active) on how to use such feature
    • can be check via the command "check crossing p p > j j j --output=standalone_cpp"
    • C symmetry of helicity are now checked
  • supported in standalone_mg7
    • can be check via the command "check crossing p p > j j j --output=standalone_mg7 --simd=sse4 --precision=d"
    • check command works for other simd format
    • change SIMD strategy (seed post below)
    • C symmetry of helicity are now checked
  • moves the T-channel propagator width treatment into ALOHA.
    • Outside the complex-mass scheme, the ALOHA propagator routine now drops the width i·M·Γ for spacelike (P²<0) momenta at runtime (correct t-channel treatment), replacing the old codegen call-rewrite (which only affected madevent's fk_ widths — standalone/cpp never got it)
    • new mg option: zerowidth_tchannel to control if this need to be activated (old madevent trigger is crashing now!)
  • New width treatment zerowidth_external (default True): set the width to zero (internally to the matrix-element) for any particle that appears in the final state)
    • not applied if a particle is marked as offshell (*, syntax supported for standalone)
  • Madevent handling:
    • Crossing within the same group:
      • a single matrix.f
      • multiple auto_dsigX.f
      • metadata generated as in the case of grouping
    • crossing-equivalent MEs in different P directories share one compiled ME via symlink;
      • the dependent keeps its own PDF/PS/leshouche/configs and routes its auto_dsig to the base SMATRIX.
      • Per-flavour event maps: flavour route (DSIG_XGROUTE), CONFIGMAP (multichannel diagram pairing), COLMAP,
        helmap.
      • Shared base .o via crossgroup.mk + a single parallel top-level makefile for all P dirs.
      • hadronic p p also uses cross-group sharing; crossing limitation tag extended to cross-group.
    • Helicity recycling: A cross-group base keeps all configs in the shared optim (exact; still recycles wavefunctions).
      • helicity needs a flipping associated to the permutation
        - [x] C symmetry of helicity are now checked compatible with helicity recycling
      • checked that event helicity are correctly written
        - [x] Block beam polarisation + EVA under crossing (clear crash rather than silent wrong results).
        - [x] handled only for the production side in case of decay chain syntax.
  • New tests/acceptance_tests/test_standalone_cross_symmetry.py (multi-process crossing checks) and additions to test_standalone_madevent_consistency.py.

Validation:

  • p p > j j bit-identical; p p > j j j matches; colour conserved.
  • p p > t t~ j j: completes (was crashing), total within MC error, colour 0/10000 unphysical.
  • p p > w+ w- j j: 22.74 vs 22.91 pb (<1σ), colour 0/2000 unphysical; QCD=0: 0.7315 vs 0.7326 pb.
  • Lepton/photon EP EM > EP EM: now matches reference (0.2675, was ~1.8% low).
  • In progress: check the rate in the lhef of
    • check the rate in the lhef of flavor
    • icheck the rate in the lhef of nitial state
    • check the rate in the lhef of color
    • check the rate in the lhef of helicity (CURRENTLY FAILING --under investigation--)
    • check density matrix computation

Known limitations / follow-ups

  • IOTest references for the changed ALOHA propagator routines (TestAlohaWriter/*) still need regenerating.
  • IOTest for madevent/standalone
  • test_standalone_madevent_consistency.py uses group_subprocesses False, which the non-group madevent exporter doesn't support under the default use_crossing=True — needs --use_crossing=False there (or non-group crossing support).
  • need a full run of the test suite (some reweighting one fail for example)

merge option

  • merge now but set back the default to no_crossing
  • implement in madspace before merging

oliviermattelaer and others added 24 commits July 21, 2026 16:03
… driver

Crossing symmetry lets one generated matrix element evaluate physically
related processes by moving legs between the initial and final state,
encoded in an extended flavor index (id = cross*nmaxflavor + flavor).

- Fortran, C++ and mg7 (madmatrix) exporters emit the crossing tables, the
  per-event momentum/NSF permutation, the crossed denominator (initial
  spin*color x identical-final-state factor) and the sigma-permuted
  good-helicity remap / union.
- `check crossing <proc> [--exporter=standalone|standalone_cpp|standalone_mg7]
  [--simd=...]` matches each reference subprocess against the crossing of the
  collapsed output that reproduces it; help text and auto-completion updated,
  and the "not checked" message now distinguishes its three causes.
- Fix a stack buffer overflow in the mg7 UMAMI SIMD flavor-sorting path: it
  grouped events into SIMD vectors by the raw extended flavor id into arrays
  sized nmaxflavor, overflowing them for any crossing (id >= nmaxflavor) and
  crashing check_sa.exe (SIGABRT/SIGSEGV). Group by the reduced flavor
  instead and write flavor_indices per event, padding unused lanes with a
  valid crossing-0 id so the per-event momentum gather never indexes the
  crossing tables out of range.

Validated: `check p p > j j j --exporter=standalone_mg7` now reports
1161/1161 flavor/crossing subprocesses passing with zero "not checked"
(g d > d d d~ and g d > d u u~ previously crashed); the standalone
cross-symmetry acceptance tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pleteness)

--use_crossing=False mapped to merge_crossing=True, which takes the
"do not generate diagrams" branch in generate_diagrams_multiprocess and
drops the crossed subprocesses from the amplitude list entirely -- an
incomplete partonic sum (verified: p p > j j gave 3 of 8 subprocesses,
missing g q > g q and q q~ > g g). In 3.x merge_crossing defaulted to
False (crossed processes kept as cross_amplitudes); only the old
--no_crossing debug flag dropped them.

Decouple the two: keep crossed subprocesses unconditionally
(merge_crossing=False). --use_crossing now only decides, at the exporter
stage (via self._use_crossing), whether they collapse into a single
extended-FLAV_IDX matrix element (fortran standalone) or are written out
as their own matrix elements (every other output, incl. madevent).
--use_crossing=True generation is byte-identical (already mapped to
False); only --use_crossing=False changes, and it is now a complete
output again -- the fallback the madevent gate already points users to.

Verified: p p > j j --use_crossing=False -> output madevent now yields
all 8 subprocesses (5 P dirs); test_standalone_cross_symmetry 36/36 OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…te-equivalent

Scaffold the crossing-aware evaluation path in the madevent group matrix
template without changing behaviour yet. Add holes to the SMATRIX/MATRIX of
matrix_madevent_group_v4.inc:
 - smatrix_me_cross_decl / smatrix_me_cross_decode  (decode IFLAV -> CROSS,
   FLAV_USE and APPLY_CROSSING to build crossed P/NHEL/IC),
 - me_flav_key token (IFLAV off / FLAV_USE on) at every GOODHEL/NTRY/GET_FLAVOR,
 - me_matrix_args for both MATRIX call sites,
 - smatrix_me_iden_line (crossed denominator branch),
 - me_matrix_ic_param / me_matrix_ic_decl (runtime IC into MATRIX),
 - crossing_routines_me + smatrix_me_goodhel_or.

fill_crossing_replace_dict_me() fills them; its OFF branch reproduces the
historical madevent code and the ON branch is added in slice 2. The madevent
exporter calls it with use_crossing=False for now, so the output is
functionally identical (a p p > j j regeneration differs only by two comment
lines and three blank lines).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…es+links)

Implement the crossing-on fills of fill_crossing_replace_dict_me: decode the
extended FLAV_IDX into (CROSS, FLAV_USE), short-circuit an unusable crossing
(spin*color = 0) to a zero ME, and build the crossed P/NHEL/IC once via
APPLY_CROSSING_TABLE before the helicity loop; MATRIX now takes a runtime IC and
its helas calls read <base_nsf>*IC(i) (use_crossing_ic). The denominator uses
GET_SPINCOL_CROSS*GET_IDENT_CROSS for a genuine crossing and the historical
IDEN*BROKEN_SYM for CROSS=0. The crossing routines are emitted with a per-proc
qualifier (CR<pid>_GET_CROSS_PERM, ...) so the matrix<i>.f of one group do not
clash at link time. The good-helicity filter is bypassed for a crossing-enabled
ME (a crossing permutes/flips helicities; a shared-flavor remap can optimise
this later).

write_matrix_element_v4 computes me_use_crossing (opt use_crossing, group
template only, and the process definition must not pin an s-channel) and drives
use_crossing_ic from it. The madevent crossing gate is unchanged, so this stays
dormant until the per-crossing auto_dsig/metadata land: a normal
--use_crossing=False output is byte-identical, and the ON matrix element
(matrix1_orig) compiles and links (madevent_forhel) for p p > j j.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Helicity recycling (hel_recycle.py) rewrites matrix_orig.f into a single sweep
with fixed, baked-in helicity values. That cannot coexist with the runtime
helicity permutation a crossing applies: one baked helicity set cannot serve
every crossing of a merged matrix element, and the _hel template MATRIX has no
runtime IC to thread. So the group exporter now turns helicity recycling off
when the output is written with crossing on, and compiles the crossing-aware
matrix<i>.f directly.

Verified: with crossing on the group emits matrix<i>.f (no _orig/template pair)
and the default `make madevent` target links for p p > j j; a normal
--use_crossing=False output is unchanged (recycling still on, matrix<i>_orig.f +
template_matrix<i>.f). Gated on opt use_crossing, so it is inert until the
madevent crossing gate is opened.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Helicity recycling IS compatible with crossing, via the same permutation that
lets goodhel be shared. hel_recycle.py rewrites only the helicity argument of an
external call (NHEL(k) -> a baked +-1) and copies the momentum slot and the NSF
argument unchanged, so an IC-aware matrix_orig.f yields IC-aware baked calls
(e.g. IXXXXX(P(0,1),ZERO,+1,+1*IC(1),...)). The recycled MATRIX loops over the
base good-helicity set; feeding it the crossed momenta PUSE and IC evaluates
that set at the crossed kinematics, which by H=sigma(K) is exactly the crossed
matrix element -- no NHEL table nor an explicit helicity remap is needed in the
recycled code.

So: revert the "disable helicity recycling for a crossing output" stopgap, and
give matrix_madevent_group_v4_hel.inc the same crossing holes as the non-hel
template (decode + PUSE/IC build, crossed denominator, IC into MATRIX,
per-proc CR<pid>_ routines), minus NHELUSE. fill_crossing_replace_dict_me fills
the _hel variants (smatrix_hel_cross_decl/decode, hel_matrix_call_args,
hel_matrix_ic_param).

Verified (crossing gate bypassed in a throwaway harness): with recycling ON the
group emits matrix1_orig.f + template_matrix1.f (both crossing-aware, no leftover
holes); madevent_forhel links; and running HelicityRecycler on them produces a
matrix1_optim.f whose baked calls keep *IC(k) and which compiles+links into the
full madevent. OFF (--use_crossing=False) output unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add ProcessExporterFortran.partition_crossing_classes(matrix_elements): group a
subprocess group's matrix elements into crossing-equivalence classes. A member
is tied to a base when the base's crossing enumeration
(compute_crossing_pdg_entries) reproduces the member's crossed physical-PDG
signature -- the same key check_crossing matches on -- returning per member a
(base_index, cross) pair. This is the basis for sharing one matrix<i>.f across a
base and its crossings (the base SMATRIX, driven by an extended FLAV_IDX,
evaluates the whole class), which the exporter wiring will use next.

Pure function, not wired into the exporter yet. Unit test TestCrossingPartition
on p p > j j: within P_gq_gq, g q~ > g q~ is found as a crossing of g q > g q
(2 MEs -> 1 base); within P_qq_qq, q~ q~ > q~ q~ crosses q q > q q (3 -> 2),
while q q~ > q q~ stays its own base. Cross-*group* crossings (g g > q q~ vs
q q~ > g g, separate P dirs) are not merged -- that needs crossing-aware
grouping, a later step. test_standalone_cross_symmetry: 37/37 OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous ME-level partition was wrong: it matched a module by its first
flavor's signature only, so it silently missed genuine crossings (e.g. it
claimed u u~ > u u~ is not a crossing of u u > u u, when it is exactly cross=4).
More fundamentally, the crossing relates flavor combinations, not whole modules:
a flavor-merged module bundles flavors that cross to different bases (the
Q Q~ > Q Q~ module carries both u u~ > u u~, a crossing of Q Q > Q Q, and
d d~ > u u~, which is not), so an ME-level base/cross map cannot be right.

Rework the helper to route per flavor: a module may drop its own matrix<i>.f only
when EVERY one of its flavors is a genuine crossing of some base module's flavor;
otherwise it stays a base. Returns (bases, routing), routing[i] giving one
(base_index, iflav) per flavor -- the base SMATRIX and the 1-based extended
FLAV_IDX (cross*nflav+flav) to reach that flavor. This is what per-flavor
auto_dsig routing (piece 2) needs.

Verified on p p > j j: gq_gq g q~ > g q~ routes to g q > g q (cross 4), 2 MEs ->
1 base; qq_qq q~ q~ > q~ q~ routes to q q > q q (cross 19) and is eliminated,
while Q Q~ > Q Q~ stays a base because of its d d~ > u u~ flavor.
TestCrossingPartition updated; suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verifying the n_flav/NFLAV consistency (needed before the per-flavor auto_dsig
routing) turned up a real M0 bug. fill_crossing_replace_dict_me sized the
extended-FLAV_IDX NFLAV from _build_flav_table_flat() (compute_flavor_masks, the
STANDALONE convention), but the madevent GET_FLAVOR table is sized by
get_external_flavors_with_iden() (== replace_dict 'max_flavor', what
FLAVOR(NEXTERNAL,max_flavor)/MAXFLAVPERPROC use). For a merged group ME the two
differ -- e.g. Q Q~ > g g has iden 1 but masks 4 -- so NFLAV=4 while the FLAVOR
table has a single row, and FLAV_USE=mod(IFLAV-1,4)+1 would index it out of
bounds at run time. Use get_external_flavors_with_iden() for NFLAV.

This also makes the madevent decode agree with compute_crossing_pdg_entries
(which already uses iden), so partition_crossing_classes' routed FLAV_IDX
decodes correctly in the base SMATRIX -- the property the per-flavor routing
(piece 2) relies on.

Verified on p p > j j (crossing on): every matrix<i>_orig.f now has
PARAMETER(NFLAV) equal to its FLAVOR(NEXTERNAL,rows) count (0 mismatches over 8
matrices, incl. the NFLAV=2 module), and forhel still links.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e ME

Wire the crossing merge into the madevent group output. generate_subprocess_
directory now partitions the group's matrix elements (partition_crossing_
classes) and, for a subprocess whose every flavor is a crossing of a base
subprocess's flavor, writes a light router matrix<i>.f instead of the full one:
it keeps GET_FLAVOR<i> (for the PDF) and a router SMATRIX<i> that dispatches per
flavor to the base SMATRIX with the crossed FLAV_IDX, dropping the heavy
MATRIX<i>/helas. The base SMATRIX (M0) crosses the momenta and rebuilds the
crossed denominator, so the routed ANS is the subprocess's matrix element.
get_nhel<i> lives in auto_dsig<i>.f so it is unaffected; the auto_dsig itself is
unchanged (it still calls SMATRIX<i>/GET_FLAVOR<i>).

- New template matrix_madevent_group_router_v4.inc; write_matrix_router_file.
- ProcessExporterFortranMEGroup.supports_crossing = True (the _check_crossing_
  support gate now lets a grouped madevent output through with --use_crossing).
- Helicity recycling is turned off for a merged group for now (the router writes
  a static matrix<i>.f while recycling builds matrix<i>_optim.f at run time and
  the makefile globs one or the other; mixing them needs build-system work).
- gen_infohtml.get_diagram_nb: tolerate a matrix<i>.f with no diagram comment
  (a router shares the base's diagrams).

Validated: p p > j j, low-stat integration. use_crossing=True (merged/router)
gives 5.449e8 +- 2.8e6 pb vs use_crossing=False (complete reference) 5.448e8 +-
2.8e6 pb -- agree within MC error. P1_qq_qq runs 3 subprocesses on 2 heavy MEs,
P1_gq_gq 2 on 1; both link and integrate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… disable)

Let a merged crossing group keep helicity recycling on its heavy base matrix
elements while the light routers stay static. A router now writes
matrix<i>_router.f (not matrix<i>.f): the makefile globs matrix*_router.o into
both the forhel and the optimized binaries, gen_ximprove recycles only
matrix*orig.f so it leaves routers untouched, and a base is recycled exactly as
before (matrix<b>_orig.f + template -> matrix<b>_optim.f). Removes the stopgap
that disabled recycling for a whole merged group.

gen_infohtml.get_diagram_nb also looks for matrix<i>_router.f and returns 0 when
a subprocess has no matrix file of its own (a router shares the base's diagrams).

Validated p p > j j, hel_recycling=True: bases produce matrix<b>_optim.f, the
router matrix3_router.o compiles and links, the full forhel->recycle->madevent
run completes and gives 5.468e8 +- 3.0e6 pb vs the use_crossing=False reference
5.448e8 +- 2.8e6 pb (agree in MC error).

Also validated (earlier commits' merge path): p p > t t~ j j merged == reference
344.8 pb exactly (massive tops), and p p > j j j merged 4.912e7 vs reference
4.923e7 pb (agree in MC error).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TestCrossingUnsupportedOutput asserted that a madevent output refuses a process
generated with crossing. That was true before Track A; the grouped madevent
exporter now supports crossing (supports_crossing=True, the crossing router
shares a base matrix element), so move 'madevent' out of UNSUPPORTED_FORMATS
(matchbox stays) and assert instead that both standalone and madevent accept it.

Verified: TestCrossingUnsupportedOutput 3/3 OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Checking the LHE colour flow (thanks to the reviewer's prompt) turned up a real
bug. A router returns the base's selected colour-flow index ICOL, but events are
written through the router's own leshouche ICOLUP. For p p > j j the two flow
orders happen to coincide, but in general the crossed colour reps decompose the
shared colour basis in a DIFFERENT order (verified: for p p > j j j and
p p > t t~ j j the router flow set equals the crossed base set but is permuted),
so ICOLUP(ICOL) is a valid but WRONG flow -- colour conservation still holds, so
it is invisible in the event, but the parton shower would get the wrong colour
connection.

Add a per-flavor COLMAP in the router: match each crossed base flow (leg j <-
base flow leg perm^-1(j), colour<->anticolour where that leg swapped
initial/final) to the local flow of the same topology, and remap ICOL after the
base call. Emitted only when non-identity. Helicity needs no such map: the
router's own get_nhel already enumerates the crossed helicities in the base's
order (rh[hb] = bh[hb] with the crossed legs sign-flipped, verified for every
router in p p > j j / j j j).

Verified: p p > j j j routers now carry COLMAP /4,3,2,1/ and /3,4,1,2/, compile,
and the full run gives 4.929e7 +- 3.3e5 pb vs reference 4.923e7 +- 4.2e5 (xsec
unchanged, as ICOL only labels the flow) with colour conserved in all events.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Crossing reuses one matrix element across physically distinct (crossed) initial
states, so a per-beam property is ill-defined. Rather than return silently-wrong
numbers, tag the output and raise a clear error at run time.

Generation: in generate_subprocess_directory, append 'crossing' to
proc_characteristic['limitations'] -- but only when crossing is materially
applied (a router is emitted, or a base evaluates a cross>0 flavor). A process
where crossing does nothing (e.g. two unrelated single-process groups) stays
untagged and is never blocked.

Run time: in check_card_consistency (LO branch, next to the dressed_ee check),
when 'crossing' is tagged, raise InvalidCmd if polbeam1/polbeam2 != 0 (beam
polarisation) or pdlabel/pdlabel1/pdlabel2 == 'eva' (EVA luminosity). The message
points to 'generate <process> --use_crossing=False'. The guard sits inside the
existing RunCardLO gate, so NLO cards (no polbeam) are untouched.

Verified: p p > j j tags ['crossing'] (round-trips as a list); the guard blocks
polbeam=-100 and pdlabel=eva, and does NOT block when limitations=[] with the
same settings; --use_crossing=False gives limitations=[], 0 routers, 8 full
matrix.f (falls back to the 3.x per-subprocess matrix elements).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…hoton)

For lepton/photon beams (define EP=e+ a; EM=e- a; generate EP EM > ...) each
initial state lands in its own single-process P directory and the crossings
relate DIFFERENT directories, so the within-group router (Track A) never fires
and every group compiles its own matrix element even when several are crossings
of one base. This adds cross-group reuse: a dependent group symlinks the base
group's crossing-aware SMATRIX and routes to it, keeping its own phase space and
PDFs.

- compute_crossgroup_routing(): flatten every group's matrix elements, run the
  (group-agnostic) crossing partition, and return, per dependent, the base
  directory/proc_id and the crossed FLAV_IDX per flavor. It bows out entirely if
  ANY group has within-group routing -- that is the hadronic p p case, which
  Track A owns and where cross-group merging is a separate deferred optimisation.
  Wired into export_processes before the group loop.
- generate_subprocess_directory: a dependent writes NO matrix element of its own;
  it symlinks the base group's matrix<b>.f (+ template with hel recycling) and
  its auto_dsig routes to the base SMATRIX.
- auto_dsig_v4.inc: backward-compatible holes (default == original) for the
  flavor lookup and the SMATRIX call in BOTH the scalar and vectorised
  (SMATRIX_MULTI) paths, plus per-program-unit declaration holes. A dependent
  inlines its own beams (DSIG_XGFLAV, since it cannot own a GET_FLAVOR without
  clashing with the symlinked base's) and calls SMATRIX<base>(P, DSIG_XGROUTE
  (IFLAV), ...). Verified p p > j j auto_dsig is BYTE-IDENTICAL.

Validated on EP EM > EP EM: check crossing 5/5 at machine precision; the
dependent compiles and links; and a full generate_events (direct e+e- collider)
gives 43.23 +- 0.17 pb (crossing) vs 43.40 +- 0.16 pb (independent) with every
subprocess -- all three cross-group dependents included -- agreeing.

Deferred: sharing the compiled .o across directories (recompilation is still
per-dir), COLMAP/hel-map for the LHE event records, CONFIGMAP for multi-channel
at higher multiplicity, and tagging the 'crossing' limitation for the cross-
group case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…P dirs

Cross-group dependents symlinked the base group's matrix-element SOURCE but each
directory still recompiled it. Reuse the compiled object instead, and build every
P directory in one parallel call.

- write_crossgroup_mk(): each dependent P dir gets a crossgroup.mk (pulled in by
  the shared makefile via `-include crossgroup.mk`, a no-op where absent) whose
  specific rule symlinks matrix<b>_orig.o from the base directory rather than
  recompiling the symlinked source; the base object is built first (the specific
  rule overrides the %.o:%.f pattern, and a recursive rule is the standalone
  ordering fallback). Only matrix<b>_orig.o -- the full matrix element, identical
  across the crossing class -- is shared; matrix<b>_optim.o is NOT, because
  gen_ximprove bakes each subprocess's own good-helicity set into it (it is
  subprocess-specific). Without recycling the single matrix<b>.o is shared.
- write_crossgroup_parallel_makefile(): SubProcesses/makefile_madevent builds
  every P directory with `make -f makefile_madevent -jN` (or `... forhel`),
  delegating each to its own makefile and adding `<dep>/madevent: <base>/madevent`
  ordering so make compiles the base before its dependents while running the rest
  in parallel.

Verified: parallel forhel build (-j4) links all binaries with the dependents'
matrix1_orig.o symlinked to the base (compiled once); a full generate_events
still gives 43.23 pb; p p is unaffected (no crossgroup files, auto_dsig
byte-identical, the -include is a no-op); acceptance suite 37/37 OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…roup

A cross-group dependent evaluates its matrix element through the base group's
SMATRIX, which selects a helicity/colour in the BASE's enumeration; but the event
is written through this subprocess's OWN get_helicities / ICOLUP, so the index
must be translated. Without it the LHE events carried the base's helicity labels
against the dependent's table -- e.g. e+ e- > a a events showed unphysical
same-helicity e+e- pairs (the cross section, summed over helicities, was already
right, so this was invisible until the events were inspected).

- _crossgroup_helmap(dep_me, base_me, cross): 1-based map from a base helicity
  index to the dependent helicity index carrying the physically-crossed
  configuration. The base APPLY_CROSSING permutes NHEL by PERM and flips it by the
  IC sign, so dep config[k] = base_row[PERM[k]]*SGN[k]; the result is a clean
  permutation of the dependent's helicity table.
- _dsig_crossgroup_fills emits per-flavor DSIG_XGHEL (and DSIG_XGCOL for colour,
  which is the identity and thus skipped for colourless lepton/photon) and applies
  selected_hel = DSIG_XGHEL(selected_hel, IFLAV) after the base SMATRIX call in
  both the scalar and vectorised (SMATRIX_MULTI) paths. Emitted only when
  non-identity; the new dsig_smatrix_vec_post hole attaches to the call's closing
  paren so the default is byte-identical.

Verified: e+ e- > a a events now have physical opposite-helicity e+e- (matching
the independent generation, zero same-helicity events); xsec 43.32 pb; p p
auto_dsig byte-identical; acceptance suite 37/37 OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The base group's compiled orig object was already shared, but each dependent
still compiled its own _optim: helicity recycling bakes each subprocess's good-
helicity INDEX set, and under crossing those sets are permutations of each other,
so the base's baked set does not cover a dependent's. The fix mirrors what the
within-group case gets for free (there the base's forhel survey already sees all
the crossed flavors): bake the base optim over the UNION good-hel of the crossing
class. Because G_dep = pi^-1(G_base), the union is derivable from the base's set
plus the crossing permutations -- no dependent surveys needed.

- _crossgroup_base_helperm(base_me, cross): the base->base helicity permutation
  pi[hb] = the base index whose NHEL row equals the crossed row of hb (factored
  _crossed_helicity_configs, shared with _crossgroup_helmap). The dependent for
  that crossing is good at h iff pi[h] is good for the base.
- export persists these per base directory in crossgroup_helunion.dat.
- gen_ximprove reads it and, before hel_recycle, expands the base's good-hel to
  the union.
- crossgroup.mk re-adds the matrix<b>_optim.o symlink. The `-include crossgroup.mk`
  moves to the END of the SubProcesses makefile so its specific rules override the
  $(MATRIX) static-pattern rule for the optim objects (orig already worked from the
  top since orig is not in $(MATRIX)).

Verified on EP EM > EP EM: base optim now bakes the union {3,5,8,9,12,14} (6, was
4); all three dependents' matrix1_orig.o AND matrix1_optim.o are symlinks to the
base (compiled once); xsec 43.32 pb; e+ e- > a a event helicities still physical;
p p matrix+auto_dsig byte-identical with no crossgroup files; acceptance suite
37/37 OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The within-group case already tags the 'crossing' limitation so
check_card_consistency blocks beam polarisation / EVA (a per-beam property is
ill-defined once one matrix element is shared across crossed initial states).
Cross-group (lepton/photon) reuse shares a matrix element across even more
distinct initial states (e+ vs e- vs gamma), so tag it the same way when
compute_crossgroup_routing returns any routing.

Verified: EP EM > EP EM now reports limitations = ['crossing'] and the guard
blocks polbeam/EVA; the non-crossing EP EM > mu+ mu- (two unrelated groups) stays
[]; p p > j j still tags via the within-group path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A cross-group dependent samples its own config's poles in genps, but the base
SMATRIX enhances AMP2(channel) in the BASE's diagram numbering, so the channel
must name the base diagram of the same topology -- otherwise the importance
sampling is mis-paired. This never changes the result (summing the channels gives
the full integral for any bijective pairing), only the convergence, which matters
at higher multiplicity.

- _diagram_leg_subsets(me): per diagram, the set of its internal propagators'
  canonical external-leg subsets (from get_s_and_t_channels; propagators are the
  negative leg numbers, the trailing single-external-leg t-channel one dropped; a
  subset and its complement are one propagator) -- a crossing-covariant topology
  signature.
- _crossgroup_configmap(dep, base, cross): map each dependent diagram's subsets
  through the crossing leg permutation and match to the base diagram with that
  signature; identity if not a clean permutation.
- _dsig_crossgroup_fills emits DSIG_XGCONFIG and wraps the channel as
  DSIG_XGCONFIG(channel, IFLAV) in both the scalar call and the new vec channel
  hole, only when non-identity.

Verified on EP EM > EP EM: a e > a e is the identity (absent), but a e- > a e- and
e+ e- > a a get /2,1/ (identity was mis-pairing the two diagrams there); xsec
43.32 pb; p p matrix+auto_dsig byte-identical; acceptance suite 37/37 OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cross-group reuse is the same engine as the lepton/photon case; it was gated off
for p p only to protect the validated Track A. Relax the gate from all-or-nothing
(bail out if ANY group has within-group routing) to a per-group exclusion: a group
is a cross-group candidate only when every one of its members is a within-group
base (no router). Multi-ME p p groups (the `j`-multiparticle ones with within-group
routers) are skipped and stay Track A; the single-crossing-class groups now also
share across P directories -- e.g. q q~ > g g reuses g g > q q~.

Validated p p > j j: detection routes P1_qq_gg -> P1_gg_qq; the dependent carries
all four maps, including the first non-trivial COLOURED COLMAP (/2,1/), CONFIGMAP
(/1,3,2/), the hel-map and the union optim; its matrix1_orig.o and matrix1_optim.o
symlink the base (compiled once). Cross section 6.974e8 pb, bit-identical to the
--use_crossing=False reference; colour conserved in 1000/1000 events (both);
Track A multi-ME groups byte-identical to before the degate; acceptance suite
37/37 OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Outside the complex-mass scheme a spacelike (t-channel, P^2<0) propagator
has no pole to regulate, so keeping the width i*M*Gamma in the denominator
is spurious (it breaks gauge cancellations). The ALOHA propagator routine
now tests the sign of P^2 at runtime and drops the width for spacelike
momenta, keeping it for timelike (s-channel) ones.

This replaces the old code-generation mechanism (helas_call_writers rewrote
the call to pass ZERO as the width for is_t_channel() wavefunctions). That
was topology-based and matched only madevent's fk_ widths, so standalone /
standalone_cpp (which pass MDL_ widths) never got the treatment; doing it
inside ALOHA applies it consistently to every tree-level backend.

The existing zerowidth_tchannel option (default True) now drives the new
aloha.t_channel_width flag instead of the call-rewrite; True keeps the
proper treatment, False restores the width everywhere. It is a
generation-time (output) option, so setting it at madevent run time now
raises a clear error.

Fortran/C++/Python (and GPU via C++) writers updated; ignored under the
complex-mass scheme. IOTest references for the changed propagator routines
still need regenerating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…icity recycling

Two correctness fixes for cross-group (Track B) crossing, where a dependent
subprocess routes to a base group's symlinked, crossing-aware matrix element.

1. Colour selection. The base SMATRIX picked the event colour flow with
   select_color, which masks the base-order JAMP2 with the DEPENDENT binary's
   ICOLAMP + ICONFIG (mismatched in flow order AND config space); the picked
   flow could be incompatible with the sampled config and addmothers then
   failed to reduce its ICOLUP (a p p > t t~ j j refine crash). The base now
   publishes its per-flow JAMP2 (COMMON/TO_XG_JAMP2) and the dependent permutes
   it into its own flow order and runs its own SELECT_COLOR (the XG_SELCOL
   helper) -- a native colour selection. Emitted only for MEs that are
   cross-group bases, so every other madevent ME stays byte-identical.

2. Helicity recycling. The recycled optim bakes the helicity configs into its
   wavefunction calls and takes no runtime NHEL, but a crossed dependent is
   evaluated with a permuted NHELUSE from APPLY_CROSSING. The full helicity sum
   is invariant under that permutation, but the optim's base good-hel SUBSET is
   not the dependent's, so its partial sum was over the wrong configs (~2% low
   for e.g. photon-lepton Compton crossings). A cross-group base now keeps ALL
   helicity configs in the shared optim (exact for every crossing), still
   recycling wavefunctions.

Validated: p p > t t~ j j completes, total within MC error, colour conserved;
lepton/photon EP EM > EP EM crossing now matches the non-crossing reference
(was ~1.8% low); p p > j j and p p > j j j unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ra NSF sign)

The LHE event helicity (SPINUP, unwgt.f jpart(7,i)=nhel(i)) is the raw NHEL
table value, never NHEL*IC. APPLY_CROSSING permutes NHEL but flips only the
IC/NSF flags, so the correct crossed label for leg k is base_row[PERM[k]] with
NO extra sign: the base MATRIX gives leg k physical helicity
NHEL(k)*IC(k)=base_row[PERM[k]]*SGN[k]*IC_IN[PERM[k]]=base_row[PERM[k]]*IC_dep[k],
matching the dependent's native label iff NHEL_dep[k]=base_row[PERM[k]]. Both
event-helicity maps were multiplying in SGN, double-counting the crossing flip
and flipping every fermion/vector leg that swaps initial<->final. Invisible to
xsec/colour/flavour (all helicity-summed) and to non-chiral p p > j j (where the
per-leg density is (++)==(--)), but wrong for chiral finals: on p p > w+ w- j j
the incoming/outgoing antiquark helicities came out fully flipped (~34 sigma).

Two maps carried the bug, both fixed by using the UNSIGNED crossed config:
- Track B cross-group dependent: _crossgroup_helmap (DSIG_XGHEL). Added a
  `signed` flag to _crossed_helicity_configs; the helmap now passes signed=False.
  _crossgroup_base_helperm keeps signed=True -- its good-hel-set remap IS the
  GHREMAP sigma (table-space, validated by _GOODHEL_PROBE), which needs the sign.
- Track A within-group router: write_matrix_router_file returned the base's
  selected IHEL straight through (COLMAP was applied to ICOL, nothing to IHEL).
  It now applies the same unsigned _crossgroup_helmap to IHEL, mirroring COLMAP.

Validated: p p > w+ w- j j crossing vs --use_crossing=False (10k evt) -> every
quark/antiquark/W/gluon helicity bin consistent (max |pull| 1.2 sigma, was 34);
the discriminating chiral standalone density matrix (u d~ > w+ g crossed to
u g > w+ d, the crossed d 100%-polarised) matches native per-helicity to machine
precision via both the compiled Fortran GET_DENSITY_IDX and the f2py
PY_GET_DENSITY_IDX wrapper (2 new acceptance tests). xsec unchanged (labels do
not affect |M|^2); goodhel/GHREMAP tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@theoheimel

Copy link
Copy Markdown
Contributor

Do we need any new functionality for that on the madspace side or will it just affect the generation of the subprocesses.json file and we will have a few more flavors within each subprocess?

@oliviermattelaer

Copy link
Copy Markdown
Contributor Author

The things that was needed for madevent were multiple map to correctly connect the information

  1. a map over the helicity (madevent use an helicity index which does not map trivially when crossing are happening -- important for the writing of the lhef)
  2. a map over the color (also for the writing of the lhef)
  3. a map for the sde (when the same matrix-element is used between grouping mode). This one I do not think that you will need (it is already contained in your json file)

So yes only subprocesses.json need to be modified. I forwarded your question to claude:

Short answer: It's not just "a few more flavors within each subprocess" — a crossing changes the initial state, so a crossed process can't live inside an existing subprocess's flavor list. But the extra work on the madspace side is small and localized: for the cross-section it's essentially just "pass the extended flavor id to the shared ME"; the phase-space machinery is untouched. The only genuinely new piece is for event generation (the per-crossing helicity/colour/channel index maps), and that's a design choice about where those tables live.

Why it isn't just more flavors in a subprocess. In subprocesses.json each subprocess has a fixed incoming/outgoing, and its flavors are what the exporter calls all_flavors_same_initial — different PDG assignments that share the same initial-state slots, hence the same kinematics/phase space. A crossing by definition moves a leg between the initial and final state, so a crossed process has a different incoming/outgoing (different PDF legs, generally different masses). It violates the same-initial invariant, so it must be its own subprocess entry, not an extra entry in the base's flavors list.

What actually changes in subprocesses.json. You get additional subprocess entries (the crossed ones), each with its own incoming/outgoing/channels/helicities and its own phase space, but pointing at the same me_path (shared compiled ME) plus one new field: the crossing / extended flavor id (cross*nflav + flav). This mirrors the Fortran madevent design exactly — each crossed process keeps its own phase-space setup and only the matrix-element object is shared.

What madspace needs — split by use case:

Cross-section / integration: essentially nothing new. Each crossed subprocess is an ordinary subprocess with its own incoming/outgoing, so phase space, PDFs, cuts, and channel sampling all work as today. The one requirement is that the ME call forwards the extended flavor id so the ME applies its internal crossing. The madmatrix/cudacpp backend already supports this (supports_crossing=True, extended flavor id) and returns the correct crossed |M|² internally — so no new PS math and no correctness work for the xsec.

Event generation (spin/colour in the event record): here there is something. The ME returns the selected helicity, colour flow, and multichannel channel in the base process's enumeration, but the event for the crossed process needs them in the crossed process's own convention. That's three small per-crossing maps — the direct analogues of the Fortran DSIG_XGHEL (helicity), COLMAP (colour), and CONFIGMAP (channel↔diagram). Decision to make: either emit them into subprocesses.json per crossed subprocess and have madspace apply them after the ME call, or bake them into the C++ ME. (Note CONFIGMAP is only an importance-sampling/variance matter, not correctness.)
Net: more subprocess entries (not more flavors per subprocess), each carrying a crossing id and sharing a me_path; zero new phase-space functionality; a trivial ME-call change to forward the extended flavor id for the xsec; and a small, well-defined set of index maps if/when you want correct spin+colour in the generated events.

Confirmed: the madmatrix/cudacpp ME currently returns the selected helicity, colour, and channel/iconfig in the base process's numbering — only the |M|² is crossed. So the crossed-process index maps (helicity/colour/channel, = Fortran DSIG_XGHEL/COLMAP/CONFIGMAP) are not yet handled by the ME. For cross-sections that's irrelevant; for event generation someone has to apply them — cleanest inside the C++ ME (the colour one especially, since it needs the base ME to publish its per-flow jamp2 and re-select in the dependent's colour basis), otherwise emitted into subprocesses.json and applied by madspace.

…decoder

Represent a helicity configuration as a single mixed-radix "canonical code"
over the per-leg helicity states (STATES/NHSTATE), with the last external leg
as the least-significant digit -- matching get_helicity_matrix()'s
itertools.product order, so for a non-polarized process the code of row i is
exactly i (nothing is relabelled). A polarization restriction ({0}/{L}/...)
keeps the full per-leg multiplicity as the radix (helicity 0 / longitudinal
stays a first-class state) and leaves the allowed-code list HELALLOW as the
selected, non-contiguous subset.

matrix_standalone_v4.inc: drop the explicit NHEL config DATA table; add
DECODE_HEL / ENCODE_HEL / FILL_NHEL. PROCESS_NHEL is now materialized at
runtime by decoding HELALLOW (FILL_NHEL, called from SMATRIX / GET_NHEL /
GET_DENSITY_IDX), keeping the density-matrix and f2py interfaces intact. The
external helicity label (USERHEL) is the canonical code.

f2py: the get_nhel_entry accessor (all_matrix.f) now fills the table via
GET_NHEL instead of copying the PROCESS_NHEL common raw -- otherwise an early
caller (reweighting builds its per-config helicity map at init, before any
matrix-element evaluation) would read the not-yet-materialized table as zeros.
GET_NHEL is defined by every standalone matrix.f, so this also links for
split-order processes (which have no FILL_NHEL).

Validated: e+ e- > mu+ mu- and e+ e- > w+{0} w- |M|^2 bit-identical to the old
table; 39/39 standalone cross-symmetry tests (fortran + cpp + mg7; density +
crossing + polarization + f2py); reweight per-event weights bit-identical
(p p > t t~ mass reweight).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
oliviermattelaer and others added 2 commits July 26, 2026 08:07
…LIDATED]

For a crossed event the reported per-event helicity (allselhel) was the
base row index; it should be the CROSSED code. Add a selected_hel_code
helper to the madmatrix (cudacpp) backend that mirrors the fortran
APPLY_CROSSING_TABLE + ENCODE_HEL: permute the base NHEL config by the
crossing slot permutation (no sign flip -- the NSF sign lives in IC) and
re-encode it in the canonical mixed-radix code over the base per-leg
helicity states. cross 0 is the identity (base row+1), so non-crossing
output is byte-identical, and the |M|^2 path is untouched.

*** CAUTION: COMPILE-CHECKED ONLY, NOT VALIDATED AT RUNTIME. ***
The |M|^2 path evaluates each helicity row with the helicity read by
DESTINATION slot (cHel[ihel][s]) and the leg permutation absorbed by the
good-helicity union sum, so whether the SELECTED row needs this perm
digit-permute, an NSF sign flip, both, or nothing must be confirmed by a
cudacpp event-level run that checks the reported crossed-event helicity
against the fortran backend. The generated code says the same. Until that
check exists, do NOT rely on allselhel for crossed events -- the |M|^2 is
correct and unaffected.

Verified here: standalone_mg7 crossing .cc compiles (scalar SIMD), and
`check crossing p p > t t~ j --exporter=standalone_mg7` still passes 4/4
(the |M|^2 crossing is intact); off-path (use_crossing=False) output is
byte-identical (plain fill keeps cGoodHel[ighel]+1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n on the C++ hot path

The cudacpp crossing good-hel filter used a UNION over all crossings
(goodhel_scan_count = ncross*nflav): sigmaKin looped once over that union
with a scalar helicity, and rows that vanish for a given event's crossing
contributed 0. For a chiral process the union is far larger than any one
crossing needs (p p > w+ j: base good = 6, union = 24 -> 4x waste).

Make the helicity PER-LANE (user's design): loop once over the per-crossing
good-hel count; each lane uses its crossing's ighel-th good helicity. The
helicity only ever reaches the EXTERNAL wavefunctions (ixxxxx/oxxxxx/
vxxxxx) -- everything after (get_amp) stays fully SIMD, no masking. So each
event computes exactly its own good helicities.

C++ path (scalar-SIMD AND mixed precision); GPU stays on the union (not
testable here). Gated on use_crossing: a non-crossing build is byte-
identical (plain fills keep cNGoodHel / cGoodHel[ighel] / scalar ihel).

- getGoodHel records a per-cross good-hel table (cGoodHelOfCross,
  cNGoodPerCross, cNGoodMaxCross) during its existing scan.
- sigmaKin C++ loop: for ighel<cNGoodMaxCross, pass ighel to calculate_jamps.
- calculate_jamps gains a host param int _ighel; the crossing external block
  loops each leg's helicity states (2 fermion/massless-vec, 3 massive-vec;
  scalars carry none), reuses the per-lane NSF (icsign) blend, and mask-
  blends by the per-lane helicity. The per-lane row is derived PER PAGE from
  ievt0 (the iParity page start), so mixed precision's second neppV page is
  handled correctly (a page-0-only precompute zeroed it #firstpass).

Validated on cppsse4 (neppV=2, sse2neon on arm64) with a mixed-crossing
driver (different crossings in one SIMD vector): per-event |M|^2 is
BYTE-IDENTICAL to the union build in BOTH pure-double (FPTYPE=d) and mixed
precision (FPTYPE=m, nParity=2). check crossing --exporter=standalone_mg7
--simd=sse4 passes 3/3 (p p > w+ j) and 4/4 (p p > t t~ j). Non-crossing
output byte-identical. See [[mg7-perlane-helicity]].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@oliviermattelaer

Copy link
Copy Markdown
Contributor Author

@Qubitol, I add to take some decision concerning the nice handling of helicity/crossing to be optimised with SIMD, here is the documentation of such strategy. Would be nice if you can comment on this.

Per‑lane helicity: the SIMD strategy for standalone_mg7 crossing

Backend: madmatrix (cudacpp). Commit 4663f1855 on claude/fortran-cross-symmetry-3f13f3.

1. Background — crossing + the good‑helicity filter

Crossing symmetry lets one base matrix element serve all of its crossed
subprocesses. g q → t t̄ q already computes g q̄ → t t̄ q̄, q q̄ → t t̄ g, … —
same diagrams, relabelled legs. In standalone_mg7 this is carried by an
extended flavor id per event:

flavorID = cross * nmaxflavor + flav        // cross ∈ [0, (npar+1)²)

cross = 0 is the identity (base process); the momenta of a crossed event are
permuted into the base slot order per event (_crossing_preamble, deliberately
not vectorised
— see §2), and the swapped legs' NSF sign is flipped.

Orthogonally, cudacpp uses a good‑helicity filter: sigmaKin_getGoodHel
samples a few events and keeps only the helicity rows that contribute; sigmaKin
then loops over that reduced set. The tension this note is about is how the
good‑helicity filter interacts with crossing in a SIMD/GPU execution model
.

2. Why the original design used a union

cudacpp processes events in SIMD lockstep. sigmaKin loops once over a single
shared list cGoodHel, and for each helicity calculate_jamps computes all
events in the vector:

for (int ighel = 0; ighel < cNGoodHel; ighel++) {
  const int ihel = cGoodHel[ighel];              // ONE scalar helicity, all lanes
  calculate_jamps(ihel, …, iflavorVec, …);       // whole SIMD vector
}

The catch: events in one SIMD page share flav_use but may carry different
crossings.
(That is exactly why _crossing_preamble gathers each lane's
permuted momenta in a scalar, per‑event loop.) A per‑crossing good‑helicity
gate therefore cannot be applied at this shared loop level — different lanes
would want different helicity subsets.

The original solution was the union: getGoodHel scans every
ncross * nflav combination and ORs the result, so cGoodHel is the union of
good rows over all crossings. Rows that vanish for a given lane's crossing simply
contribute 0. Correct, and SIMD‑clean — but a superset.

3. The cost of the union

The per‑crossing good sets are permutations of each other (crossing is a
relabelling), so they all have the same size but sit on different rows of
the table. On a chiral/EW process the good set is small yet the crossings scatter
it across the whole table, so the union blows up.

Measured, p p > w+ j, the gQ directory (ncomb = 24):

helicity rows computed / event
any single crossing (base good set) 6
union over all 25 crossings 24 (all of them)

→ the union computes 4× more helicities per event than any single crossing
needs
. (For pure‑QCD processes the base set is already near‑full, so the union
is barely larger and the win is small — the payoff is on EW/chiral matrix
elements.)

4. The strategy: per‑lane helicity

The key observation: helicity only ever enters the external wavefunctions
(ixxxxx / oxxxxx / vxxxxx). Everything after them — internal wavefunctions,
FFV* vertices, colour decomposition (get_amp) — is helicity‑agnostic and
already fully SIMD
.

So we do not need a shared helicity across the vector. Instead:

  • loop once over the per‑crossing good‑hel count (not the union);
  • at iteration ighel, each lane uses its own crossing's ighel‑th good
    helicity — i.e. the helicity becomes a per‑lane quantity;
  • the external wavefunction for a leg is built for each of the leg's helicity
    states and mask‑blended lane‑wise (does this lane want state v?);
  • get_amp then runs full‑width on the blended wavefunctions — no lane masking
    downstream, no union waste.

This is the SIMD‑native realisation of the same shared‑filter idea the Fortran
and standalone_cpp backends express with a per‑event gate (CROSS_GHIDX /
ghfilt): each event ends up evaluating exactly its own good helicities.

External block, per leg (schematic)

{ static const int _st[NS] = { <helicity states of this leg> };   // e.g. {-1,1} or {-1,0,1}
  for (int _vi = 0; _vi < NS; _vi++) {
    const int _v = _st[_vi];
    ROUTINE(xmom, MASS, _v, +nsf, …, aloha_x[0], s);              // build wf at helicity _v, both NSF
    ROUTINE(xmom, MASS, _v, -nsf, …, aloha_x[1], s);
    const fptype_sv _sp = (icsign[s] + 1)*0.5, _sm = (1 - icsign[s])*0.5;   // per-lane NSF blend
    fptype_sv _hm{};
    for (int _ie = 0; _ie < neppV; _ie++) {                       // per-lane helicity mask
      int _hr = (_ighel < 0) ? ihel
              : cGoodHelOfCross[ iflavorVec[ievt0 + _ie] / nmaxflavor ][_ighel];   // (padded to -1)
      ((fptype*)&_hm)[_ie] = (_hr >= 0 && cHel[_hr][s] == _v) ? 1. : 0.;
    }
    // accumulate the lane-selected wavefunction (get_amp is unchanged after this)
    w_sv[s]    += _hm * (_sp*w_x[0]    + _sm*w_x[1]);
    pvec_sv[s] += _hm * (_sp*pvec_x[0] + _sm*pvec_x[1]);
  } }

Scalars (spin 1) carry no helicity, so their block stays the plain NSF blend.
The per‑leg state list comes from _hel_state_values(spin, mass) (fermion →
{-1,1}, massless vector → {-1,1}, massive vector → {-1,0,1}, spin‑2 too).

Data structures

getGoodHel builds a per‑cross table during its existing scan:

static int cGoodHelOfCross[ncross][ncomb];   // good rows per crossing
static int cNGoodPerCross[ncross];           // count per crossing
static int cNGoodMaxCross;                   // loop bound (= base count)

sigmaKin's summation loop then runs ighel < cNGoodMaxCross.

5. The mixed‑precision subtlety (please look here, reviewers)

calculate_jamps processes nParity pages in a loop, and the external block runs
once per page:

for (int iParity = 0; iParity < nParity; ++iParity) {   // nParity = 2 in mixed precision
  const int ievt0 = ievt00 + iParity * neppV;           // this page's first event
  … external block …
}

So the per‑lane helicity must be derived per page from ievt0. A first
implementation precomputed a _ihlane[neppV] array in sigmaKin for page 0 only;
in mixed precision (nParity = 2) that silently zeroed the second page
every second neppV‑page of events came out 0 under divergent crossings.

The fix (and the current design): pass the good‑hel index _ighel to
calculate_jamps, and let the external block resolve _hr from
iflavorVec[ievt0 + _ie] per page. _ighel < 0 falls back to the scalar
ihel (used by getGoodHel's scan and by the non‑crossing path).

6. Scope and gating

  • C++ path only — scalar‑SIMD and mixed precision. GPU
    (MGONGPUCPP_GPUIMPL) stays on the union
    ; it isn't ported and needs a CUDA
    box to validate. (The CUDA async helicity loop is deliberately left on
    cNGoodHel; note it is textually identical to the C++ loop and is
    distinguished by the following line, fptype* hAllJamps vs cxtype_sv jamp_sv.)
  • Fully gated on use_crossing. A non‑crossing build is byte‑identical:
    the OFF fills keep cNGoodHel / cGoodHel[ighel] / the scalar ihel, and the
    _ighel parameter is not even added to calculate_jamps.
  • Codegen lives in madmatrix/model_handling.py
    (get_madmatrix_crossing_dict holes + _crossing_external_block) and two
    templates (process_function_definitions.inc, process_sigmaKin_function.inc).

7. Validation

check_sa -f <flavorID> forces one crossing for the whole run, so all lanes
share a crossing — it cannot exercise the very thing this change is about.
A small mixed‑crossing driver was used: patch the host flvVec to cycle
crossings per event (MG_FLVMIX="c0,c1,…"), so neighbouring lanes in a SIMD
vector carry different crossings.

Results on cppsse4 (neppV = 2, via sse2neon on arm64):

  • per‑event |M|² is byte‑identical to the union build, in both
    FPTYPE=d (pure double) and FPTYPE=m (mixed precision, nParity=2), with
    divergent crossings inside a SIMD vector;
  • check crossing --exporter=standalone_mg7 --simd=sse4: 3/3 (p p > w+ j)
    and 4/4 (p p > t t~ j);
  • --use_crossing=False emits zero per‑lane machinery (historical output).

Work per event drops from 24 → 6 helicity iterations on w+ j (~4× on the
dominant get_amp); even the external‑wavefunction call count nets down
(≈ 192 → 108) despite the per‑state blend, because the iteration count falls
faster than the per‑iteration cost rises.

8. Open items

  • GPU per‑lane port (needs CUDA to validate).
  • The reported per‑event helicity allselhel for crossed events (separate
    commit 1eb6ec73c) is compile‑checked but runtime‑unvalidated — it does not
    affect |M|², only the LHE helicity label; it needs an event‑level cudacpp run
    against the Fortran backend to settle the permute‑vs‑NSF convention.

oliviermattelaer and others added 26 commits July 26, 2026 19:32
Alongside --simd, 'check crossing --exporter=standalone_mg7' now accepts
--precision to pick the madmatrix float type: m mixed (default, = the
madmatrix default), d double, f float. It is passed to the build as
'FPTYPE=<x>'. Ignored by the other crossing backends.

- MG7_PRECISION_CHOICES + validation in _Mg7CrossingBackend (process_checks).
- do_check: parse/validate --precision, completion, help text, default 'm'.

Verified: --precision=d --simd=sse4 builds double and passes p p > w+ j 3/3
(values shift from the mixed-precision run and agree with the crossed
subprocess to ~1e-16); an invalid value is rejected with a clear message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rectory

Like the fortran 'standalone' exporter, 'standalone_mg7' now consumes the
crossed_processes metadata directly instead of reconstructing the crossed
subprocesses: do_output skips the Stage-C reconstruction for standalone_mg7
too, so the crossings collapse into the base directory and are reached
through the base's crossing-aware sigmaKin (extended flavor id).

p p > w+ j: 3 dirs (gQ, gQx, QQx) -> 1 (gQ); p p > t t~ j: 4 -> 2 (gg, gQ).
The base dir keeps the full crossing machinery (spincol_cross / flavorPDGs_
cross / per-lane good-hel), builds, and check crossing --exporter=
standalone_mg7 still passes 3/3 (w+ j). Only the dir count changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ir RAMBO point

With the crossings folded into the base directory, check_sa.exe now also
demonstrates each folded crossed subprocess (like the fortran check_sa.f),
evaluated at the RAMBO point generated for ITS OWN mass permutation.

- Exporter writes crossing_demo.dat in the P* dir: the extended flavor ids of
  the folded crossings (one per asked direction, mirror pairs collapsed),
  matched label-aware against compute_crossing_pdg_entries so a merged _quark
  leg matches any same-sign flavor -- the id is cross*nflav+flav0, i.e. the mg7
  flavor id, so flavorPDG(id,k) gives the crossed PDG. Absent when the ME folds
  no crossings (check_sa then just shows the base flavors).
- check_sa.cc (shared template): after the base flavors, read crossing_demo.dat
  and for each id build the crossed masses (base mass of the leg with the same
  |PDG|), RAMBO those masses, compute |M|^2 via UMAMI at the extended id, and
  print the per-leg PDG + momenta + |M|^2. RANMAR is made re-seedable
  (reset_rng) so every crossing is shown at the first draw for its mass
  permutation -- the point a standalone run of that subprocess would use.

Verified (cppnone/none, FPTYPE=d and =m): p p > w+ j gQ shows flavorID 4
(g d~ > w+ u~) and 20 (d~ u > w+ g); p p > t t~ j gQ shows 5 (g d~ > t t~ d~)
and 30 (d~ d > t t~ g); all matrix elements match check crossing / the
independent standalone values. Non-crossing dirs (gg) get no demo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…op the CROSS-indexed tables

Both halves of the crossed averaging/symmetry denominator in
matrix_standalone_crossing_v4.inc are now recomputed at runtime from small
per-particle tables (one entry per external leg) instead of tables indexed by
the crossing code:

- GET_SPINCOL_CROSS: SPINCOL_CROSS_TABLE(0:NCROSS-1) -> SPINCOL_PART(0:NEXTERNAL-1),
  the per-leg spin*color (conjugation invariant). It decodes CROSS into its two
  transpositions, rejects the invalid (overlapping-swap / out-of-range) codes with
  the same condition get_crossing_permutation uses, builds the slot->leg map, and
  multiplies SPINCOL_PART over the legs the crossing puts in the initial state.

- GET_IDENT_CROSS: BASEPID_CROSS_TABLE + SRC_CROSS_TABLE (both 0:NCROSS*NEXTERNAL-1)
  -> IDS_BASE(0:NEXTERNAL-1) (base leg PDG) + ANTIPID_BASE(0:NEXTERNAL-1) (its charge
  conjugate). It rebuilds the crossing PERM and its initial/final sign flips ICS the
  same way, reads each slot's representative PDG off IDS_BASE (ANTIPID_BASE where the
  leg swapped side), and looks up FLAVOR(PERM(k)); the n! identical-particle count is
  unchanged. The now-unused NCROSS parameter is dropped there.

For a 2->3 process this is 36 + 2*180 = 396 CROSS-indexed integers down to 3*5 = 15
per-leg integers, and no CROSS-dimensioned array survives.

compute_crossing_tables now also returns spincol_part/ids_base/antipid_base, with a
generation-time assertion that both reconstructions reproduce the old spincol/basepid/
source for every applicable crossing (generation fails if they ever drift).
get_iden_cross_lines emits the three per-leg DATA tables. The C++/cudacpp exporters
still read the unchanged basepid/source/spincol, so this change is Fortran-only and the
shared template also covers the madevent-group crossing routine.

Validated: test_standalone_cross_symmetry TestStandaloneCrossSymmetry 20/20 (incl. the
IDEN 36->192 identical-quark crossing and every density-matrix test); full suite
otherwise green except one pre-existing, unrelated cudacpp failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ultiplying by 0

The per-event crossing-aware denominator zeroed an inapplicable crossing
(spincol_cross==0: out of range or overlapping swap) by setting its lane factor
to 0 and multiplying it into MEs_sv. But an invalid crossing's unphysical
momentum relabelling can make that lane's |M|^2 a NaN, and nan*0 = nan, so the
matrix element came out NaN rather than 0.

Apply the denominator per lane directly onto MEs_sv and ASSIGN 0 for an invalid
crossing (rather than multiply), which is NaN-safe. Valid lanes still multiply,
so their result is unchanged.

Fixes test_standalone_cross_symmetry TestStandaloneMg7CrossSymmetry
test_invalid_overlapping_swap_returns_zero (previously "no matrix element
parsed" because the driver printed "nan"); the per-event mixed-crossing test
stays byte-identical. Class now 5/5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ntime, drop the CROSS-indexed tables

The C++ backends stored the crossing as several tables indexed by the crossing
code (spincol_cross[ncross], basepid_cross/src_cross[ncross*nexternal],
cross_perm/cross_ic[ncross*nexternal]; standalone_mg7 also had
flavorPDGs_cross[ncross*nflav*npar] and an xhel_perm[ncross*npar]). The fortran
standalone stores none of these -- GET_CROSS_PERM decodes the slot permutation
and NSF signs from the crossing code at runtime, and the denominator / crossed
PDGs are rebuilt from small per-leg tables. Bring both C++ backends onto the
same scheme.

New runtime decode cross_perm_ic(cross, perm, ic) mirrors GET_CROSS_PERM/
SWAP_LEGS (identity plus the crossing's two transpositions; returns false and
leaves perm/ic the identity for an inapplicable code, so a momentum gather never
reads out of range). From it:

- spincol_cross(cross): product of the per-leg spin*color (spincol_part, one
  entry per leg) over the initial-state slots; 0 for an invalid crossing.
- ident_cross(cross, flavor): identical-final-state n! factor from ids_base /
  antipid_base (per-leg base PDG and its charge conjugate) plus the actual
  flavor at slot perm[k].
- standalone_mg7 flavorPDG: crossed signed PDG rebuilt from the base per-(flavor,
  leg) PDG tables and the runtime perm/ic, like GET_PDG_FOR_FLAVOR -- no
  per-crossing PDG table.
- standalone_mg7 per-event momentum permutation and selected_hel_code decode the
  crossing per event instead of reading cross_perm/cross_ic / xhel_perm.

cross_perm_ic is __host__ __device__ (host flavorPDG and the device paths both
call it). No cross-indexed crossing table survives in either generated source;
for a 2->3 process standalone_mg7 drops from O(ncross*nflav*npar) integers to a
handful of per-leg tables. use_crossing=False output is unchanged.

Also fixes a latent overflow: _build_flav_pdg_tables can return more flavor rows
than nmaxflavor, so base_pdg is filled from the first nflav rows only.

Validated: test_standalone_cross_symmetry 43/43 (standalone_cpp 4/4,
standalone_mg7 5/5 incl. per-event mixed-crossing SIMD and the overlapping-swap
zero); p p > w+ j standalone_mg7 builds and its crossing demo prints the correct
crossed PDGs (g d~ > w+ u~, d~ u > w+ g) and matrix elements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… propagator

A particle that appears as an external (initial/final) state is an on-shell
asymptotic state, so an internal propagator of the same field must not carry the
i*M*Gamma resonance term in its denominator -- e.g. the s/u-channel top in
t a > t a. This mirrors the existing T-channel width drop, but is keyed on the
particle being external rather than on the propagator momentum being spacelike.

New settable option `zerowidth_external` (default True, like zerowidth_tchannel):
a code-generation option applied per matrix element at output time via
HelasMatrixElement.set_onshell_particles_width_to_zero(), which flags the
relevant internal-propagator wavefunctions with a runtime `onshell_zero_width`
annotation. HelasWavefunction.get('width') then returns ZERO for them, so every
UFO backend (standalone, madevent, standalone_cpp, standalone_mg7) reads a single
source for the propagator's W argument; in the complex-mass scheme the same ZERO
makes that propagator use the real mass. External legs take no width argument and
are untouched; `set zerowidth_external False` restores the widths.

Validated on t a > t a across all four backends (MDL_WT->ZERO, mdl_WT->ZERO,
cIPD[1]->0., MDL_WT->FK_ZERO; option off restores them); ME compiles/runs and
shifts as expected off the top resonance. test_standalone_cross_symmetry 43/43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When two helicity configurations are exact mirrors -- every helicity negated --
a parity/C-conserving amplitude gives them an identical |M|^2, so one of the two
need not be recomputed. Extend the good-helicity machinery of SMATRIX to detect
and exploit this alongside the existing zero-filter:

- FLIP(IHEL): the row with every helicity negated, built once at runtime from
  the PROCESS_NHEL table (an involution; self-paired when all helicities are 0).
- CSYM(IHEL,FLAV): stays true only while |M(IHEL)|^2 == |M(FLIP(IHEL))|^2 at
  EVERY good-helicity scan point. One mismatch anywhere permanently drops the
  pair, so a parity-violating (chiral) process simply never de-duplicates.
- Fast phase: the lower-index representative of a surviving pair is evaluated
  once and its |M|^2 counted twice; the higher-index partner is skipped.

Gated to the plain unpolarized sum and, crucially, to the UNCROSSED process only
(FLAV_IDX in [1,NFLAV], tracked by a base-only counter NTRY_CSYM): a crossing
permutes/sign-flips the helicities, so a base-row FLIP is not the crossed
C-parity partner. Crossed flavours therefore keep the full helicity sum.

Pure fixed template text -- no export_v4 change; the density matrix (a separate
routine) is untouched and still computes every helicity.

Validated: u u~ > g g deduped sum == full sum to all digits (and a x3-weight
probe confirms the path fires, halving the C-symmetric rows); d u~ > w- g
(chiral) never de-duplicates; p p > w+ j crossed flavours match the full sum;
test_standalone_cross_symmetry 43/43 (incl. the density-matrix invariants).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port of the fortran standalone C-parity good-helicity de-duplication
(b8c086f) to the standalone_cpp backend. Two helicity configurations that
are exact mirrors -- every helicity negated -- give an identical |M|^2 under
a parity/C-conserving amplitude, so one of the two need not be recomputed.

- flip[ihel]: the helicity row with every helicity negated, an involution
  built once from the helicities[] table.
- csym_bad[flav][ihel]: latches true once |M(ihel)| != |M(flip)| at any scan
  point (parity/C violation). One mismatch anywhere permanently drops the
  pair, so a chiral process simply never de-duplicates.
- The good helicities of a flavor are reduced to the lower-index
  representative of every surviving C-parity pair (igoodrep/nrep) carrying a
  doubled weight (repwgt); the recycling sum then evaluates one of the pair
  and counts it twice, and the higher-index partner is skipped.

Gated to the plain unpolarized sum and, crucially, to the UNCROSSED process
(csym_dedup_ok = "cross == 0", or unconditionally "true" when the crossing
machinery is off): a crossing permutes/sign-flips the helicities, so a
base-row flip is no longer the crossed C-parity partner. Crossed flavors keep
the full helicity sum. amp2/jamp2 are unaffected: a C-symmetric skip halves
every entry uniformly (per-diagram symmetry holds whenever csym does), and
every consumer normalises (amp2(iconfig)/XTOT, color CDF), so the ratios are
invariant.

The scan phase (sum_hel==0 or ntry<10) still full-sums; the reuse is confined
to the helicity-recycling fast phase (single new hole csym_dedup_ok in
get_crossing_replace_dict + new members in cpp_process_class.inc).

Validated (u u~ > g g): ngood=8 -> nrep=4, deduped sum == full sum to all
digits (136.37530903958569); a x3-weight probe gives 1.5x, confirming the
weight path fires. d u~ > w- g (chiral) never de-duplicates (nrep=ngood=6).
The crossed value (flavor_id 3) keeps the full sum and differs from the
identity. test_standalone_cross_symmetry 43/43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port of the fortran standalone C-parity good-helicity de-duplication
(b8c086f) to the ungrouped madevent SMATRIX (matrix_madevent_v4.inc). Two
helicity rows that are exact mirrors -- every helicity negated -- give an
identical |M|^2 under a parity/C-conserving amplitude, so one need not be
recomputed.

- FLIP(I): the helicity row with every helicity negated, an involution built
  once from the PROCESS NHEL table.
- CSYM(I,IFLAV): stays true while |M(I)| == |M(FLIP(I))| at every scan point
  (NTRY<20). One mismatch anywhere permanently drops the pair, so a chiral
  process (or a polarized beam, whose per-helicity beam_polarization scaling
  breaks the mirror) never de-duplicates -- no explicit polarization gate is
  needed, the scan self-excludes it.
- Fast phase (NTRY>=20, still within MAXTRIES=25 so the reuse overlaps the
  grid build): the lower-index representative of a surviving pair is evaluated
  once and counted twice in ANS; its |M|^2 is copied to TS(FLIP) and, when the
  DS helicity grid is being built, DS_add_entry is called for the partner too,
  so the per-helicity event-selection CDF and the grid stay exact. The skipped
  MATRIX call is the only saving -- confined to the init full-sum loop; the
  per-event random-helicity branch is untouched.

Gated to the uncrossed base process: madevent keeps each crossing in its own
subprocess directory, so every IFLAV row here is the base process and a
base-row FLIP is always the genuine C-partner. amp2/jamp2 are unaffected: the
skip halves every entry uniformly (per-diagram/per-colour symmetry holds
whenever CSYM does), and their only consumers are ratios (set_amp2_line's
AMP2(iconfig)/XTOT, the colour CDF), so they are invariant.

Validated with an in-code self-check (recompute the full sum at the same
momenta whenever DEDUP fires): u u~ > g g de-duplicates 4 non-zero C-pairs and
the deduped ANS equals the full sum at every one of ~28000 event calls (0
mismatches); d u~ > w- g (chiral) de-duplicates 0 non-zero pairs; both
generate correct cross-sections (7.433e4 pb, 1650 pb) and 100 events.
test_standalone_cross_symmetry 43/43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port of the fortran standalone C-parity good-helicity de-duplication
(b8c086f) to the madmatrix / cudacpp CPU-SIMD backend. Two helicity rows that
are exact mirrors -- every helicity negated -- give an identical |M|^2 under a
parity/C-conserving amplitude, so calculate_jamps need not be run for both.

Applied to the NON-crossing helicity loop only (the plain get_madmatrix_
crossing_dict path); every csym hole is empty in the crossing path, leaving
the validated per-lane/per-crossing SIMD machinery byte-for-byte unchanged.
This is the right place: the crossing loop already runs cNGoodMaxCross times
whatever a single crossing's good-hel list is, so reusing a base-row |M|^2
would save no kernel call there, and a base-row FLIP is not the crossed
C-parity partner anyway.

Mechanism (all C++ only; GPU and the crossing path keep the full sum):
- cFlip[ihel]: helicity row with every helicity negated (an involution, built
  once from cHel). cCsymBad[ihel]: latched true once |M(ihel)| != |M(cFlip)| at
  some getGoodHel scan point. cCsymPair[ihel]: a good, distinct, C-symmetric
  pair member, finalised in sigmaKin_setGoodHel.
- Detection lives in the serial sigmaKin_getGoodHel scan (per-page |M|^2 stored
  and compared against the flipped partner across the scan events/flavors), so
  sigmaKin only READS the tables -> thread-safe under the OpenMP page loop.
- sigmaKin keeps the FULL cGoodHel list -- so the per-helicity event-selection
  CDF (MEs_ighel) and selected_hel_code stay exact -- but calls calculate_jamps
  only for the lower-index representative of each surviving pair, storing its
  |M|^2 (meOfIhel) and reusing it for the higher-index partner. Handles both
  the single- and mixed-precision (two-page) C++ layouts.

numerators/denominators/jamp2 are unaffected: the skip halves every entry
uniformly (per-diagram/per-colour symmetry holds whenever csym does) and their
consumers normalise (ratios), so they are invariant.

Validated (SSE4 mixed-precision check_sa.exe): u u~ > g g deduped |M|^2 equals
the crossing-on full sum byte-for-byte over 8 events; an x2-reuse probe gives
exactly 1.5x, confirming the reuse path fires; d u~ > w- g (chiral) is
byte-identical to the full sum AND unchanged by the x2 probe (never fires); the
crossing-on build contains no csym token (path unchanged).
test_standalone_cross_symmetry 43/43 (incl. TestStandaloneMg7CrossSymmetry 5/5
and test_use_crossing_false_byte_identical, which checks deduped == full sum).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…m loop

Extend the C-parity good-helicity de-duplication (b8c086f) to the GROUPED
madevent SMATRIX (matrix_madevent_group_v4.inc -> matrix<i>_orig.f / matrix<i>.f),
the default madevent path. Mirrors the single-process port
(matrix_madevent_v4.inc) but threaded through the grouped
(helicity, flavor, subprocess)-indexed GOODHEL/CSYM and the crossing machinery.

- FLIP(I): helicity row with every helicity negated, an involution built once.
- CSYM(I,me_flav_key,proc_id): stays true while |M(I)| == |M(FLIP(I))| at every
  scan point; a chiral process or a polarized beam self-excludes (its per-
  helicity beam_polarization scaling breaks the mirror), so no explicit gate.
- Fast phase (NTRY>=20): the lower-index representative of a surviving pair is
  evaluated once and counted twice in ANS; its |M|^2 is copied to TS(FLIP) and
  (while the DS helicity grid is built) DS_add_entry is called for the partner,
  so the per-helicity event-selection CDF and grid stay exact.

Gated to the uncrossed base process via the new me_csym_cross_ok hole
(".TRUE." without crossing, "CROSSUSE.EQ.0" with it): a crossing permutes/sign-
flips the helicities so a base-row FLIP is not the crossed C-partner; crossed
dependents keep the full sum. amp2/jamp2 are unaffected (uniform halving ->
ratio invariant). Only the init full-sum loop changes; the per-event random-
helicity branch is untouched.

Validated (u u~ > g g, hel_recycling off so matrix<i>_orig.f drives events):
4 non-zero C-pairs de-duplicated, deduped ANS == full sum at every one of
~77000 event calls (0 mismatches); LHE helicity distribution stays symmetric
(no representative bias); cross-section consistent. test_standalone_cross_
symmetry 43/43 (incl. the grouped TestMadevent* tests, whose good-hel
discovery now runs matrix<i>_orig.f with the dedup).

NB: this covers matrix<i>_orig.f (good-hel discovery + hel_recycling-off runs).
The recycled matrix<i>_optim.f event-generation loop is a separate template
(matrix_madevent_group_v4_hel.inc + hel_recycle.py), addressed next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tim.f

Extend the C-parity de-duplication to the helicity-recycling event-generation
hot path (matrix<i>_optim.f), so a C-symmetric process computes only one
representative of each mirror pair. This is the default grouped+recycling
madevent path, generated at run time by gen_ximprove/hel_recycle from
matrix_madevent_group_v4_hel.inc.

Mechanism (four coordinated pieces):
- Discovery: matrix<i>_orig.f, once its C-parity scan has settled (NTRY=20 in
  the forhel init_mode run), PRINTs "CSYM PAIR: <me> <rep> <flip>" for each
  surviving pair (representative rep<flip, both good, |M(rep)|^2==|M(flip)|^2 at
  every scan point). Gated to the uncrossed base process (me_csym_cross_ok).
- gen_ximprove parses those lines. For each pair it KEEPS both helicity rows
  (so the |M|^2 sum and the event-helicity CDF stay complete) but adds every
  (partner, graph) to bad_amps_perhel, so hel_recycle never generates the
  partner's HELAS calls -- only the representatives' amplitudes are computed.
- The new ${csym_reuse} template hole (filled by gen_ximprove with the optim's
  re-indexed helicity positions) copies each partner's |M|^2 back from its
  representative (TS(flip)=TS(rep)) right after the recycled MATRIX returns.
- Disabled for a crossing-class base (perms): it keeps every config, and the
  reuse would be redundant since the sum is already crossing-invariant.

amp2/jamp2 are unaffected: the partner's are 0 (dropped) rather than reused,
i.e. every entry is uniformly halved -> the multichannel/color ratios that
consume them are invariant.

Validated (u u~ > g g, default grouped + hel_recycling): matrix1_optim.f drops
2 of 4 good helicities' amplitudes and reuses TS(4)=TS(1), TS(3)=TS(2); the
cross-section is BYTE-IDENTICAL to a dedup-off recycling reference at the same
seed (7.399e4 +- 546.6 pb, 1000 events); all four helicities are populated in
the LHE (the dropped partners via the reuse) with a symmetric distribution.
d u~ > w- g (chiral) reports no pair, so its optim is unchanged. p p > w+ j
(chiral, crossing class) is likewise untouched. test_standalone_cross_symmetry
43/43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Commit 4ec2ae7 moved the T-channel (spacelike) propagator width drop into the
ALOHA propagator routine itself: the denominator is now emitted as a runtime
P^2>0 conditional (keep the i*M*Gamma width when timelike, drop it when
spacelike) instead of the fixed single-line COUP/(P^2 - M(M-CI*W)). The
reference files for the off-shell (propagator) routines predated that change and
still carried the old single-line denominator, so 8 ALOHA IOTests reported their
propagator routines as differing.

Regenerate those 15 golden files (Fortran, C++ and Python writers) to the
conditional form. The kept form deliberately repeats the P^2 sub-expression
rather than caching it in a temporary: the compiler common-subexpression-
eliminates it (verified in -O2 assembly), so a temporary would be cosmetic.
Amplitude routines and the complex-mass-scheme references are unchanged (the
P^2>0 branch is suppressed under CMS). All 12 ALOHA IOTests now pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…PEP 667)

The short ALOHA vertex tests verify an expression numerically by
eval(str(expr.get_rep(...))). That string references ALOHA's common-subexpression
temporaries (TMP0/TMP1/..., from KERNEL.reduced_expr2, or a routine's contracted
items), which the tests first defined with

    exec('%s = %s' % (name, cexpr))

inside the test method. Under Python 3.13+ (PEP 667) a name created by exec() in
an optimized/function scope is written to a transient locals snapshot and is no
longer visible to a subsequent eval() in that function, so the tests errored with
NameError: name 'TMPk' is not defined (the ordinary F*/P* locals stay visible, so
only the exec-injected temporaries were lost).

Replace the idiom with

    globals()[name] = eval(str(cexpr))

which evaluates each temporary (using the still-visible real locals, and earlier
temporaries already in globals) and stores it as a module global that the
existing eval() calls read. Equivalent on older Python, correct on 3.13/3.14.

Fixes test_short_aloha_{FFV,FFT2,FFVP1N,FFV_MG4,ZPZZ} and the siblings sharing the
idiom (test_short_{expand_veto,part_spin32propagator,spin2propagator4,
use_of_library_spin2}). Test-only change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_build_flav_pdg_tables builds the crossing GET_PDG_FOR_FLAVOR table and is called
unconditionally from write_matrix_element_v4. It took the per-leg ids from
process.get('legs'), which for a decay chain are the CORE process legs
(p p > w+ w-  -> 4 legs), while compute_flavor_masks() is indexed by the FULL
external legs after the decays (p p > w+ w-, w+ > j j, w- > j j -> 6 leaves). The
flavor tuple then had more entries than leg_ids, so leg_ids[leg] ran off the end
and every msF/msP (density / madspin full-flavor) decay-chain output crashed with
IndexError.

Source the legs from get_legs_with_decays() instead, which expands the decays so
leg_ids lines up with the flavor tuple, and is identical to get('legs') when there
are no decays (so non-decay crossing output is unchanged).

Fixes tests/unit_tests/madspin test_ww_jjjj_full_flavor_data_has_16_entries and
unblocks the decay-chain msF/density acceptance outputs that were erroring on this
crash. test_standalone_cross_symmetry stays 43/43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
set_onshell_particles_width_to_zero built its "external particle" set from
process.get('legs'), which for a decay chain are the CORE legs (d d~ > z z ->
[d,d~,z,z]). It therefore treated the decaying z as an external state and zeroed
the width on the resonant z propagator that z > e+ e- needs, turning its
Breit-Wigner into an on-shell 1/(p^2-M^2) singularity: the full ME of
d d~ > z z, z > e+ e- blew up to ~1e18 and the density-matrix convolution ratio
collapsed to ~0 (test_standalone_density_dd / _uu). This mis-fires for any decay
chain where a core-final resonance decays (Z/W/H/top production+decay).

Fix, mirroring the _build_flav_pdg_tables decay-chain fix:
- take the external set from get_legs_with_decays() (the asymptotic leaves; a
  no-op without decays), so a decaying core particle is represented by its decay
  products rather than itself;
- never drop the width of a decay-chain resonance (onshell is True), covering the
  case where the same field is both a resonance and an asymptotic leg (one top
  decays while the other stays final).

Verified: the z propagator keeps MDL_WZ while t a > t a still drops the top width
(intended case unchanged); test_standalone_density_{dd,uu} pass;
test_standalone_cross_symmetry stays 43/43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…DEN assert

compute_crossing_tables built the crossing tables from the core production
legs while a decay-chain matrix element's NEXTERNAL counts the decay leaves,
so on e+ e- > z z, z > mu+ mu-, z > e+ e- the two core z looked like an
identical pair (denominator 8) though they decay differently (true
denominator 4), and the generation-time IDEN sanity assert crashed codegen.

Crossing stays enabled for decay chains -- it acts within the production, and
a decay chain records no cross>0 (so nothing ever crosses the production/decay
boundary, which is the intended behavior). The fix:

  - build the tables over get_legs_with_decays() so they match the real
    NEXTERNAL (a no-op for non-decay processes, crossing suite still 43/43);
  - exempt a decay chain from the exact identity-denominator assert: its
    identical-particle factor is a resonance-level property (two z decaying
    the same way count once, differently not at all) that no leg-PDG count
    reproduces, and it never reaches GET_IDENT_CROSS since cross=0 is
    normalised by the static IDEN. Keep the weaker invariant that the initial
    spin*color divides IDEN.

test_decay_chain_symmetry_factor passes; test_standalone_cross_symmetry 43/43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…or the new codegen

Two unit expectations drifted from committed codegen changes:

  - test_header: ALOHA now wraps a propagator denominator in the t-channel
    width conditional (IF(DBLE(P^2)>0) THEN keep-width ELSE drop-width ENDIF).
    Update the expected FFV1_2 body to that form, and rstrip each compared
    line so the cosmetic trailing space the ALOHA line wrapper leaves after
    "(M1 " does not silently re-break the test (a bare trailing space in the
    source would be stripped by editors/pre-commit).

  - test_generate_helas_diagrams_uux_uuxuux: the madevent exporter drops the
    ICOLUP colour-flow table from leshouche.inc when the matrix element has a
    canonical colour code (drop_icolup) -- addmothers.f rebuilds the Les
    Houches tags from colorflow.inc. Refactor to check the same feature in the
    new layout: leshouche.inc now asserts IDUP + MOTHUP only, and a new
    colorflow.inc assertion checks the canonical code
    (NCOLSLOT/ICOLCSL/ICOLASL/ICOLCODE) that carries those six flows. The old
    ICOLUP rows are kept inline as a reference comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 9 test_standalone_madevent_consistency_* tests all errored: they output
madevent with the default use_crossing on, but the ungrouped madevent exporter
does not support crossing, so _check_crossing_support raised. Generate the
reference with --use_crossing=False (the plain fortran standalone) and extend
the check into a cross-backend matrix element comparison at one shared seeded
phase-space point, per flavor:

  - fortran madevent, ungrouped, crossing off (the original check);
  - fortran standalone WITH crossing (the crossing-aware SMATRIX must
    reproduce the plain per-flavor matrix element);
  - fortran madevent, grouped, WITH crossing (ProcessExporterFortranMEGroup);
  - standalone_mg7 (madmatrix / cudacpp CPU-SIMD), skipped if the C++ build
    toolchain is unavailable.

Backend specifics handled: the grouped exporter hides SMATRIX1 behind helicity
recycling (matrix1_orig.f), so its driver builds the madevent_forhel target;
madevent's GET_FLAVOR returns per-leg group indices, not PDGs, so the madevent
backends are matched to the reference by the shared IFLAV order while the
standalone/mg7 backends (which expose real PDGs via GET_PDG_FOR_FLAVOR) match
by PDG; standalone_mg7's check_sa.exe reads the seeded momenta from an LHE file
(-e) so it evaluates the same point.

All 9 pass with all four backends (mg7 within 1e-7, float precision). Per-flavor
diffs moved from misc.sprint to logger.debug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A decay-chain matrix element now reuses the crossings of its core production
process. The crossing acts at the production level; a decaying resonance's whole
decay block rides along on its production leg and is never split, and a crossing
that would pull a decay-block leaf into the initial state is rejected
(spin*color 0).

- compute_crossing_tables: _leaf_block_sizes (per-leg leaf counts) drives a
  block-size table; a crossing that carries a decay-block leaf across the
  initial/final line gets spincol=0. Emits a per-leaf COUNTABLE mask and a scalar
  IDENT_RESONANCE = identical_particle_factor // (n! over countable final legs).
  All no-ops for non-decay. Called class-qualified so the C++/mg7 exporters can
  reuse it unbound.
- GET_IDENT_CROSS (matrix_standalone_crossing_v4.inc): FACT starts at
  IDENT_RESONANCE and counts only COUNTABLE final legs, giving the resonance-level
  crossed denominator; reduces to the historical plain leaf count for non-decay.
- diagram_generation: thread merge_crossing into the decay-chain production so the
  base amplitude records crossed_processes and the combined decay-chain ME
  inherits them (gated off when a decay pins an s-channel). Standalone
  p p > t t~ j, t > b w+ collapses 7 dirs -> 2.
- _crossed_signatures: expand recorded production crossings to leaves so the
  check_sa demo is enabled and restricted to the real crossed subprocesses.
- madevent/summation backends keep the pre-dedup output: the decay chains are
  regenerated fully (merge_crossing=False) at output time, byte-identical to a
  MG_MERGE_CROSSING=off build.

Validated (test_standalone_cross_symmetry, two new tests): the base decay-chain
crossing SMATRIX at a crossed FLAV_IDX reproduces a --use_crossing=False build of
the crossed decay chain exactly, for a plain jet crossing and for the
resonance-denominator case (u u~ > z z g, z > e+ e-, IDENT_RESONANCE=2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
p p > w+ j, w+ > j j: the crossed decay-chain subprocesses route through the base
decay-chain crossing SMATRIX (matrix2_router -> SMATRIX1 with a crossed FLAV_IDX).
Assert the crossing-routed cross section equals a --use_crossing=False build
(independent matrix elements). Verified identical (1.39e4 pb, same error bar),
confirming madevent supports the production-crossing of a decay chain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Track B cross-group crossing dependent (e.g. q q~ > t t~ g g, reusing
g g > t t~ q q~) has its matrix<i>_orig.f symlinked and crossgroup.mk symlinks
the base's compiled matrix<i>_orig.o AND the base's recycled matrix<i>_optim.o
over it. gen_ximprove still ran the (expensive) helicity recycler on the
dependent's symlinked source, producing a matrix<i>_optim.f that is never
compiled -- pure redundant work (one wasted recycler run per Track B dependent).

Skip it: for a symlinked matrix source, copy orig->optim (cheap) instead of
recycling. A placeholder matrix<i>_optim.f must remain because the P makefile
discovers its matrix objects by the presence of that file (a bare skip leaves
SMATRIX1 undefined at link); the placeholder is never compiled since crossgroup.mk
overrides its .o with the base's. Only symlinked (Track B dependent) sources are
affected; base directories, whose sources are real files, still bake the shared
optim over the union good-hel of the whole crossing class.

Verified on p p > t t~ j j: cross section 397.8 pb unchanged, build OK, the
dependent's optim.f == orig.f (recycler skipped) while the base is recycled;
recycler runs drop from 6 to 5. Applies to non-decay and decay chains equally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ference

Seven flavor tests broke on the crossing branch from two independent, intended
feature changes; none is a regression in the crossing/mask machinery.

Crossing default-on (6 tests) -- add --use_crossing=False, which isolates each
test's actual subject from the orthogonal crossing codegen (crossing reduces to
the base flavor before the mask/grouping/xsec logic runs, and its correctness is
covered by the crossing and standalone-madevent-consistency suites):

  - test_standalone_flavor_mask, test_standalone_wwjj: crossing folds the
    inspected subprocess into another directory, so the test can no longer find
    it (p p > j j gives one folded P0_QQ_QQ dir, not the three separate ones);
  - test_madevent_flavor_zud_nogroup: ungrouped madevent has no crossing
    support, so `output madevent` raised;
  - test_flavor_grouping_consistency{,_width,_mlm}: the ungrouped settings hit
    the same guard.

ALOHA t-channel width (1 test) -- test_standalone_merged_flavor_uq_zuq: u q > Z u q
runs through a spacelike (t-channel) electroweak propagator, and commit 4ec2ae7
drops the width of a spacelike propagator (its momentum can never reach the pole,
so the Breit-Wigner term there is spurious). This shifts the matrix element ~0.1%,
independent of crossing and of the per-flavor mask. The reference values are
updated to the new numbers; the previous values are kept in a comment.

Verified: merged_flavor_uq_zuq, standalone_flavor_mask, standalone_wwjj all pass.
The madevent tests are unblocked (guard cleared); their event generation is
environment-dependent here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@oliviermattelaer

Copy link
Copy Markdown
Contributor Author

One subtelty to keep in mind (maybe we should change the crossing encoding?):

Why q q~ > t t~ q q~ keeps its own matrix element (and is not routed as a crossing of q q > t t~ q q)

Context. In p p > t t~ j j the group P*_qq_ttxqq contains two compiled
matrix elements, matrix1_orig.f (Q Q > t t~ Q Q) and matrix2_orig.f
(Q Qx > t t~ Q Qx), plus one crossing router (Q~ Q~ > t t~ Q~ Q~). A natural
question is why Q Qx is a second matrix element rather than a crossing of
Q Q, since q q > t t~ q q and q q~ > t t~ q q~ are related by crossing.

Short answer. It is not a filtered/"no-flavour" diagram, and the
processes are genuine crossings. The one flavour that cannot be routed is the
flavour-changing annihilation d d~ > t t~ u u~, and the obstruction is a
limitation of the crossing encoding (it can only swap the two initial legs
with final ones — it has no final↔final permutation), not a physics or
diagram-content difference.

The facts (verified)

Diagram counts match, so no diagram is dropped — these are true crossings:

process NGRAPHS crossing of …
u u > t t~ u u 14
u u~ > t t~ u u~ 14 u u > t t~ u u
d u > t t~ d u 7
d d~ > t t~ u u~ 7 d u > t t~ d u

Reachability of the crossed signatures (from compute_crossing_pdg_entries):

u u~ > t t~ u u~   reachable from u u > t t~ u u   : True   (both leg orders)
d d~ > t t~ u u~   reachable from d u > t t~ d u   :
      (1,-1,6,-6, -2,  2)  =  d d~ > t t~ ū u      : True
      (1,-1,6,-6,  2, -2)  =  d d~ > t t~ u ū      : False   <-- the order the merged ME uses

Why the merged Q Qx ME still cannot route

Q Qx > t t~ Q Qx bundles (via flavour grouping) three flavour classes. Two of
them do cross to Q Q:

  • d d~ > t t~ d d~ (same flavour) → crossing of d d > t t~ d d
  • d u~ > t t~ d u~ (mixed) → crossing of d u > t t~ d u

The third is the flavour-changing annihilation d d~ > t t~ u u~ (initial d d̄,
final u ū, via d d̄ → g → t t̄ and g → u ū). It is a crossing of
d u > t t~ d u, but only with the two final same-flavour-group legs in the
order (ū, u):

crossing of d u > t t~ d u delivers :  … , ū(5) , u(6)     -> sig (1,-1,6,-6,-2, 2)
merged Q Qx > t t~ Q Qx lists       :  … ,  u(5) , ū(6)    -> sig (1,-1,6,-6, 2,-2)   (Q at 5, Qx at 6)

Going from (ū, u) to (u, ū) is a swap of the two final legs. The crossing
code encodes a crossing as

CROSS = I*(NEXTERNAL+1) + J        # swap initial-1 with final-I, initial-2 with final-J

i.e. only crossings of the two initial legs with final ones — there is no
final↔final permutation in the encoding. Since u and ū are different
particles, feeding the base's crossing SMATRIX the dependent's momenta in the
wrong order would evaluate the wrong |M|², so partition_crossing_classes
correctly refuses the match. Routing a merged member requires every flavour
to cross to a single base; this one flavour cannot, so the whole Q Qx ME stays
a base and gets its own matrix<i>_orig.f (and its own helicity-recycling run).

Consequences / how it could be enabled

  • The current output is correctQ Qx genuinely needs a matrix element it
    can evaluate; nothing is double counted or lost.
  • This is not the cross-group Track-B case: g g > t t~ q q~q q~ > t t~ g g
    are reused (symlinked base matrix element), because there the crossing is a
    plain initial↔final swap the encoding can express.
  • To share Q Q with Q Qx one would have to generalise the crossing to also
    permute final legs (or have flavour grouping assign, for that flavour, the leg
    order the initial-leg crossing can actually reach). Either is a real extension
    of the crossing machinery, not a bug fix.

One-line summary

q q~ > t t~ q q~ is a genuine crossing of q q > t t~ q q, but the merged
Q Qx matrix element also carries the flavour-changing d d~ > t t~ u u~, whose
crossing needs a final-leg reorder the I*(N+1)+J encoding cannot express —
so the merged ME cannot be routed as a whole and keeps its own matrix element.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants