Skip to content

Crossing symmetry - #49

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

oliviermattelaer wants to merge 191 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 crossing base bakes the shared optim over G_base U tau(G_base) (crossgroup_helunion.dat); wavefunction recycling is retained.
      • helicity needs a flipping associated to the permutation
      • tau is that flipping applied in place (the crossing's NSF sign flips), and it is the ONLY part of the transform the recycled optim can realise: it bakes its helicity configs into the HELAS calls and receives just (PUSE, IC) -- IC carries the signs, nothing carries the slot permutation.
      • the permuted map (the GHREMAP sigma that matrix<b>_orig.f applies via APPLY_CROSSING_TABLE) is the wrong one here. Baking G_base U sigma(G_base) cost -28.5% on q q~ > q q~; fixed in db46ee2, pinned run-free by TestCrossingRecycledHelicityUnion.
      • AMP2 (multi-channel) and JAMP2 (colour flow) -- not the |M|^2 sum -- are gated on TS(K).NE.0D0 for a crossing base, so each member weights channels and colour over exactly its own good set. A shared optim necessarily holds rows that are dead for whichever member is calling.
        - [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).
  • q q~ > q q~ (q=u d s c, apply_flavor_grouping False, group_subprocesses True, MLM ickkw=1/xqcut=20): 5188690 +- 15750 vs the 3.7.2 reference 5184588 +- 2971 -- acceptancetest_flavor::test_flavor_grouping_consistency_mlm green on all four settings (the crossing+recycling one was 3706990 +- 10989, 105.8 sigma). Colour flow held: TestMadeventCrossingBaseColorFlow and TestMadeventRouterColorSelection pass, g g > u u~ crossing-on and --use_crossing=False both 3.023e6 +- 1406 pb.
  • 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 20 commits August 10, 2026 23:29
…the partner

The C-parity de-duplication in the madmatrix / cudacpp CPU-SIMD backend kept
the FULL good-helicity list and merely skipped calculate_jamps for the higher
-index member of each mirror pair, reusing its |M|^2. cGoodHel is now REDUCED
in sigmaKin_setGoodHel to the lower-index representative of each pair, each
representative is counted twice, and the event-by-event helicity choice returns
the representative or its cFlip partner at equal rate. That halves the sigmaKin
trip count, and it halves nGoodHel -- which is what sizes the ghelAllJamps /
ghelAllMEs super-buffers on the GPU side, so it is the prerequisite for ever
extending this to the device.

The 50/50 needs no extra random number: conditional on the CDF landing in bin
[lo,hi) the selection variate is exactly uniform there, so its position within
the bin is an independent U(0,1). Drawing a fresh one would desynchronise the
stream shared with the Fortran integrator.

Two safety fixes fall out of getting this to work at all:

* The pair-equality test was RELATIVE only. For two rows whose |M|^2 is
  numerically zero that compares roundoff noise against itself and latches
  "not C-symmetric" at random -- so on u u~ > g g, whose MHV-vanishing gluon
  configurations sit at |M|^2 ~ 1e-30 out of ~10 and are still admitted by the
  `!= 0` good-helicity filter, the de-duplication NEVER ENGAGED. It now also
  requires the difference to be significant against the largest |M|^2 of the
  same (flavor, page). The same relative-only test exists in the fortran and
  standalone_cpp backends and may be equally inert there.

* cCsymScanned: the verdict now defaults OFF unless the validating scan
  actually ran. Previously cCsymBad was zero-initialised, so any path reaching
  setGoodHel without getGoodHel would have read "no mismatch seen" as "C
  -symmetric" and enabled the dedup unvalidated.

Still gated to the uncrossed base process (a crossing permutes AND sign-flips
helicities, so a base-row mirror is not the crossed C-partner) and to CPU
builds; the crossing dict leaves every csym hole empty, so that path is
byte-for-byte unchanged.

Validated on u u~ > g g, g g > t t~, g g > g g with FPTYPE=d (not m, which
hides ULP differences in this backend), 2M events per process, dedup-on vs a
-DMGONGPU_NOCSYM build on identical inputs:
  |M|^2 max relative difference 6.2e-16 .. 1.2e-15, summed |M|^2 identical to
  all 17 digits; selected-helicity chi2/ndf 0.167 (3 dof), 0.945 (11), 1.478
  (5); intra-pair split chi2/ndf 0.993, 0.636, 1.071.
  Negative control: with the 50/50 deliberately broken, |M|^2 stays bit
  -identical while the helicity chi2/ndf goes to 4.5e5 -- a cross-section check
  cannot see this class of bug.
  End-to-end `output mg7` cross-section (multichannel, real integrator), 3
  independent 40k-event runs each: 60999 +- 43 pb vs 61029 +- 43 pb, 0.49 sigma.
  Chiral p p > w+ j, d u~ > e- ve~ and u u~ > e+ e- self-exclude and are byte
  -identical; u u~ > e+ e- is the one that exercises the |M|^2-mismatch arm
  (mirror rows both good and distinct, differing by a factor 4).
  Mixed precision (FPTYPE=m) and scalar (BACKEND=cppnone) paths both exercised.

Timing (arm64, cppsse4, FPTYPE=d, MinTimeInMatrixElems, interleaved runs):
  process        vs branch tip   vs no dedup   vs skip-and-reuse
  u u~ > g g        2.16x           1.86x            0.99x
  g g > t t~        2.22x           2.06x            1.02x
  g g > t t~ g       --             1.97x            1.00x
So the halving itself is a wash against the old skip-and-reuse on CPU, as
expected -- the kernel was already being skipped. The 2.2x against the branch
tip is the de-duplication finally engaging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… relative

The scan that decides whether two mirror helicity rows have the same |M|^2 used
a RELATIVE tolerance only:

    IF (DABS(TS(I)-TS(FLIP(I))).GT.1D-6*(DABS(TS(I))+DABS(TS(FLIP(I)))))

For a pair whose |M|^2 is *numerically zero* that compares roundoff noise
against itself and latches at random, and since the verdict is all-or-nothing a
single noise pair vetoes every real pair. Also require the difference to be
significant against TSMAX, the largest |M|^2 of the same scan point.

Whether a vanishing helicity configuration lands on exact 0 or on ~1e-30 turns
out to depend on the process AND on the backend, so this was measured rather
than assumed, by instrumenting the generated code to print its own verdict:

    backend              process       before          after
    standalone fortran   u u~ > g g    DEDUP=T (0.0)   unchanged
    standalone fortran   g g > t t~    DEDUP=F         DEDUP=T
    madevent ungrouped   g g > t t~    DEDUP=F         DEDUP=T
    madevent grouped     g g > t t~    DEDUP=F         DEDUP=T, 6 CSYM PAIRs

Only pairs 1/16 and 4/13 were ever rejected, at at most 6.3e-30 against a scan
maximum of 3.2e+02 -- 32 orders of magnitude down.

Validation is exact rather than statistical: the fortran de-duplication writes
TS(FLIP)=T, so the event-selection CDF still runs over the FULL helicity list
and the random-number stream is untouched. A same-seed dedup-on/dedup-off pair
is therefore event-by-event identical, and is:
  ungrouped  7.211495 pb both, 20000/20000 identical helicity combinations
  grouped    7.206236 pb both, 20000/20000 identical helicity combinations
(20k events, lpp1=lpp2=0 partonic at 500+500 GeV, iseed=33, helicities read
from the LHE spin column; chi2 = 0 exactly on the helicity histogram.)

Grouped side-effect: with pairs now reported to gen_ximprove, matrix1_optim.f
gains 6 `TS(flip) = TS(rep)` reuse assignments and drops from 52 to 39 HELAS
calls for g g > t t~.

NOT ported to standalone_cpp: its verdict already passes (its noise values come
out bit-identical between mirror rows), so the floor would change nothing there.
That backend has two unrelated pre-existing problems instead -- `sum_hel` can
never leave 0, so the branch consuming igoodrep/repwgt is unreachable, and
`igood[flav][ngood]` overflows its row by one when every helicity is good --
both of which need their own fix and validation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The crossed path kept the full helicity sum. That was not a physics limitation:
a crossing acts on a helicity row as a slot permutation plus a per-leg sign
flip, and global negation commutes with both, so mirror(crossed row) ==
crossed(mirror row) and each crossing's good-hel set is closed under the mirror.
Measured directly by driving SMATRIXHEL per row on u u~ > g g at extended flavor
ids 1, 3, 4, 5, 6 and 21 (sums 6.75 / 34.6 / 129.6, so the crossings really do
change the physics): worst row-vs-mirror relative difference 0, on every row.

What makes it harder than the uncrossed path is that lanes of ONE SIMD page may
carry DIFFERENT crossings. The verdict, the reduced list, the weight and the
50/50 are therefore all per crossing and applied per lane:

  * the scan latches cCsymBadCross[cross] (iflav encodes cross*nmaxflavor+flav),
    with the same absolute floor as the uncrossed path;
  * goodhel_percross_build reduces each cGoodHelOfCross[c] to the lower-index
    representative of every pair, halving cNGoodPerCross[c] and cNGoodMaxCross;
  * sigmaKin builds a 0/1 per-lane vector once per page and doubles each lane's
    contribution through it;
  * selected_hel_code_lane_csym returns the lane's representative or its mirror
    at equal rate, the coin recycled from the selection variate so no extra
    random number is drawn.

ALL-OR-NOTHING ACROSS CROSSINGS, for an implementation reason that is worth
recording. Reducing only some crossings leaves cNGoodPerCross non-uniform, and
the lanes of a shorter crossing then reach the ighel >= cNGoodPerCross padding
row (_hr = -1 in calculate_jamps). That row yields NaN rather than 0: its zeroed
wavefunctions give a 0/0 propagator, and for a VALID crossing the per-event
denominator multiplies instead of assigning 0, so the NaN reaches the output.
This is PRE-EXISTING -- reproduced with -DMGONGPU_NOCSYM (no de-duplication code
active at all) by shortening one crossing's list by hand -- and latent today
only because every crossing happens to have the same good-hel count (checked:
p p > w+ j is uniform at 6). Keeping the verdict uniform preserves that
invariant exactly rather than arming the trap. Fixing the padding row is a
separate change.

Validated against a -DMGONGPU_NOCSYM build on identical inputs, FPTYPE=d,
2M events, with the driver cycling flavor ids so ONE page carries several
crossings:

  crossings fed   |M|^2 max rel diff   summed |M|^2   helicity chi2/ndf
  0 (identity)         7.4e-16          identical          0.167 (3)
  3                    4.9e-16          identical          1.023 (3)
  0,3,4,5 mixed        7.4e-16          identical          0.540 (5)

Negative control on the mixed configuration: removing the mirror pick leaves
|M|^2 bit-identical and the summed |M|^2 unchanged while the helicity chi2/ndf
goes to 2.7e5 -- a cross-section check cannot see this class of bug.
Chiral p p > w+ j self-excludes and is byte-identical under crossing.
FPTYPE=m and BACKEND=cppnone both agree. No NaN in any run.

Timing (arm64, cppsse4, FPTYPE=d, MinTimeInMatrixElems, interleaved, 6 reps):
cNGoodMaxCross 8 -> 4 and 1.98x, close to ideal because here the halving really
does halve the trip count -- unlike the uncrossed path, where the previous
skip-and-reuse had already removed the kernel call.

The uncrossed path is untouched: --use_crossing=False generates byte-identical
code to before this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed() clause

Conflict was add/add at EOF of .github/workflows/acceptancetest.yml: this
branch appended the crossing-symmetry CI jobs, zen-kilby appended
acceptancetest_standalone_mg7_openmp. Independent jobs, both kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A loop-induced ([noborn=]) process is exported by the *tree-level* output
machinery: master_interface borrows the MadLoop interface only to validate
the model, then switches back to 'MadGraph' and calls create_loop_induced.
Only the madevent formats have a loop-induced exporter to route it to, so
every other format handed the LoopHelasMatrixElement to a tree-level
exporter and died deep inside it:

  output standalone -> IndexError in write_check_sa
  output matrix     -> "wavefunction_rank has not been computed"
  output mg7        -> KeyError on the first loop leg, which the mg7
                       exporter's edge-name map does not contain

All pre-existing, none of them a regression. Refuse those formats up front
instead, and point at [sqrvirt=], which stays in the MadLoop interface and
reaches the MadLoop exporters: g g > h h [sqrvirt=QCD] + output standalone
gives a working standalone directory whose ./check returns exactly the same
3.2829343688358318E-005 as the [noborn=] madevent PV dir.

The check lives in MadGraphCmd.do_output, ahead of the rmtree that cleans an
existing output directory, so a guaranteed refusal never deletes one first;
ExportV4Factory and ExportCPPFactory carry the same check as a backstop for
direct callers. [virt=]/[sqrvirt=] are untouched -- they go through
loop_interface.do_output (output_type='madloop') and reach none of the three
sites -- and 'output aloha' returns before the check.

Add tests/unit_tests/loop/test_loop_induced_output.py, which nothing covered
before: the refusals (asserting the message still names sqrvirt), and the
routes that must keep working -- madevent on [noborn=], standalone on
[sqrvirt=] and on [virt=].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e6d89

# Conflicts:
#	madgraph/interface/madgraph_interface.py
…rix (uncrossed + crossed)

Halves the good-helicity list to one representative per mirror pair, counts
each twice, and returns representative or partner at equal rate (coin recycled
from the selection variate, so the shared RNG stream is untouched). Also fixes
the relative-only pair tolerance that made the de-duplication inert, and
defaults the verdict OFF unless the validating scan ran.

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

# Conflicts:
#	madgraph/iolibs/template_files/madmatrix/process_sigmaKin_function.inc
test_standalone_mg7_openmp failed as soon as the generated process took the
crossing branch: that branch's sigmaKin loop is bounded by cNGoodMaxCross, a
file-scope static, and 'omp parallel for default( none )' requires every such
variable in shared(). Pre-existing -- it reproduces at 92088eb, before the
C-parity merge -- and invisible until now because the test arrived on a branch
based on dcdd3df, which predates the per-lane crossing work, so the process
it generates there never reached that loop.

Renamed the hole csym_omp_shared -> extra_omp_shared: cNGoodMaxCross is needed
whether or not the C-parity de-duplication is compiled in, and leaving it
behind a csym-named key is how the next variable gets missed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two conflicts, both "keep both":

* helas_objects: the quartic-merge caches and the flavor-mask cache are
  independent per-matrix-element runtime caches.
* the madmatrix sigmaKin loop: the C-parity work renamed its holes
  (csym_me_decl -> csym_page_decl, csym_record -> csym_weight) when it
  moved from skipping a partner to halving the good-helicity list, so the
  BLAS variant of the same loop keeps its own wrapper around the NEW
  names. color_sum_blas_loop.inc only carries the crossing holes, which
  are unchanged, so it needed nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…passing for one

Two defects, one hiding the other.

1. The standalone makefile handed $(LINKLIBS) -- which carries $(BLASLIBS),
   '-framework Accelerate' on macOS -- to f2py. On python>=3.12 f2py must use
   the meson backend, which does not parse '-framework': it prints
   "Unknown option '-framework'", builds no module, and STILL EXITS 0. So
   matrix2py.so could not be built at all on macOS, and the failure was silent
   -- it even masked a second, unrelated meson failure underneath by turning
   f2py's exit 1 into exit 0. BLASLIBS now goes through LDFLAGS, which meson
   honours (verified: a bogus -l in LDFLAGS does fail the link, so the flag
   really does reach the linker), and only plain -L/-l stay in the f2py
   argument list as LINKLIBS_NOBLAS. The $(FC) links of check_sa and
   check_sa_born_splitOrders still use the full $(LINKLIBS) and are unchanged;
   no standalone template references a BLAS symbol today, so this is currently
   dead weight there, but it stops being a trap the moment one does.

2. _crossing_build_f2py took 'make exited 0 and a matrix2py*.so exists' as
   success. The makefile touches the bare .so unconditionally to give make a
   timestamp (f2py names the real module matrix2py.cpython-<ver>-<plat>.so),
   so after a failed f2py that file is present and EMPTY. check_crossing then
   built nothing, enumerated nothing, and returned [] -- surfacing as
   'check crossing returned no comparison' rather than the build_failed its
   caller skips on, which is what the test docstring promises. Success is now
   that the module IMPORTS, probed out of process so a bad dlopen cannot hurt
   the caller.

CI never saw either: linux picks -lblas, which f2py accepts.

test_check_crossing_command / test_check_crossing_s_channel_graceful pass in CI
(acceptancetest_crossing_cpp, 'Ran 15 tests ... OK'; test_manager exits 1 on a
skip, so they really ran). Locally they now skip with the accurate reason
instead of failing, and with meson present 'make matrix2py.so' produces an
importable matrix2py.cpython-314-darwin.so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
madspace does not support crossing yet, so the shipped default must be the
un-crossed output everywhere. --use_crossing=True opts in; --use_crossing=False
is now redundant but still honoured. Flipped in the four places that decided it:
MadGraphCmd._use_crossing / ._output_use_crossing (class attrs and the per-process
reset), the do_add fallback when the flag is absent, and the exporter's
self.opt['use_crossing'] fallback. One .get('use_crossing', True) in
get_crossing_pdg_lines still defaulted on and is now False, so no path can
re-enable it implicitly. Help text and the output completion updated.

Verified end to end: 'generate p p > j j QCD=0; output standalone' gives the
3-directory unfolded layout by default (as on main) and 1 folded directory with
--use_crossing=True.

TESTS NOW STATE THEIR CHOICE. The crossing suite had 45 explicit
--use_crossing=False against only 2 explicit True -- everything else inherited
the default, so flipping it would have silently converted ~60 crossing-ON tests
into a second copy of the crossing-OFF ones: still green, testing nothing. Every
generate in test_standalone_cross_symmetry.py is now pinned, via a _pin_crossing
helper where the caller supplies an options string (it leaves an explicit choice
alone) and literally otherwise. Same for the two crossing-ON arms of
test_standalone_madevent_consistency.py, the standalone_mg7 backend arm, and
test_standalone_crossing_folds_qqx_subprocess, whose 'crossed' arm was the bare
default and is now --use_crossing=True. Nothing in the test suite reads the
product default in either direction any more.

Checked green with the flip: the 13 static crossing tests, the three
layout-sensitive test_cmd tests, and every IOTest job that was green in CI
(iotest_examples, modification_to_cuts, cpp_write, loop_sqso, ewsudakov,
gnuplot). Full unit sweep 1042 tests: the only failures are the 6 reserved
codegen goldens that were already red (fks, export, unit_proc), plus one local
'No module named scipy'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The default flip left the accumulator behind:

    self._use_crossing = self._use_crossing and use_crossing

That was right while the default was True (any line asking for False wins), but
with the class attribute now False it can never be lifted -- False and True is
False -- so --use_crossing=True was parsed, accepted, and silently ignored. No
routers, no CROSSUSE, crossing unreachable. CI caught it in the three tests whose
non-vacuity guards exist for exactly this ('no subprocess group is served by a
crossing router, so the comparison would be between two identical builds').

Resolved per line instead: an explicit True switches it on, an explicit False
switches it off for good (a multi-line definition must not end up half crossed,
which is what the old  protected), and a line with no flag inherits what
the definition already chose. The sticky-off marker is reset with the rest of
the per-definition state.

Measured on 'define q1 = g u u~; generate q1 q1 > q1 q1':
  no flag                                   0 routers
  --use_crossing=True                       3 routers
  --use_crossing=False                      0 routers
  True + 'add process g g > u u~'            3 routers   (inherits)
  True + 'add process ... --use_crossing=False'  0 routers   (sticky off)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7f50f03 ('let each output pick the diagram order') and the DDM basis work
added color_basis and merge_quartic_vertices as set-options but did not extend
the expected dict in test_config, which enumerates every option exhaustively.
The merge of that branch into the crossing tip (921b203) therefore turned
acceptancetest_5 red. Not a crossing failure and not caused by the default flip;
it just landed in the same window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FLIP(I) -- the row whose helicity configuration is I's with every helicity
negated -- was rebuilt at run time by an O(NCOMB^2 * NEXTERNAL) scan over the
materialized NHEL table, in four templates. The generator already holds
everything that search rediscovers: a configuration is a mixed-radix code over
the per-leg states, so the partner is a digit-wise complement of the code.
_helstate_data now computes it alongside HELALLOW and emits DATA FLIP.

Removed from matrix_standalone_v4.inc, matrix_madevent_v4.inc,
matrix_madevent_group_v4.inc and hel_warmup_v4.inc: the search itself, the
HELSAME/JHEL/KHEL scratch, the DATA FLIP/NCOMB*0/ sentinel and the
IF (FLIP(1).EQ.0) lazy-init guard -- one less piece of order-dependent mutable
state in a routine already juggling FLAV_USE/CROSSUSE. The grouped template
keeps a latch (CSYM_DONE) for the self-paired scan that follows, which writes
CSYMBAD and is not idempotent-cheap.

Semantics are unchanged, including the two ways to land on FLIP(I)=I, which
every consumer reads as "no distinct partner, keep the de-duplication off":
a row that negates to itself, and -- the case the search could only discover by
failing -- a row whose negation is not in the allowed set, which is what
polarization produces (a leg keeps its full radix but only some states are
selected, leaving HELALLOW a non-contiguous subset).

Verified rather than assumed. The emitted table is identical to what the
removed search computes, checked directly on g g > t t~, u u~ > g g, g g > g g,
e+ e- > z z (NCOMB=36, so the 0-helicity state is exercised), p p > w+ j, and
p p > z{0} j -- the polarized case, confirmed non-contiguous HELALLOW. Same
check re-run against the generated fortran of all three backends (standalone,
madevent single, madevent grouped): emitted == search on every matrix*.f, and
an involution on the encoder-based file whose NHEL is only materialized at run
time. The standalone build links and gives Matrix element = 0.59262631132419219
for g g > t t~, matching the 0.5926263 pinned at test_cmd.py:616.
TestGoodHelCParityDedup + the crossing static suites 17/17. Full unit sweep
1042 tests: failure set byte-identical to before this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
matrix_standalone_v4.inc emitted DATA HELALLOW twice: a private copy in SMATRIX,
which matches a user-requested helicity code (USERHEL.EQ.HELALLOW(IHEL)), and
another in FILL_NHEL, which decodes the list into the NHEL table. Both are live;
neither was dead code. The list is NCOMB long and grows with the multiplicity,
and a reader had no way to tell the two tables were the same table.

FILL_NHEL now owns the single DATA and publishes it through a COMMON that
SMATRIX reads. A DATA statement cannot initialise a COMMON outside a BLOCK DATA
-- and a BLOCK DATA can be dropped by the linker when it is not referenced --
so the list stays a local DATA and is copied into the COMMON on the first call.
Every reader is ordered behind that call: SMATRIX calls FILL_NHEL at the top and
only reads the table ~30 lines later, with no RETURN or GOTO in between (checked),
and it is the only other consumer in the file. No generator change: the emitted
DATA is untouched, only who declares it.

An accessor function was the other option and is worse here: Fortran does not
guarantee short-circuit evaluation of .OR., so USERHEL.EQ.-1 .OR.
USERHEL.EQ.GET_HELALLOW(IHEL) risks a call per helicity per event in the loop.

Scope, checked rather than assumed: only this template duplicated it. The
madevent and madweight templates never mention HELALLOW; matrix_standalone_hel
has one; and the split-orders variants (matrix_standalone_splitOrders_v4.inc,
the matchbox one, split_orders_helping_functions.inc) carry neither the encoder
nor the C-parity machinery at all -- a generated split-orders matrix.f has zero
DATA HELALLOW, zero DATA FLIP and zero HELSAME.

Verified: g g > t t~ still gives 0.59262631132419219 (the value pinned at
test_cmd.py:616), and p p > z{0} j -- polarized, so HELALLOW really is
non-identity (3,4,9,10,15,16,21,22) and the changed USERHEL path is exercised --
is bit-identical against a build from the parent commit on both matrix elements.
Generated file goes from 2 DATA HELALLOW to 1. Crossing/C-parity suites 17/17.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
matrix_standalone_splitOrders_v4.inc had drifted from
matrix_standalone_v4.inc: 16 routines against 23, and no reader could tell
which of the differences were meant. They are decided one by one here, and
the dividing line is the crossing -- this template has no crossing machinery
(fill_crossing_replace_dict fills holes it does not have, use_crossing_ic
excludes split orders), so what exists only to decode an extended FLAV_IDX
stays out and what is meaningful without one comes in.

CARRIED

  DECODE_HEL / FILL_NHEL / the HELALLOW list. The helicity table was emitted
  TWICE (SMATRIX_SPLITORDERS and GET_NHEL) and initialised a COMMON from a
  DATA statement, which Fortran only allows inside a BLOCK DATA -- it worked
  because gfortran accepts it. Now decoded once into PROCESS_NHEL, with the
  FILL_NHEL call added to GET_DENSITY as well: it reads that table without
  going through SMATRIX first, and would otherwise see zeros.

  The C-parity de-duplication (FLIP/CSYM). Adapted, not copied: the pair test
  and the doubling run per squared-order component rather than on their sum,
  since two components could in principle cancel into an equal total. The
  separate NTRY_CSYM of the default template is collapsed into NTRY -- it
  exists there only so a crossed call does not advance the scan. Kept
  verbatim: the verdict is all-or-nothing per flavor (a self-paired row or
  one mismatched pair turns it off for good), and the pair tolerance has an
  absolute floor as well as a relative one, without which two numerically
  zero rows compare roundoff to itself and veto every real pair.

  GET_NHEL_IDX, in its non-crossing form. A caller that reads GET_NHEL's
  static IDEN to undo the averaging gets the wrong denominator for every
  non-representative flavor of a merged matrix element, and merged flavors
  are exactly what this template already handles (BROKEN_SYM).

LEFT OUT, each for a reason

  GET_DENSITY_IDX, GET_ALL_INTER_IDX, GET_ALL_INTER_CROSSED,
  GET_INTER_RESCALE. The whole quartet exists because a FLAVOR array cannot
  express a crossing; with no crossing the _IDX entries duplicate their
  FLAVOR-array twins and _CROSSED/RESCALE are pass-throughs. Same for
  GET_PDG_FOR_FLAVOR and the f2py flav_idx wrappers, which decode nothing
  else.

  ENCODE_HEL. Nothing in the tree calls it -- the crossing routines that
  would inline their own encode in CROSS_GHIDX. A split-orders matrix.f is
  written once per MadLoop and FKS born as well as per standalone
  subprocess, so a dead routine there is paid for many times over.

  The canonical-code helicity label (HELCODE). The default template matches
  USERHEL against the code; here it stays the ROW NUMBER, because that is
  what the callers pass: MadLoop hands SMATRIXHEL_SPLITORDERS its USERHEL,
  which the EW-Sudakov driver fills from `do chosen_hel=1,SDK_GET_NCOMB()`
  (Template/NLO/SubProcesses/check_sudakov.f). The two labels coincide for
  every unpolarized process; relabelling would silently move the Born of a
  polarized NLO run.

  HELSAME, named as missing: it exists nowhere in the tree, in any template.

  matrix_standalone_matchbox_splitOrders_v4.inc, the further-reduced variant:
  out of scope, so that this lands ahead of a queued golden regeneration.

matrix_template_provides answers by searching the template text, so a comment
naming an omitted routine would make it claim the routine exists. The
templates therefore describe what they leave out instead of naming it, and
test_splitorders_template_carries_the_non_crossing_standalone_api pins both
lists plus the two predicates that read them (the crossing demo and the
folded color sum stay off).

Also: check_sa_splitOrders.f could not compile at all against a model with
flavored couplings -- coupl.inc declares TYPE(FLV_COUPLING) and the driver
had no `use model_object`, which check_sa.f has carried since those landed.
Nothing noticed because `make` builds check_sa.f only.

Verified by compiling and running the generated code, not by `make` exiting.
  * u u~ > u u~ QED^2==2, g g > t t~ QCD^2==4, e+ e- > w+{0} w- QED^2==4
    (polarized, so HELALLOW is really non-identity: 4,5,6,13,14,15,...):
    check_sa and check_sa_born_splitOrders byte-identical to a build from
    602235d, the polarized one included.
  * p p > j j QCD^2==4: all 8 subprocesses, every one of their 65 flavor
    columns plus the out-of-range indices, evaluated 40 times so the fast
    phase is reached, and each cross-checked against the same total rebuilt
    one helicity at a time through SMATRIXHEL_SPLITORDERS (which never
    de-duplicates). Byte-identical, column for column.
  * The de-duplication engages and halves the loop: 1.94x on g g > g g,
    1.88x on the merged QQx_QQx (28 flavors), 1.92x on gQ_gQ, 1.92x on
    g g > t t~ -- the last shifting the total by 1.2e-16, the reassociation
    of summing half the rows doubled, and agreeing with the per-helicity
    rebuild to the same 1.2e-16. It correctly refuses u u~ > u u~ QED^2==2,
    whose Z axial couplings break the pairing.
  * MadLoop born_matrix.f is written from this template: u u~ > u u~
    [virt=QCD] builds and its driver returns 2.8276928588371737, bit-identical
    to the base build. A matchbox output builds and returns the same.

IOTests, reported rather than regenerated (the user regenerates next):
  * testIO_sqso_uux_uuxuuxx: red before, red after (its golden already
    predates a template reordering).
  * test_export_fks: 11 tests, the same 2 red before and after
    (testIO_test_ppw_fksall, testIO_test_wprod_fksew).
  * test_loop_exporters: same 1 red before and after.
  * test_export_v4 unit tests: same 3 red before and after, +1 new passing.
  * testIO_MatchBoxOutput: green before, RED after -- its
    check_sa_born_splitOrders.f golden picks up the `use model_object` line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`p p > j j QCD^2==4 --use_crossing=True` (standalone) folded 8 subprocesses
onto 3 and wrote no decoder into any of them: 50 of the 65 flavor columns
became unreachable, and an extended FLAV_IDX -- the only way to ask for one --
returned 0 without a word, because SMATRIX_SPLITORDERS' range guard treats an
index above NFLAV as an unresolvable flavor. Same generation without the
squared order folds to the same 3 directories and DOES write the machinery, so
the loss was invisible unless you counted subprocesses across the two.

The cause is that the decision was made per FORMAT while the machinery is
written per TEMPLATE. `standalone` is in _crossing_folding_formats, so the
interface kept the folded crossings; but write_matrix_element_v4 picks
matrix_standalone_splitOrders_v4.inc for any process carrying split_orders,
and that template has no crossing holes -- fill_crossing_replace_dict fills
holes it does not have.

_crossing_needs_expansion now answers per amplitude: a folding format still
gets the crossings expanded back when the amplitude carries a squared order.
Expanding is the same output the user gets from --use_crossing=False, so
nothing is approximated and nothing is lost -- verified byte for byte: every
generated source of `p p > j j QCD^2==4 --use_crossing=True` (8 dirs, 65 flavor
columns) is now identical to the --use_crossing=False build, matrix.f,
check_sa.f, check_sa_born_splitOrders.f, f2py_matrix_wrapper.f and
flavor_dispatch.py alike.

The call had to move out of the grouped path's `if not
_output_folds_crossings()` for the predicate to be asked at all: `output
standalone` takes the grouped branch (checked, not assumed), where the format
gate short-circuited it. The rest of that block -- MG_SPLIT_CROSSING peeling,
the decay-chain regeneration -- stays behind the gate and is untouched.

This is the safety net, not the feature: folding a squared-order process is
still worth having and needs the crossing holes plus a split-orders snippet
set in that template. Until then the flag degrades to the complete explicit
output instead of a wrong one.

Unaffected, checked: `p p > j j --use_crossing=True` without a squared order
still folds to 3 directories with the machinery written; madevent is unchanged
(it is not a folding format, so its crossings were already expanded, with and
without squared orders).

Tests: the crossing/C-parity acceptance suite is 67/68, and the one failure
(test_inclusive_crossing_xsec_matches, "no subprocess group is served by a
crossing router") reproduces identically at 602235d, so it is not from this
change. test_export_v4 unit tests unchanged (same 3 pre-existing reds). No
IOTest golden is affected: every suite pins crossing off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
do_add parsed --use_crossing and set self._use_crossing, and THEN called
check_add. With no model imported yet, check_generate imports the Standard
Model for the user, and do_import calls clean_process(), which resets exactly
those attributes. The LOCAL merge_crossing was already computed from the flag,
so generation still folded the crossings onto their base; the exporter reads
the ATTRIBUTE, found it False, and wrote no decoder for them.

That silently drops partonic channels. `p p > j j --use_crossing=True` came out
as 3 subprocess directories reaching 15 of its 65 flavor columns; the other 50
had no entry point at all, and the extended FLAV_IDX that names them returns 0
in silence, because SMATRIX reads an index above NFLAV as an unresolvable
flavor. The same generation with `import model sm` on the line before was
correct, which is why this survived: every test in the crossing suite imports
its model explicitly.

Resolving the flag AFTER check_add is the fix. The pop has to stay where it
is -- the flag must be out of `args` before check_add validates them -- so only
the resolution and the merge_crossing it feeds moved down, past check_add and
past the `add model` early return that has no use for either.

It also turns the madevent crossing router back on, which was dark for the same
reason and not (as I first assumed) because madevent cannot fold: routing is
not folding, it needs opt['use_crossing'] and nothing more. `p p > t t~ j j
--use_crossing=True` now writes matrix<i>_router.f in P1_gq_ttxgq and
P1_qq_ttxqq, the two the acceptance suite documents, and it does the same WITH
a squared order (`p p > j j QCD^2==4` routes P1_gq_gq and P1_qq_qq). So madevent
does support crossing with squared orders; it was only ever switched off.

Verified:
  * `p p > j j --use_crossing=True` with no model line now produces exactly
    what the same script with `import model sm` produces, and what
    602235d produces with that line -- matrix.f byte-identical per
    subprocess, plus check_sa.f, f2py_matrix_wrapper.f, flavor_dispatch.py.
  * Crossing/C-parity acceptance suite 68/68. It was 67/68 before this, the
    failure being test_inclusive_crossing_xsec_matches, "no subprocess group
    is served by a crossing router" -- which reproduced at 602235d and is
    what this repairs.
  * Squared-order standalone still expands rather than folds (8 dirs, 65
    flavor columns), as the previous commit intends; crossing off is
    untouched. test_export_v4 (same 3 pre-existing reds) and test_cmd 15/15.

Correction to the previous commit's message: its closing "p p > j j
--use_crossing=True without a squared order still folds to 3 directories with
the machinery written" was measured on this bug, so the machinery was in fact
NOT written in that run. The statement holds now, and held then for a script
with an explicit model import; the conclusion that a squared-order process must
not fold is unaffected -- rechecked here with the import in place, where
602235d folds to 3 directories and still writes no decoder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
matrix_standalone_splitOrders_v4.inc now carries the crossing machinery, so a
squared-order standalone output folds its crossed subprocesses onto their base
like any other. `p p > j j QCD^2==4 --use_crossing=True` goes from 8 subprocess
directories to 3, and all 65 flavor columns stay reachable -- through the
extended FLAV_IDX rather than through a directory of their own.

The previous commit made that case EXPAND instead, because folding into a
template with no decoder dropped the crossed subprocesses outright. That was
the safety net; this is the feature, and _crossing_needs_expansion goes back to
answering on the format alone.

NO TEMPLATE SPLIT WAS NEEDED, though the option was open. Every crossing
addition sits behind a hole of its own, and with crossing off every hole
reproduces what was there before -- so the FKS and MadLoop borns, written from
this same template and always uncrossed (a perturbative process breaks crossing
symmetry, so use_crossing is False for them by construction), are untouched.
Duplicating a thousand-line template to express that would have been worse than
one fill function.

fill_crossing_replace_dict_so is that function, and it is separate from
fill_crossing_replace_dict rather than shared with it: this SMATRIX has ANS and
T as VECTORS over the squared split orders and MATRIX as a subroutine, so the
default template's snippets do not fit -- `T=MATRIX(...)`, `ANS=ANS/IDEN*...`
and `IF (T.NE.0D0)` all have to become loops or read BUFF. Sharing them would
mean a template with a hole it cannot fill.

Three things had to come with it:

  - use_crossing_ic. The external NSF flag has to be read through IC(i) for a
    crossed leg to flip, and this template was excluded outright. It threads a
    meaningful IC (SMATRIX passes JC, MATRIX passes it on to GET_AMP), so the
    exclusion was only ever "crossing is not supported here"; it is keyed on
    the template now rather than on split_orders.

  - NTRY_CSYM is back, in the ON fill only. The C-parity scan must not be
    advanced or consulted by a crossed call -- a crossing permutes and
    sign-flips the helicities, so a base-row negation is no longer the partner.
    With crossing off there are no crossed calls and NTRY remains that counter,
    which is why the OFF fill keeps it.

  - matrix_template_has_pdg_decoder. The crossing demonstration in check_sa.f
    was gated on the DEFAULT template's hole name, so it stayed dark for a
    folded split-orders output that could perfectly well run it. It asks the
    real question now -- "does this matrix.f define GET_PDG_FOR_FLAVOR" -- and
    both holes answer it.

Crossing OFF is unchanged. The only difference in the emitted file is
FLAV_IDX -> FLAV_USE, which is the same value there (the entry guard still
rejects an index above NFLAV when there is no crossing to name), plus the
comments. Checked numerically, not just read: u u~ > u u~ QED^2==2 and
g g > t t~ QCD^2==4 give the same check_sa value to the last digit, all 65
flavor columns of p p > j j QCD^2==4 are byte-identical through the flavor
sweep, and the MadLoop born of u u~ > u u~ [virt=QCD] still returns
2.8276928588371737.

Crossing ON, validated two ways:

  - test_split_orders_merged_flavor_crossing_every_flavor, the split-orders
    twin of test_merged_flavor_crossing_every_flavor: every flavor of the
    merged `_quark _anti_quark > g _quark _anti_quark QED^2==0` crossed onto
    `_quark g > _quark _quark _anti_quark QED^2==0`, matched through
    GET_FLAVOR_INDEX rather than by index, to 1e-11. Sweeping the whole table
    is what catches a crossed denominator built from the representative flavor
    instead of the actual one. It also asserts the crossed value is not exactly
    zero, which is what a matrix element with no decoder returns.

  - p p > j j QCD^2==4 end to end: the folded build's crossing demonstration
    against the unfolded build, matched by signed-PDG signature. Every one of
    the 8 shared signatures agrees to at most 1.6e-16 (one ulp), and the folded
    build reaches 22 further signatures that returned 0 before.

Tests: crossing/C-parity acceptance suite 69/69 (68 + the new one).
test_export_fks same 2 pre-existing reds, test_loop_exporters same 1,
test_export_v4 same 3, test_cmd 15/15. IOTest goldens move again for every
split-orders matrix element (the FLAV_USE rename), on top of the
check_sa_born_splitOrders golden the first commit moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Qubitol Qubitol modified the milestone: Alpha release Aug 18, 2026
oliviermattelaer and others added 8 commits August 25, 2026 11:57
Leg polarization ({0}/{T}/...) selects helicity STATES on a named leg, and a
crossing moves legs between the initial and the final state. The per-row
polarization gate (IS_BORN_HEL_SELECTED) would therefore have to re-read the
selection through the crossing to mean anything, and nothing did that: a
polarized process was free to fold crossings and then consult the selection with
the base's leg numbering.

Beam polarization is already refused outright against crossing
(common_run_interface.py:6886, EVA at 6895); this is the same decision for the
per-leg case, taken at GENERATION so that no crossing is ever recorded rather
than emitted and then mis-consulted at run time.

Verified on the predicate itself:
  p p > z j       leg pol [[],[],[],[]]        breaks_crossing=False
  p p > z{0} j    leg pol [[],[],[0],[]]       breaks_crossing=True
  p p > w+{T} j   leg pol [[],[],[1,-1],[]]    breaks_crossing=True

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…end shares

The standalone matrix element applied SIGMA -- permute the helicity slots AND
sign-flip -- while the recycled optim and the madmatrix lanes can only realise
TAU, sign-flip in place, because they bake the helicity values and take no
permutation at run time. Two maps for one concept is what produced the -28.5%
cross-section bug (crossgroup_helunion.dat carrying sigma where tau was read),
and it meant no single good-helicity relation described every backend.

APPLY_CROSSING_TABLE now leaves the NHEL slots alone and lets the sign ride on
IC, which HELAS folds into its spinors as nhel*nsf. IC stays aligned with the
MOMENTA -- it is also the NSF direction flag -- so slot XK gets P_IN(PERM(XK))
with NHEL_IN(XK), and the effective helicity is NHEL_IN(XK)*SGN(XK). CROSS_GHIDX
gates in the same space (TGT(K), not TGT(PERM(K))): the gate has to live where
the evaluation lives.

No union was needed. SMATRIX resolves the crossing per call, so it gates
exactly on tau(h); the union G U tau(G) exists only for the optim, which bakes
one row list shared by every crossing and so cannot gate per crossing.

The relation test is restated, not weakened. It asserted the crossed good-hel
set equals the identity's mapped through sigma -- true by construction of the
old code. It now asserts equality through tau, which still holds because tau is
a clean bijection (each leg's states are closed under negation). Equality, not
containment.

Prerequisite, committed separately: a polarized leg now breaks crossing
symmetry. Leg polarization selects helicity STATES on a named leg and a crossing
moves legs across the initial/final boundary, so IS_BORN_HEL_SELECTED would have
to re-read the selection through the crossing; beam polarization was already
refused outright against crossing, and this is the same decision per leg.

VALIDATION NOTE WORTH KEEPING: none of this can be checked on a Mac without
meson. With the stock toolchain the whole suite reports "Ran 23 tests ... OK"
while silently skipping every test that evaluates a crossed matrix element (the
exit code is 1, the visible verdict is OK). These runs used a throwaway venv
with numpy+meson+ninja+six, which is what made the two sigma-pinned failures
visible at all.

Measured with that venv: TestStandaloneCrossSymmetry 23/23,
TestGoodHelCParityDedup + TestCheckCrossingCommand 6/6 including the
standalone_mg7 backend -- 33 tests, every |M|^2 comparison among them. Before
restating the probe, tau failed exactly the two sigma-pinned relation tests and
passed the other 21, which is how the map was confirmed correct rather than the
tests merely made green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Main landed two renames the branch predates -- standalone (Fortran) is now
standalone_fortran, standalone_mg7 (madmatrix) is now standalone, bin/mg5_aMC is
bin/madgraph, the madmatrix backend tokens are scalar/simd_128/simd_256/
avx512y/simd_512, and the product is MadGraph7 -- and removed three features
the branch still carried. The merge translates the branch to the new
vocabulary and honours the removals rather than resurrecting them:

  - BLAS colour sum (madevent + fortran standalone): gone again, including
    the hel-recycling path, which now always takes the unfolded GET_MATRIX
    route. madmatrix keeps its BLAS, as on main.
  - colour reflection folding (9f8b1b3): get_jamp_folding and the
    JFOLD/COLREP gather are gone; the tests asserting on them are updated.
    get_color_data_lines keeps plain=True for the FKS templates, which
    cannot read the INIT_CF-compressed matrix either.
  - standalone_cpp: TestStandaloneCppCrossSymmetry, the cpp check-crossing
    backend and its driver are dropped.

Conflict resolutions worth knowing:

  - beam polarisation (main) x C-parity dedup and crossing (branch): the
    dedup is gated off when a beam is polarised (the partner row has the
    opposite beam helicity), and a crossed FLAV_IDX with a polarised beam
    stops with an error rather than weighting the wrong leg.
  - matrix_madevent_v4.inc: fixes an orphan ENDDO/ENDIF left by f54bc1c
    (the FLIP DATA commit), which broke every non-grouped madevent output.
  - MadSpin decay.py / interface_madspin.py taken from main: the branch's
    changes there were crossing-only, dead with crossing off by default.

Onium (main) against branch-only machinery:

  - get_matrix_template returns the onium template itself, so
    matrix_template_provides answers for the file actually written.
  - _helstate_data collapses each constituent pair to its 2J+1 states, the
    rows get_helicity_matrix enumerates (it crashed on list.index).
  - _module_color_flows gives no flows for a bound state, so madevent writes
    the onium-aware ICOLUP table instead of the colour-code tables.
  - ALOHA's spacelike-width test takes the value of a dual (p-wave)
    momentum: DBLE of a dual stays dual and did not compile.
  - test_quarkonium_standalone: the u a > Upsilon u chib2 reference moves
    1.8195973e-20 -> 1.8195052e-20, the t-channel Z width now dropped where
    the momentum is spacelike; zerowidth_tchannel False reproduces main's
    value to every digit.

Also fixed while here: a polarized leg (af590a2) or a bound state breaks
crossing symmetry for the exporter, but generation still RECORDED the
crossings onto the base, so p p > z{0} j --use_crossing=True came out as the
g q directory alone, the q q~ and g q~ subprocesses reachable from nowhere.
Generation now keeps such a process unfolded (also for a polarized decay
leg); test_crossing_breaking_process_keeps_every_subprocess pins it.

Known drift, left for regeneration: the IOTest goldens of
testIO_sqso_uux_uuxuuxx, testIO_export_matrix_element_v4_{standalone,
madevent_group,madevent_nogroup}, testIO_test_{ppw_fksall,wprod_fksew} and
testIO_UnitProcOutputIOTests (the last three already red before the merge).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Main replaced its per-test jobs by bundles (257 -> 77 jobs). The crossing
branch had added its own jobs next to the ones main folded away, and the
three workflow conflicts were exactly those blocks. Main's bundles are taken
as they are; the branch's tests go back in the same style instead of as
separate jobs:

  - acceptancetest.yml: acceptancetest_crossing_bundle -- crossing static,
    fortran standalone + C-parity dedup, madmatrix + check crossing, and
    the madmatrix OpenMP build (four jobs before).
  - acceptancetest_madevent.yml: acceptancetest_madevent_crossing_bundle --
    crossed LHE labels, router colour selection, crossing cross sections,
    folded layout + reweight labels, density multicore (five jobs before).
  - unittest.yml: TestAverageDensityMatrix / TestPdgForMeCall become a step
    of unittest_new_coverage, which now restores the pip cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every madevent launch in it failed within seconds on 'lhapdf-config: No
such file': the default LO PDF is an LHAPDF set (NNPDF4.0) since main moved
to it, and the crossing jobs this bundle replaces predate that and only
restored the pip cache. The one step that launches nothing passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MadSpin rebuilds its production processes from the banner's proc card,
flags included, so an event sample generated with --use_crossing=True
handed that flag to MadSpin's own generation. For the density modes that
is output standalone_fortran --density=1, which folds: p p > w+ j came out
as the single g q > w+ q directory carrying the crossing machinery, where
the default gives all six subprocesses. MadSpin finds a production matrix
element per event flavor, through the processes the matrix element lists
and the FLAVOR-array entry points, and reaches no folded crossing that way
(main's MadSpin has no crossing support at all).

without_crossing() pins --use_crossing=False on every generate / add
process MadSpin runs -- production, full and decay, density and legacy
modes alike -- dropping any flag already on the line; [...] lines are left
alone, as reweight does in its density mode. Pinned where the command is
built, so the logged line is the one that runs.

Checked end to end on p p > w+ j, decay w+ > e+ ve (default spinmode, i.e.
the density path): with --use_crossing=True in the proc card MadSpin now
writes the same six directories as the default, none with crossing code,
and decays all 200 events. MadSpin unit tests: 616 OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…idth too

The expected FFV2_3.f came out of the main merge half converted: the
branch's spacelike-width IF for FFV2_3, but main's single-line denominator
for FFV2_4_3, the assembled routine main added. ALOHA writes both through
the same t-channel switch, so both carry the IF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two tests the main merge left inconsistent:

- test_density_mode_multicore (kept from the branch) calls
  read_average_density_matrix, a static helper that went with the density
  section main rewrote. Restored verbatim.

- test_standalone_cpp: the standalone_fortran cross-check main added reads
  6.4739191, the value with the width kept in the gluino exchanged in the
  t/u channels. The branch drops it -- the gluino is an external field
  (zerowidth_external) and its momentum is spacelike (zerowidth_tchannel),
  either rule alone is enough -- and gives 6.4739329, the value the C++
  assertion above already had. Measured: with both rules set to False the
  merged tree gives 6.4739191277160320, main's number to every digit; the
  per-helicity amplitudes differ only in the t/u-channel diagrams. The C++
  assertion's comment blamed a helicity-summation reorder, which cannot move
  a double at the 5th digit; it now says what does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

3 participants