Skip to content

Crossing symmetry#49

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

Crossing symmetry#49
oliviermattelaer wants to merge 44 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)
  • change NHEL that instead of a lookup table (complicated for crossing) is a radix encoding of the helicity
  • 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"
  • 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"
  • supported in standalone_mg7
    • can be check via the command "check crossing p p > j j j --output=standalone_mg7 --simd=sse4"
    • check command works for other simd format
  • 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!)
  • Madevent handling:
    • Crossing within the same group:
      • a single matrix.f
      • multiple auto_dsigX.f
      • metadata generated as in the case of grouping
    • crossing-equivalent MEs in different P directories share one compiled ME via symlink;
      • the dependent keeps its own PDF/PS/leshouche/configs and routes its auto_dsig to the base SMATRIX.
      • Per-flavour event maps: flavour route (DSIG_XGROUTE), CONFIGMAP (multichannel diagram pairing), COLMAP,
        helmap.
      • Shared base .o via crossgroup.mk + a single parallel top-level makefile for all P dirs.
      • hadronic p p also uses cross-group sharing; crossing limitation tag extended to cross-group.
    • Helicity recycling: A cross-group base keeps all configs in the shared optim (exact; still recycles wavefunctions).
      • helicity needs a flipping associated to the permutation
        - [x] Block beam polarisation + EVA under crossing (clear crash rather than silent wrong results).
  • New tests/acceptance_tests/test_standalone_cross_symmetry.py (multi-process crossing checks) and additions to test_standalone_madevent_consistency.py.

Validation:

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

Known limitations / follow-ups

  • IOTest references for the changed ALOHA propagator routines (TestAlohaWriter/*) still need regenerating (reserved for the maintainer).
  • IOTest for madevent
  • 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 <[email protected]>
…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 <[email protected]>
…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 <[email protected]>
…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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
…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 <[email protected]>
… 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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
…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 <[email protected]>
…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 <[email protected]>
…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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
…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 <[email protected]>
…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 <[email protected]>
@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 <[email protected]>
@oliviermattelaer

Copy link
Copy Markdown
Contributor Author

I'm looking to a new way to encode helicity to avoid to have to do a mapping. If it works we can try similar (but more complex?) idea for the color as well.

oliviermattelaer and others added 2 commits July 24, 2026 09:09
Adopt the canonical helicity code (phase 1) in madevent and use it to replace
the precomputed base->dependent helicity map with a runtime encode.

get_nhel (auto_dsig): the per-event helicity label is the mixed-radix code, so
GET_NHEL<i> decodes it (per-leg NHSTATE/STATES) instead of indexing an NHEL
config table. Byte-neutral: the stored id is the full-table row, which for a
non-polarized process equals the canonical code, and the per-leg STATES order
matches get_helicity_lines by construction. Drops the auto_dsig NHEL table.

Crossing: a dependent's event is written through its own get_nhel, which decodes
the DEPENDENT code, while the shared base SMATRIX selects a BASE code -- so
relabel by permuting the code's mixed-radix digits with the crossing permutation
(GET_CROSS_PERM). This is exact because dep_states[k] == base_states[PERM[k]]
with no reversal (the incoming->antiparticle wf flip and the crossing's
conjugate + initial/final swap cancel), making the relabel a pure digit
permutation over the base NHSTATE -- no STATES, no sign. Applied in both the
within-group router (Track A, was HELMAP) and the cross-group auto_dsig
(Track B, was DSIG_XGHEL); both precomputed maps are removed, as are the now
dead _crossgroup_helmap / _crossgroup_remap_decl.

Validated byte-identical vs the old maps: u u~ > d d~ (get_nhel decode == table,
all 16 codes; identical events/xsec), p p > j j (both tracks; xsec 6.967e8),
p p > w+ j (chiral W; xsec 2.163e4); 39/39 standalone cross-symmetry tests pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
End-to-end guard for the crossed-helicity label in the LHE (phase-4 GET_NHEL
decoder + phase-5 runtime crossing encode). p p > w+ j puts the W+ -- a massive
vector with three helicity states -- in a leg the crossing moves (u g > w+ d,
...), so a bug in the base->crossed helicity relabel scrambles its helicity.
The test runs a small madevent generation and asserts the W+ polarisation is
physical: all three helicity states populated, the two transverse states
chirally asymmetric, and the longitudinal (0) fraction inside a physical window
-- a scrambled relabel reads a quark leg's +-1 into the W+ slot and breaks this.
Thresholds are loose (survive PDF/param updates) but catch the failure modes.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@oliviermattelaer

Copy link
Copy Markdown
Contributor Author

Done with having coding of NHEL representing the value of the helicity (rather than an arbitrary look-up table).
This is obviously slightly slower (need to encode/decode) but at the end much more practical (in particular with crossing but also for density matrix/...
Tell me what you think about such idea, I can think of implementing something similar for the color-flow.
(likely more complicated since this is how particles connect but likely doable)

Documentation on the encoding to interface that with madspace:

Writing event helicities with the canonical helicity encoding (madspace)
This PR changes how a matrix element (ME) labels the helicity it selects for an event. A consumer that writes per-particle helicities (LHE SPINUP, the density matrix, …) must interpret that label with the rules below. Momenta and |M|² are unchanged — only the helicity label.

  • The convention: the selected helicity is a self-describing canonical code
    The ME no longer returns an arbitrary row index. It returns one 1-based integer — the canonical mixed-radix code — that fully describes the helicity of every external leg.

Per external leg k (1..nexternal):

NHSTATE[k] = number of physical helicity states (2 for a fermion / massless vector, 3 for a massive vector, 4/5 for spin-3/2, spin-2).
STATES[d][k] = leg k's helicity values, i.e. get_helicity_states(allow_reverse=True) for the Fortran backends (allow_reverse=False for C++/cudacpp — each backend is internally self-consistent, so decode with the same backend's tables).
Last external leg = least-significant digit (matches itertools.product / the old NHEL order):

code = 1 + Σ_k digit[k] · Π_{j>k} NHSTATE[j]

  • Decoding a code → per-leg helicities
    w = Π_{j>ipart} NHSTATE[j]
    digit = ((code - 1) // w) mod NHSTATE[ipart]
    helicity[ipart] = STATES[digit + 1][ipart]
    You don't have to reimplement it — every ME exposes it:

Fortran madevent (auto_dsig.f): GET_NHEL(code, ipart) → code>0: the helicity of leg ipart; code=0: NHSTATE[ipart].
Fortran standalone: DECODE_HEL(code, config), GET_NHEL(iden, table), SMATRIXHEL(P, code, flavor_idx, ans).
So the writer is just SPINUP[ipart] = get_nhel(code, ipart) for ipart = 1..nexternal.

  • Without crossing
    Nothing special: each subprocess ME returns a code in its own enumeration; decode as in §2.

  • With crossing
    A crossed subprocess shares a base ME, called with an extended index FLAV_IDX = cross·nflav + flav.

    • Option 1: Recommended: use the subprocess's own SMATRIX / get_nhel (crossing is transparent). The Fortran madevent relabels internally — the router / auto_dsig permutes the base code's digits by the crossing permutation, so the code handed back is already in this subprocess's enumeration. Decode it with this subprocess's get_nhel. Identical to §3, no crossing code on your side.

    • Option 2: If you call the base ME directly (e.g. today's C++/cudacpp Bridge, §5), the code is in the base leg order. Convert with the permutation:
      CALL GET_CROSS_PERM(FLAV_IDX, PERM, SGN, FLAV_USE) ! PERM[k] = base leg landing in this subprocess's slot k (1-based)
      for ipart in 1..nexternal:
      SPINUP[ipart] = get_nhel_base(code, PERM[ipart]) ! base ME's get_nhel, read at the permuted leg
      Critical: copy the helicity value unchanged — do not multiply by SGN. SGN is the NSF/momentum sign; it belongs to |M|² (nh = nhel·nsf), not the label. Applying it double-counts and silently reverses every crossed leg's helicity — invisible on non-chiral final states, wrong for e.g. w+ w- j j. (dep_states[k] == base_states[PERM[k]], no reversal — the incoming→antiparticle wf flip and the crossing conjugate+swap cancel.)

  • Backend status
    Fortran standalone + f2py + madevent — fully in this PR; §4a works out of the box.
    C++/cudacpp (standalone_mg7) — non-crossing already returns the canonical code (the selected ihel is the mixed-radix code). The crossed relabel is not yet wired into the C++ allselhel selection (a scoped follow-up), so for a crossed flavor apply §4b with the generated cross_perm / GET_CROSS_PERM until it lands.

  • Regression guard
    test_standalone_cross_symmetry.py::TestMadeventCrossingHelicity.test_w_helicity_asymmetry_ppwj — a small p p > w+ j run must write a physical, chirally-asymmetric W⁺ polarisation; a scrambled relabel breaks it.

Summary:

tl;dr for madspace: the selected helicity is now a self-describing 1-based mixed-radix code; write SPINUP[ipart] = get_nhel(code, ipart). If you go through the per-subprocess SMATRIX/get_nhel, crossing is transparent. If you call the base ME directly (C++ today), read the base code at GET_CROSS_PERM's permuted legs — and never apply SGN to the label.

@theoheimel

Copy link
Copy Markdown
Contributor

Yes, I think using a smart encoding makes a lot of sense. If the encoding/decoding speed is an issue (I'd be surprised though), we can also consider a less compact but easier to decode representation with a fixed width of 3bits per particle for up to 8 helicity states.

@oliviermattelaer

Copy link
Copy Markdown
Contributor Author

Ok we agree, let me try to do the same for the color of the events then.

oliviermattelaer and others added 8 commits July 24, 2026 14:52
First step of the colour analogue of the canonical helicity encoding. A colour
flow is labelled by its connectivity once the INITIAL-state legs swap their
colour/anticolour roles -- the LHE convention runs initial-state colour lines
"through", so without that flip a label sits in the same slot on two legs and
the flow is not a colour<->anticolour bijection. Ordering the colour and the
anticolour slots by leg (a gluon holds one slot of each kind, a sextet two),
digit i is the anticolour slot that colour slot i connects to and
code = sum_i digit_i * N^i.

Adds _color_flow_canon / _color_flow_code / _color_flow_codes, and refactors
_router_colmap to share the canonical form rather than keep its own copy (the
generated output is byte-identical; only run artefacts differ).

The code is injective over a process's colour basis -- verified up to
g g > g g g (24 flows over 5 colour slots) by the new test -- so it identifies
a flow without a per-process flow table. It is also crossing-covariant:
relabelling the legs with the crossing permutation carries a base flow's code
onto the crossed process's own flow code (checked against _router_colmap for
p p > j j and p p > j j j, all flows, zero mismatches), which is what will let
a crossed subprocess drop the base->dependent colour map.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds the inverse of _color_flow_code. Decoding needs only the code and the
process's SLOT STRUCTURE (_color_flow_slots: which legs carry a colour resp. an
anticolour index) -- and that structure is FLOW-INDEPENDENT, being fixed by the
colour representations after the initial-state flip rather than by which flow is
picked. It is therefore the colour analogue of the per-leg helicity-state
counts, and it is all a consumer needs to rebuild a flow from its code.

The new test asserts, for every flow of every process checked (up to
g g > g g g, 24 flows over 5 colour slots), that decode(code(flow)) reproduces
the flow's canonical connectivity exactly and that the slot structure does not
vary between flows. That round trip is the guarantee required before colour
tags are rebuilt from the code instead of read from the generated ICOLUP table.

Still keeps ICOLUP as the source of the tag LABELS, so the generated output and
the events are unchanged; only the machinery is added.

Known gap: a leg carrying two slots of the same kind (a sextet) needs encode and
decode to agree on the tie-break between its slots -- untested, flagged in the
docstring.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
End-to-end guard on the colour written to the event, the colour counterpart of
the p p > w+ j helicity-asymmetry test.

Structure: every event's tags must form a clean colour<->anticolour bijection
once the initial-state legs swap roles (the canonical form the colour-flow code
is built on) -- each colour label matched by exactly one anticolour label. That
is what breaks first if the flow written to the event is ever rebuilt wrongly,
which is exactly the risk when the tags start being decoded from the canonical
colour-flow code instead of read from the ICOLUP table.

Physics: g g > t t~ dominates (measured 87%), and its TWO colour flows (three
connections each) are both populated and balanced -- measured 50.5/49.5, the
symmetry of the two ways to connect the gluons to the top line. A colour
selection stuck on one flow, or producing a wrong topology, fails here.
Thresholds are loose (gg > 0.6, each flow within 0.25-0.75) so PDF/param drift
does not false-fail.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…ric)

Replaces the p p > t t~ colour test, which was too weak to be useful: the two
g g > t t~ colour flows are related by a symmetry and split 50/50 (measured
50.5/49.5), so the test passed just as happily with the two flow labels SWAPPED
-- precisely the bug it was meant to catch.

u u~ > u u~ instead splits ~98/2 (measured 2932/68 of 3000 events), so the test
can pin down WHICH flow is which and a swap inverts the ratio. The dominant
topology is identified topologically -- whether each colour connection stays
inside the initial/final groups (signature II+FF, ~0.977) or crosses between
them (IF+FI, ~0.023) -- rather than by raw leg indices, so the check does not
depend on leg ordering.

The structural half is kept and still applies per event: the tags must form a
perfect colour<->anticolour matching in the canonical (initial-flipped) form,
which is what breaks first once colour tags are rebuilt from the canonical
colour-flow code instead of read from ICOLUP.

Also measured on the way (not asserted, for the record): u d > u d exposes a
single LHE flow -- the event-level basis is leading-colour, so the 1/N piece is
not a separately selectable flow -- and g g > g g splits 2 dominant (~0.317
each) vs 4 suppressed (~0.09 each).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…canonical code

Track A step (ii) of the colour encoding: the base returns its selected colour
flow in ITS flow order, and the router must express it in this subprocess's.
That went through COLMAP_<proc>_<flav>, one explicit map per base-flavor pair,
precomputed by _router_colmap. It now goes through the canonical colour-flow
code instead:

  decode the base's code with the base's slot structure
    -> relabel both legs of every connection with the crossing permutation
    -> re-encode in this subprocess's slot order
    -> look the result up in this subprocess's own code table

_color_code_tables(me) supplies the three arrays that needs -- codes, colour-slot
legs, anticolour-slot legs -- and they are per-ME, so every crossing sharing a
base reuses them, where COLMAP needed one array per pair. It is the same trick
already used for the helicity, with one difference noted in _color_flow_code: the
helicity relabel is a pure digit REORDER, while colour needs a permutation
CONJUGATION (digit positions and digit values both relabel).

The direction of that relabel is not ambiguous even though it looks like it
should be: get_crossing_permutation builds two DISJOINT transpositions and marks
the overlapping 3-cycle codes invalid, so a crossing is always an involution and
perm == inv. A probe over p p > j j, j j j, w+ j j, t t~ j and t t~ j j confirmed
it on all 16 crossing pairs, with the relabelled connections landing on exactly
the dependent's own slot structure every time.

_router_colmap is kept and is still the fallback for an ME with no usable code
(no colour basis, or a flow that is not a clean colour<->anticolour bijection);
none of the processes exercised here needed it.

Not converted, deliberately: Track B's DSIG_XGCOL. It is not a label relabel like
COLMAP -- it permutes the base's published per-flow JAMP2 into the dependent's
flow order so the dependent can run its OWN select_color (the native reselect).
That reorder is intrinsic, it survives the next step too, and it is already
derived from the same canonical form, so moving it to runtime would rebuild the
identical array.

Validated: the emitted fortran, transcribed and simulated, reproduces the old
COLMAP /4,3,2,1/ for 1_gQx_ttxQx <- 1_gQ_ttxQ (cross=5); all four code-path
routers compile; and event records are BYTE-IDENTICAL against the previous
generation for p p > t t~ j (500 ev, seed 321) and p p > j j j (500 ev, seed
4242), same cross-section. 43/43 crossing acceptance tests pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
… ICOLUP table

Step (i) of the colour encoding. The event's colour tags came from the ICOLUP
table baked into leshouche.inc; addmothers now rebuilds them from the canonical
colour-flow code, and leshouche.inc stops writing the table.

What did NOT change is the runtime label: ICOL stays the flow INDEX, because it
also indexes JAMP2 and ICOLAMP in the selection. Only the source of the tags
moves. That is what keeps code 0 harmless -- a process with a single colour
connection encodes to 0 (u u~ > z, u d~ > w+, and p p > e+ e- in a real run),
which would have collided with ICOL=0 meaning "no colour" had the code become
the label.

New per-P-directory colorflow.inc carries NCOLSLOT / ICOLCSL / ICOLASL /
ICOLCODE -- the per-flow codes plus the slot structure, which is flow
independent, so it is a handful of integers where ICOLUP was
2*nexternal*nflow. addmothers decodes it: connection k joins colour slot
ICOLCSL(k) to anticolour slot ICOLASL(digit+1), each connection gets its own
tag, and the two roles are swapped back on the initial-state legs, undoing the
reversal color_flow_decomposition applies to follow the les houches convention.

NCOLSLOT=0 marks an ME with no usable code -- no colour basis, a SEXTET (stored
as a negative tag in the opposite slot, a sign a decoder cannot restore, now
refused explicitly), or an epsilon structure. leshouche.inc still writes ICOLUP
for those and addmothers still reads it, so the fallback is intact.

The colour helpers moved from ProcessExporterFortranMEGroup up to
ProcessExporterFortranME: non-group madevent ships addmothers.f too and needs
them. MadWeight is untouched -- it inherits ProcessExporterFortran, ships no
addmothers.f, and keeps drop_icolup=False, so its leshouche.inc is unchanged.
The python readers of leshouche.inc (madweight Cards.read_leshouches_file,
madevent_interface.get_subP_ids) parse only IDUP.

This RENUMBERS the tags: color_flow_decomposition numbers them by the colour
string's traversal order, not by connection order, so the rebuilt tags are a
relabelling. Validated by colour CONNECTIVITY equivalence (tags relabelled by
order of first appearance) with every other event field required to match
exactly: p p > t t~ j 500 ev and p p > j j j 500 ev (grouped) and
u u~ > u u~ 800 ev (non-grouped) all give 0 colour mismatches and 0 other-field
mismatches at unchanged cross-sections. Only 13 of the 500 t t~ j events kept
their raw tags, so the check is not passing by coincidence. p p > e+ e- exercises
the code-0 path end to end: 400/400 events put the colour on the initial quark
and the anticolour on the antiquark.

43/43 crossing acceptance tests pass. IOTests were run read-only (-R): the 26
differing references are identical with and without this change, i.e. all
pre-existing on this branch, and none of them is a leshouche/colour file.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
C4, data layer. get_subprocess_info now also writes color_codes (the canonical
colour-flow code of each flow) and color_slots (the flow-independent colour /
anticolour slot legs needed to decode a code), using the SAME encoding as the
fortran madevent output -- get_color_code_tables delegates to export_v4's
_color_flow_canon / _color_flow_code / _color_flow_slots, so a code means the
same thing on both backends.

color_codes/color_slots are null when the flows have no usable code (a sextet's
negative tag, or an epsilon structure); a consumer falls back to color_flows.
The existing color_flows (ICOLUP-style per-flow tags) is kept: besides being the
fallback, the LHE writer still derives INTERNAL propagator/decay-line colours
from it, so it is not redundant even where a code exists.

Validated against the fortran colorflow.inc: u u~ > g g gives codes [7,11] and
slots color=[2,3,4]/acolor=[1,3,4] (identical to the madevent P1_gg_qq tables);
u u~ > e+ e- gives [0] (the single-connection code, matching p p > e+ e- in
madevent). This is only the encoder side -- the runtime that decodes a code to
LHE tags is a separate step (the mg7 colour selection is currently a stub, see
api.cpp color_out=0).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
C4, ME side. coloramps.h now carries colorflowcode[ncolor] -- the canonical
colour-flow code of each flow -- next to the icolamp colour masks, plus a
colorflowcode_valid flag. This is the "encoding" the ME returns: given the flow
index it already selects (select_col_and_diag, 0-based icolC), the code is
colorflowcode[icolC], a self-describing integer the LHE writer can decode
without an ICOLUP-style tag table.

edit_coloramps computes the codes with get_color_code_tables -- the same encoder
that fills subprocesses.json -- so the C++ table, the json "color_codes", and
the fortran colorflow.inc all agree. colorflowcode_valid is false for flows with
no usable code (a sextet's two-slot leg, an epsilon structure); the code array
is then filler and a consumer falls back to the per-flow tag table. Note the
valid flag is separate from the code value, so code 0 (a single colour
connection, e.g. u u~ > e+ e-) is unambiguous -- unlike the fortran icol=0
"no colour" sentinel, which is why the fortran side keeps the index as the label.

The flow-independent slot structure a decoder also needs is not baked here; it is
static per subprocess and already travels in subprocesses.json as "color_slots".

Validated at generation: u u~ > g g bakes colorflowcode = {7,11} (identical to
the json color_codes and the fortran ICOLCODE), u u~ > e+ e- bakes {0} with
valid=true; the generated CPPProcess.cc (which includes coloramps.h) passes
g++ -std=c++17 -fsyntax-only. The runtime that returns and decodes the code
(api.cpp selection wiring + the madspace decoder) is specified separately -- the
mg7 event-level colour selection is still a stub (api.cpp color_out=0), the same
gap the helicity encode has.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@oliviermattelaer

Copy link
Copy Markdown
Contributor Author

Done for the color encoding:

The matrix element emit a self-describing colour code (the colour analogue of the helicity encoding). The encoder is done on the MG7 side; the runtime that returns and decodes it is the madspace/cudacpp half described below.

What the ME now provides

  • subprocesses.json (per subprocess): color_slots = {"color":[...], "acolor":[...]} — colour/anticolour slot leg numbers (1-based), flow-independent; color_codes — code of each flow (informational); color_flows — old ICOLUP tags, kept as fallback
  • coloramps.h (per ME) : constexpr int colorflowcode[ncolor] + constexpr bool colorflowcode_valid

The code of the selected flow is colorflowcode[icolC], where icolC is the 0-based flow index the ME already picks in select_col_and_diag (allselcol = icolC + 1). Same integer on all three backends — the fortran colorflow.inc, the JSON color_codes, and the C++ colorflowcode[] agree byte-for-byte (u u~ > g g → {7, 11}).

The encoding

Order colour slots and anticolour slots by leg on the initial-state-flipped flow (every colour connects to an anticolour). Then code = Σᵢ digitᵢ·Nⁱ (N = #anticolour slots), where digitᵢ is the anticolour-slot index that colour slot i connects to.

Two runtime pieces to implement (specified, not yet built):

  • api.cpp — return the code. It currently hardcodes color_out = 0 (mg7 colour is a stub today — the same gap as helicity_out = 0). Once the event's colour is selected: (likely fixed in the latest main)

  • madspace LHECompleter — decode instead of table-lookup. Colour enters complete_event_data in exactly one place: the per-external-leg (colour, anticolour) list of the selected flow (color_flows[flavor][color_index][perm_index]). Replace that with a decode of the returned code producing the same length-nexternal array in matrix leg order; everything downstream (internal-line reconstruction, cancellation) is unchanged.

for (int i = 0; i < N; ++i) {                 // N = acolslots.size()
    int digit = (code / ipow(N,i)) % N;
    int tag = 501 + i;                        // fresh; only which legs SHARE a tag matters
    int cleg = colslots[i], aleg = acolslots[digit];
    // colour of cleg / anticolour of aleg, but SWAP those positions on initial-state legs
    (cleg <= n_initial ? anticol(cleg) : col(cleg)) = tag;
    (aleg <= n_initial ? col(aleg) : anticol(aleg)) = tag;
}

Three things to know

  • The initial-state position swap is the one convention to get exactly right — it undoes the flip that defines the code, and it matches the fortran addmothers decoder.
  • Tag numbers are free. Downstream relabels and cancels, so 501+i per event is equivalent to the old color_flows numbering. Events are colour-equivalent, not byte-identical (same as the fortran side).
  • Fallback: colorflowcode_valid == false for colour sextets and epsilon structures (color_codes/color_slots null). Keep the color_flows lookup there. No SM tree process hits this.

color_flows — can be removed (but need validation/developement for sextet/epsilon_ijk, so fallback to it in those case)

Redundant for every valid-code subprocess (decode gives the externals; internal lines derive from them), but required as the sextet/epsilon fallback, so it stays in the JSON.

Validation boundary

Encoder validated (JSON + coloramps.h cross-checked against the fortran output; CPPProcess.cc compiles). The api.cpp/madspace pieces above need a cudacpp build + an mg7 event run to validate end-to-end — the same path the helicity encode is deferred behind.

oliviermattelaer and others added 8 commits July 24, 2026 22:12
…symmetry-3f13f3

# Conflicts:
#	madgraph/iolibs/export_mg7.py
… GHREMAP table)

The good-helicity filter (GOODHEL) is shared across the crossings of a flavor,
but a crossing permutes and sign-flips the helicities, so a crossed row and its
identity counterpart are different rows. The standalone backend bridged that with
a baked GHREMAP(NCROSS*NCOMB) table (crossed row -> identity row); madevent did
not bridge it at all -- it simply disabled the filter for crossed MEs (the
'.OR. .TRUE.' gate) and computed every helicity.

Both now use one shared runtime routine, CROSS_GHIDX: the crossed->identity map
is a fixed permutation, so it is cheaper to recompute it from the config than to
store it -- permute and sign-flip the row's config with the crossing's PERM/SGN
(GET_CROSS_PERM), then re-encode it in the canonical mixed-radix order (the same
STATES/NHSTATE the encoder/decoder use). All that survives as DATA is a small
per-crossing GHFILT flag (NCROSS ints, was NCROSS*NCOMB) marking which crossings
are filterable; a non-filterable one (initial-initial swap, inapplicable, or a
non-bijection) returns GHIDX=0 -> compute every helicity, never train. For
CROSS=0 it returns IHEL, so the uncrossed path is unchanged.

This removes the GHREMAP table from the standalone and, more usefully, gives
madevent good-helicity filtering for crossed MEs for the first time (it had been
computing all NCOMB helicities). madevent precomputes GHIDXA(I) once per SMATRIX
call and gates/trains the shared GOODHEL(FLAV_USE) column through it; the index
is clamped with MAX(GHIDXA(I),1) because the gate reads GOODHEL before the
GHIDXA(I).EQ.0 guard and fortran does not short-circuit .OR. (madevent builds
with -fbounds-check).

Encoding validated in python against compute_ghremap (0 mismatches, t t~ / t t~ j
/ t t~ j j). 43/43 crossing acceptance tests pass on a clean run. Unified madevent
p p > t t~ j gives 576.6 +- 3.1 pb vs the unfiltered 578.8 (0.4%, within MC
error), confirming the filter skips only zero-contribution helicities.

Speed context (standalone microbenchmark, this branch): the runtime encode is
~10x a table load in isolation but invisible once the ME runs (<=4% for the
lightest ME, ~0% for t t~ j j); the table's only real cost was generated-code
size, now removed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
… them (merge_crossing='record')

Stage A of generating crossing-related subprocesses only once. At generation,
MultiProcess.generate_matrix_elements already detects crossings (the crossing-
invariant sorted_legs signature) and has two modes: merge_crossing=False reuses
the base diagrams but still appends a separate amplitude -> separate ME -> separate
directory (the redundant P1_gQx_ttxQx / P1_QQx_ttxg dirs); merge_crossing=True
skips the crossed process entirely but DROPS it, silently losing that partonic
contribution.

This adds a third mode, merge_crossing='record': on a crossing hit, do not
generate a separate amplitude, but record the crossed process (with the base and
crossed leg permutations) on the base amplitude's new 'crossed_processes' slot.
The partonic contribution is not lost -- the base's crossing-aware SMATRIX can
evaluate it via the crossed FLAV_IDX -- and the exporter/driver will reach it
from this metadata (Stages B/C). No diagrams are built for the crossed process.

The slot is added to Amplitude and carried onto HelasMatrixElement (populated in
its constructor from the base amplitude). Nothing is wired into the interface yet,
so the default output is unchanged (merge_crossing=False) -- verified: standalone
p p > t t~ j still writes 4 directories. The two Amplitude unit tests that pin the
exact property set are updated; diagram_generation (50) and helas_objects (65)
unit suites pass.

Verified at generation: with merge_crossing='record', p p > t t~ j yields 2 base
MEs (g g > t t~ g, g Q > t t~ Q); the g Q base carries the 4 crossed subprocesses
(g q~ > t t~ q~, q q~ > t t~ g and their beam-swaps) with permutations -- the
complete partonic set (the base handles g q + q g via its mirror flag).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…ory (merge_crossing='record')

Stage B. With merge_crossing='record' (env-gated for staged rollout) the crossed
subprocesses are no longer generated, so the standalone exporter -- which writes
one directory per matrix element -- stops emitting the redundant P1_gQx_ttxQx /
P1_QQx_ttxg directories: p p > t t~ j drops from 4 SubProcess dirs to 2 (gg, gQ).

The crossed partonic contributions are not lost: the base gQ matrix.f is
BYTE-IDENTICAL to the full-generation one (verified), so its already-validated
crossing-aware SMATRIX evaluates the g q~ / q q~ configs at the crossed FLAV_IDX
exactly as before. check_sa.f now exercises them: the crossing-demo loop in
_get_check_sa_crossing_example (previously a dormant IF(.FALSE.) example) is
enabled precisely when the ME carries crossed_processes, so the folded
subprocesses -- which no longer have a directory of their own -- are the one
place this driver evaluates them, and it does.

Default output is unchanged: without the env var no crossing is recorded,
crossed_processes is empty, the demo gate stays .false., and standalone still
writes 4 directories. TEMP: madevent still generates the crossed MEs separately
(Stage C re-points its Track A/B summation at the metadata); the env gate becomes
the default for crossing-enabled output once both backends read the metadata.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…tadata (merge_crossing='record')

Stage C. merge_crossing='record' does not generate the crossed subprocesses, so
the standalone output folds them into the base directory. The grouped backends
(madevent, mg7/cudacpp) need each crossing back as an integration unit -- it is a
distinct partonic channel with its own PDF and phase space -- so this expands the
recorded crossed_processes metadata into crossed amplitudes just before grouping,
reusing the base's diagrams via MultiProcess.cross_amplitude (no diagram
regeneration). The existing grouping + crossing routing (Track A/B) then handles
them exactly as an unmerged (merge_crossing=False) generation.

Record mode records a crossing and its beam-swap as two separate entries (neither
is in the amplitude list when the other is met, so the generator's mirror check
never fires), so the reconstruction folds the beam-swap back into
has_mirror_process, matching what generate_matrix_elements does -- otherwise an
extra mirror directory (e.g. P1_qg_ttxq) would appear.

Result: record-mode grouped output is BYTE-IDENTICAL to a default build. Verified
matrix1_orig.f / auto_dsig1.f / leshouche.inc identical across every P dir for
p p > t t~ j, p p > w+ j and p p > j j; p p > t t~ j xsec 576.6 pb in both modes;
mg7 record mode generates cleanly. The block is gated on any ME carrying
crossed_processes, so a default (merge_crossing=False) generation is untouched.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…crossings by default)

Flips the staged env gate to the real default: a crossing-enabled generation now
uses merge_crossing='record', so the crossed subprocesses are recorded on the base
instead of generated on their own. Consequences:

* fortran `standalone` output writes one directory per base matrix element -- the
  crossings are folded into the base's crossing-aware SMATRIX (p p > t t~ j: 4
  SubProcess dirs -> 2). This is the visible goal.
* every summation / event-generation backend (madevent, me7, mg7/cudacpp)
  reconstructs the crossings from the metadata at output (do_output), so its
  output is BYTE-IDENTICAL to before -- madevent p p > t t~ j still 3 dirs, base
  matrix.f identical.

The do_output reconstruction is skipped only for self._export_format=='standalone'
(which folds); reconstructing is the safe default for anything else. --use_crossing=False
keeps the complete unmerged generation, and MG_MERGE_CROSSING=off is a debug escape
hatch to the same.

NOTE: this changes the fortran standalone crossing OUTPUT STRUCTURE, so the
standalone-crossing IOTest reference files must be regenerated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…ow the default)

TestCrossingPartition exercises partition_crossing_classes, which routes each
subprocess flavor of the FULL (unmerged) matrix-element list to a base -- exactly
what the madevent output reconstructs from the recorded crossings before grouping.
With merge_crossing='record' now the default, a bare `generate` folds the crossings
away at generation, so there was nothing left for the routing to eliminate. Force
MG_MERGE_CROSSING=off around the generation so the test sees the unmerged modules.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…dalone RAMBO point

The crossed-process demonstration in check_sa.f now shows exactly the
crossings that are real subprocesses of the generation (folded in via
merge_crossing='record'), each at the very momenta a standalone
(non-crossed) run of that subprocess would evaluate -- so the crossed
value is copy/paste-comparable with the subprocess's own check.

- _crossed_signatures: match each folded crossed process LABEL-AWARE
  against the runtime-reachable PDG set, so a merged _quark leg matches
  any same-sign flavor and a flavor-changing (W) vertex pairs correctly
  (fixes the earlier same-flavor rep signature that no W crossing could
  reach). Returns representative signed-PDG signatures + a 'complete'
  flag; incomplete falls back to the full applicable-crossing loop.
- _get_check_sa_crossing_example: emit those signatures into XCSIG and
  show a crossing only when GET_PDG_FOR_FLAVOR matches one; scan FLIP1/
  FLIP2 over all legs (1..NEXTERNAL) so single-leg crossings are reached
  (the old NINCOMING+1.. range only hit double crossings). Print the
  base phase-space point P row k = P(:,k) with the crossed PDG XPDG(k):
  every shown crossing keeps the massive particles final and only
  relabels massless partons, so its per-slot mass pattern equals P and a
  standalone run draws the identical RAMBO point.
- drop the now-unused APPLY_CROSSING/XPUSE/XNHIN/XICIN plumbing.

Verified: p p > t t~ j and p p > w+ j demos reproduce the momenta and
matrix element of standalone g d~ > t t~ d~ / d~ d > t t~ g / g d~ > w+ u~
runs row-for-row; crossing acceptance suite green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants