Skip to content

Crossing symmetry - #49

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

Crossing symmetry#49
oliviermattelaer wants to merge 258 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 16 commits July 22, 2026 13:03
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

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@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 2 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
oliviermattelaer and others added 30 commits August 9, 2026 00:42
…plitude

split_amps groups the unfolded amplitudes of one HELAS line by the wavefunctions
in every column but the busiest, and it found each group by walking the whole
amplitude list again for every combination: `all(w in amp.args for w in wfcts)`,
12.5 million evaluations over g g > g g g g g and the largest single cost left in
the recycling step once the DAG and the line parsing were fixed.

Index instead: one bit per amplitude, per wavefunction name, so a group is the
AND of the masks of its wfcts. Walking the set bits lowest first keeps sub_amps
in new_amps order, which is the order the list comprehension produced, and the
initial mask is all-amplitudes-set so an empty wfcts still selects everything as
`all(...)` over nothing did. A HELAS call never uses the same wavefunction twice,
so a name identifies the column it came from and "is w anywhere in this
amplitude" is the same question as "is w in its own column" -- the two group
identically, which was also measured: 0 of 90 360 groups differ on g g > 5g,
0 of 228 on g g > g g g.

Interleaved A/B, best of three, matrix1_optim.f byte-identical throughout:
g g > g g g g g 4.07 s -> 3.37 s, g g > t t~ g g g g 39.14 s -> 33.75 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit a86fb4e5d7056903d315cb31db9419d7f51cd291)
…ines

The recycled standalone MATRIX carried its own colour sum: a DO K = 1, NCOMB
loop over the rewriter's jamp_lines followed by a plain triangular CF sum. That
copy had no BLAS batch, no reflection folding, ran the C-parity partners whose
AMP row is all zeros, and above all read AMP(K,i) at stride NCOMB -- a fresh
cache line per amplitude, where the standard GET_JAMP reads AMP(i) contiguously.
The amplitude sharing was real (1.73x at g g > 5g) and the colour stage ate all
of it and more: 1.48x SLOWER end to end at 5g, 2.07x at 6g.

It now gathers one helicity's amplitudes out of the helicity-major AMP into a
contiguous buffer and calls the SAME GET_JAMP and colour sum the standard output
uses, so it inherits whatever the colour side gains instead of drifting from it:

  - GET_MATRIX_BATCHV, a per-column variant of GET_MATRIX_BATCH (same two
    DSYMMs, reduced per column instead of into one scalar). The recycled driver
    needs a |M|^2 per helicity row -- SMATRIXHEL and the polarization filter
    select on it -- so the scalar batch was unusable. Emitted only into the
    recycled copy of the shared routines; the two share head and body, and the
    text GET_MATRIX_BATCH emits is unchanged.
  - the rows whose helas calls were never generated are skipped (HRDEAD/HRROW,
    fed by a new ${csym_dead} slot next to ${csym_reuse}), which is exactly 2x
    wherever every row is C-parity paired.
  - the colour flows go through the standard path's COLREPB/NCOLORFOLD, so a
    folding applies here the moment one applies there.
  - the gather takes 8 rows at a time: 8 complex*16 is one 128 byte cache line,
    so a line is fetched per amplitude instead of per (amplitude, row). Measured
    at 5g, NHRBLK 1/2/4/8/16/32/64 -> colour 3.2/2.8/2.4/2.3/2.3/2.7/2.5 ms.

AMP loses its NGRAPHS+ntmp width -- the colour flow temporaries live in the
gather buffer now -- which halves it (519 MB rather than 1.15 GB at 6g).

Steady state per phase space point, standalone, shipped flags, back to back:

  g g > 4g   0.60 ms -> 0.29 ms   2.07x FASTER than not recycling
  g g > 5g   14.5 ms -> 10.0 ms   1.45x
  g g > 6g   0.582 s -> 0.329 s   1.77x   (was 2.07x SLOWER)

with the colour stage itself 13.3 -> 2.3 ms at 5g and 1.20 -> 0.111 s at 6g.
|M|^2 moves by 1-2 ulp and cannot not: the recycled driver has to divide by
DENOM per helicity row where the scalar batch divides once at the end.

Two things this had to get right. Dropping %(color_data_lines)s from the
recycled MATRIX gives NaN rather than zero -- that DATA is the only thing that
fills DENOM -- so the CF/DENOM common stays even though the sum moved out. And
hel_recycle.split_helas_block cut the helas block at `JAMP(` or
`DO K = 1, NCOMB`; with those gone from the standalone driver it swallowed the
gather loop into the last chunk subroutine, so the driver now marks the end of
the block explicitly and the rewriter matches that as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
claude/strange-tesla-b2c15d forked at aab7dc3 and gave the recycled MATRIX
its colour stage back to the shared GET_JAMP + GET_MATRIX_BATCHV, which is what
turns helicity recycling from a loss into a win. This branch had gone on from
the same commit with the things g g > 6g needed to build and run at all. Both
halves are wanted and they compose.

One conflict, in matrix_standalone_hel_v4.inc, and it is the same line twice:
they narrow AMP to (NCOMB,NGRAPHS) because the colour flow temporaries moved
into their gather buffer, and this branch had made AMP allocatable. Resolved as
theirs plus the allocation: the narrowing halves AMP (1.15 GB -> 519 MB at
g g > 6g) but 519 MB is still a fixed-size local linked into __DATA, and it is
__DATA plus 825 MB of __TEXT running into the arm64 dyld shared region that
stops the 6g binary from starting at all. Allocating removes the cliff at any
multiplicity and measures free. Their INIT_CF call and jamp_decl/jamp_tmp_decl
holes correctly go away with the private colour sum -- the shared routines make
that call themselves.

The other two files auto-merged and both sides survive: their END OF RECYCLED
HELAS BLOCK marker (needed precisely because moving JAMP( and DO K = 1, NCOMB
out of the driver made split_helas_block swallow the gather loop) sits next to
this branch's per-core chunk writer, and their GET_MATRIX_BATCHV next to
_hel_recycling_chunk_files.

Validated on g g > 5g after merging: 128/128 good helicities, 64 C-parity pairs
reused, 18 chunk files, |M|^2 6.6739867626784550E-007 against the standard
standalone's ...84560E-007 -- the 1 ulp their commit says cannot not move,
since the recycled driver divides by DENOM per helicity row. Steady state per
phase space point, back to back on one machine:

  baseline                  0.0134 s
  recycled, this merge      0.0093 s      1.44x FASTER

against 0.0218 s, i.e. 1.48x SLOWER, on this branch before the merge.

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

The recycled AMP is helicity major -- one CombineAmp fills one amplitude for a
whole set of rows, so a column of AMP is what the rewritten HELAS block produces
-- and reading a row back walks NGRAPHS entries NCOMB apart, a cache line per
amplitude where the unrecycled matrix element reads AMP contiguously. The colour
stage now copies the row into a contiguous buffer first, NHRBLK=8 rows at a time
so the copy itself reads whole lines, and the rewritten colour flow and AMP2
lines read that. This is the last of the four ingredients of 92475e8 that
madevent lacked.

It only pays at the top end, so it is gated on the size of AMP. Per full ME
evaluation (SMATRIX1: one phase space point, every recycled helicity row, colour
summed), HEAD -> gathered:

  g g > g g g       45 graphs,  14 kB AMP    6.85 us   (+5% if forced on)
  g g > g g g g    510 graphs, 408 kB AMP     113 us   (+10% if forced on)
  g g > t t~ g g g 1890 graphs, 3.9 MB AMP  1.017 ms -> 0.898 ms
  g g > g g g g g  7245 graphs,  13 MB AMP  5.08 ms  -> 5.08 ms
  g g > 6g       126630 graphs, 482 MB AMP  0.539 s  -> 0.276 s

Below the gate the rows sharing a cache line are visited within NHRBLK
iterations of each other anyway, so the hardware already gets that reuse and the
copy is a second pass over AMP for nothing. Above it the working set no longer
fits and the gather is worth 2x. Ordinary processes keep the loop the template
has always carried, byte for byte.

The decision lives in hel_recycle, not the exporter: it depends on the RECYCLED
NCOMB, which only exists at survey time. The two loop holes sit inside a literal
DO/ENDDO pair so the fortran writer still sees the helicity loop and indents its
body -- making the whole loop line a hole silently de-indents and rewraps the
entire colour stage.

AMPK is deliberately not SAVEd: SMATRIX1_MULTI carries an !$OMP PARALLEL over
the matrix element and the link line already passes -fopenmp, so the day FFLAGS
does too, locals become thread private and an explicit SAVE would be the one
thing left shared.

Cross sections unchanged at g g > g g g (3.642e+07 +- 2.868e+05 pb),
g g > g g g g, g g > t t~ g g g and g g > 6g, the last agreeing digit for digit
between the gathered and ungathered builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mg7 exporter can run its color sum on the (n-2)! Del Duca-Dixon-Maltoni
structures while a color flow is still picked among the (n-1)! trace ones, but
set_channels_colors_map built active_colors -- the mask which becomes icolamp --
by walking self.color_basis, i.e. the basis of the color sum. The C++ selection
walks that mask over ncolor_flow entries, so a DDM build declared
icolamp[nconfig][ncolor] and read it as icolamp[nconfig][ncolor_flow]: for
g g > g g a [3][2] array read 6 wide, which reinterprets the neighbouring rows
and then runs off the end of the object. Reading icolamp the way the selection
does gave {1,2,4,5}, {0,2,3}, {0,1,5} for the three configs instead of
{0,1,3,5}, {1,2,3,4}, {0,2,4,5}.

|M|^2 does not depend on any of this, which is why the DDM port passed every
check run so far. Events do: over 3 x 100k g g > g g events the color flow
fractions came out (9.35, 9.38, 31.38, 9.15, 31.30, 9.44)% on the trace basis
against (10.54, 1.39, 32.32, 12.83, 32.46, 10.46)% on the DDM one -- 134 sigma
on the second flow -- at an unchanged cross section (1.2 sigma), since a wrong
color selection never moves the weight.

Give the exporter a color_flow_basis next to its color_basis and build
active_colors, the color_flows table and both nb_color values from it. The two
bases now write the same icolamp, and the same processes agree flow by flow
over 3 x 100k events each: g g > g g worst 2.0 sigma over 6 flows (xsec 0.1),
g g > g g g worst 1.9 sigma over 24 (xsec 0.5), and u u~ > g g, which never
leaves the trace basis, generates identical source and agrees at 0.1 sigma.

The GPU select_col_and_diag kernel had the same ncolor/ncolor_flow mismatch and
is fixed the same way; there is no CUDA toolchain here, so that path is
generated but not compiled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
do_multiline breaks a long statement at the last space which fits in 72
characters. When the only such space is the statement's own indentation the
first chunk is all blanks, so the emitted physical line is empty and the
continuation which follows it is attached by the compiler to the *previous*
statement.

g g > g g g on the DDM color basis is the first process to hit it: the
Kleiss-Kuijf lines rebuilding the trace JAMPs are long and contain no space
of their own, and matrix1_optim.f came out as

        JAMPF(1,1)=+2D0*(+IMAG1*JAMP(6,1))

     $  JAMPF(2,1)=+2D0*(-IMAG1*JAMP(3,1)-...)

which gfortran rejects with "Unclassifiable statement", so the madevent run
dies in the compilation step. Break mid-token in that case, which is what the
no-space-at-all branch next to it already does.

The condition only fires where the old code emitted a blank chunk, so any
matrix element which built before is byte for byte what it was; of the arms
generated here only g g > g g g on the DDM basis contained one.

With this, g g > g g g builds on both color bases and the two agree exactly:
same cross section, and all 10000 color columns of all 2000 events identical
at equal seed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three commits, all of which this branch wants:

  d718ce8  share the orbit equivariant color-flow search with the madmatrix
             backend -- the search moves into jamp_optimiser.JampOptimiser and
             the C++ writer gets it too; the fortran output is byte for byte
             unchanged.
  467862f  mg7: pick the color flow from a mask built on the color flow basis
             -- a DDM build declared icolamp[nconfig][ncolor] and read it
             ncolor_flow wide, so the colour SELECTION was wrong (134 sigma on
             one flow of g g > g g) at an unchanged cross section.
  9f984d9  hel_recycle: never wrap a statement onto a blank physical line.

Two conflicts, and the first is the interesting one: 9f984d9 and this branch
fixed the SAME do_multiline bug independently. Theirs guards after the fact --
if the first chunk came out all blanks, break mid-token instead. Ours refuses
to look inside the indent in the first place, by flooring the rfind at
6+len(indent). Kept ours, having checked the two agree: byte-identical output on
the JAMPF line their commit message reports failing, on the short JAMPF, on a
long JAMP sum and on a plain HELAS call.

The second is tests/unit_tests/madevent/test_hel_recycle.py, which both sides
created. The classes are disjoint -- TestAmpGather here, TestDoMultiline there
-- so both are kept, 12 tests, all passing. Worth having their four even though
our wrapping fix is the one that survives: they are what pins the behaviour if
anyone touches do_multiline again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
input/authors.md is written at release time by bin/create_release.py and is
not tracked in git, so in any dev checkout post_banner's unguarded open()
raised FileNotFoundError.

EasterEgg.__init__ catches it (except Exception: sprint(error)), so the crash
report itself was never actually lost -- MG5_debug is written correctly. But
the swallowed error printed

    DEBUG: [Errno 2] No such file or directory: .../input/authors.md
           [misc.py at line 2103]

three times per crashing run -- startup EasterEgg('loading') at bin/mg5_aMC:155,
then 'error', then 'quit'. Landing that line next to "More information is found
in 'MG5_debug'" made it look like the debug write had failed.

Return "" when the file is absent, and skip lines that do not split into
exactly two fields, since a malformed file would produce the same misleading
noise on the crash path.

Verified: forced exception during output, authors.md noise 3 -> 0 with
MG5_debug still carrying the real traceback; with an authors.md present the
CONTRIBUTOR OF THE DAY banner still renders and blank/malformed lines are
skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--use_crossing was parsed by do_add only, stored on cmd._use_crossing and
read from there by ExportV4Factory/ExportCPPFactory. On an output line it
fell into the generic line_options dict and was silently dropped, so
`output standalone_mg7 X --use_crossing=False` wrote the full crossing
machinery anyway (byte-identical to the default build).

Parse it in both commands through a shared pop_use_crossing_flag, and AND
the output-line choice into opt['use_crossing'] in both factories.

Turning the machinery off at output time is not just an exporter flag: the
generation runs merge_crossing='record', so the crossed subprocesses are
folded onto their base and never generated on their own. Dropping the
machinery without putting them back would give a silently incomplete output
-- the failure mode do_add's comment says must never be reachable from this
flag. _output_folds_crossings() therefore gates the three expansion
decisions, so a folding backend told to skip the crossing expands the
recorded crossings back into explicit subprocesses, exactly as a
non-folding backend already does.

Verified byte-identical to the generate-line flag over the whole
SubProcesses tree for g g > t t~ g g g (one directory) and for q q > q q,
q = u d u~ d~ (one folded directory -> three expanded), on both standalone
and standalone_mg7. The crossing-off mg7 source has the plain external
HELAS calls and cNGoodHel loop bounds. The default build is unchanged, and
the flag does not leak to a later output in the same session.

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

`output standalone_mg7 g g > g g g g g` silently produced an unbuildable
process directory: a 0 byte coloramps.h and no CPPProcess.{cc,h} at all.

edit_coloramps asked `self.color_basis` for a colour flow decomposition
while baking the canonical colour flow codes. For a fully adjoint
(all-gluon) process standalone_mg7 auto-selects the (n-2)! Del Duca-Dixon-
Maltoni basis (madmatrix/output.py sets support_ddm_color_basis and
ddm_needs_flow_basis), whose elements are products of f's with no single
flow each, so color_flow_decomposition raises ColorBasisError by design.
Every other consumer had already moved to the trace basis carried
alongside -- self.color_flow_basis here, get_flow_basis() on the fortran
side -- and this one site was missed. Non-adjoint processes were never
affected: DDM does not engage, and get_flow_basis() returns the basis
itself.

The failure was invisible because coloramps.h was opened before any of
its content was computed, so the exception left the truncated header
behind; open it only once the content is built.

Verified on g g > {3,4,5}g: flow counts are exactly (n-1)!, coloramps.h
is byte-identical to a `set color_basis trace` build, gg>3g compiles and
check_sa gives the same |M|^2 from both bases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The C++/standalone_mg7 good-helicity scan walked every crossing code that
cross_perm_ic finds structurally APPLICABLE, which is far more than an event
can ever carry: g g > t t~ g g g has 48 applicable codes and records none.
Each one costs a full ncomb-helicity calculate_jamps sweep, so the scan ran
49152 kernel calls instead of 1024 -- a one-off ~1 s of startup that
check_sa's `perf 1 32 8` amortises over only 8 iterations and therefore
reports as a 4.2x matrix-element slowdown. The per-event cost was never
affected; the tell is [Min,Max]TimeInMatrixElems, whose Min matched the
pre-crossing build all along.

_scanned_crossings now emits a cross_recorded[] table of the crossings this
ME actually folded in, the scan gates on it, and calculate_jamps carries a
runtime guard: an applicable-but-unrecorded code would otherwise find an
empty cGoodHelOfCross row, mask every helicity and return a silent zero, so
it aborts with a message instead. Structurally invalid codes keep their
existing "the denominator ASSIGNS 0" contract and are exempt.

The set is deliberately not built from _folded_crossing_flavorids: that
collapses mirror pairs (it is a demo helper) and the runtime may hand us
either member -- p p > w+ j records {4,20} but also reaches {9,22}.

This narrows a tested capability: the backend could previously be driven with
any applicable crossing code on a base that recorded nothing. Three tests
relied on that; they now use `pq pq > pq pq` (pq = g u u~), whose g g > q q~
dir genuinely records cross 3 (g u~ > g u~) and cross 23 (u u~ > g g), so the
same physics is still checked against standalone references. Two of them also
needed FPTYPE=d: the makefile default 'm' runs the colour algebra in single
precision, putting differently-ordered evaluations of the same |M|^2 ~1e-7
apart, a hundredfold above the 1e-9 tolerance they compare at.

g g > t t~ g g g is back to base timing (perf 1 32 8, measured back-to-back
under one load: base 6.05e-2, before 2.44e-1, after 6.07e-2) and matrix
elements are bit-identical on gg>ttxggg, p p > w+ j and q q > q q. The
acceptance suite is unchanged at 66 tests with the same 2 pre-existing
failures and 2 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`output matchbox` died on every process with

    TypeError: get_JAMP_lines() got an unexpected keyword argument 'proc_prefix'

because the ProcessExporterFortranMatchBox override never grew the
proc_prefix (nor symmetry_source) parameters the base signature gained. It
takes both now and forwards proc_prefix; orbit and symmetry_source are
accepted and go no further, since jamp_orbit_allowed already says no for
this exporter and the template declares neither the orbit tables nor
INIT_JAMP.

Behind that, matrix_standalone_matchbox_splitOrders_v4.inc had missed three
migrations, each only visible once the previous one was fixed:

  - CF/DENOM were left undeclared in BORN (the commented-out
    `REAL*8 CF(NCOLOR,NCOLOR)` is a fossil of the pre-packed 2-D colour
    matrix) while the routine still substitutes color_data_lines, and
    TMP_JAMP was missing there too.
  - the flavor machinery: the HELAS calls take FLAVOR, rebuilt from
    FLAV_IDX, and neither existed in MATRIX or BORN.
  - the aloha objects: still COMPLEX*16 W(18,NWAVEFUNCS) with no
    use model_object / use aloha_object, against rank-1 W(i) calls.

None of the entry points of that template carries a flavor argument, so
FLAV_IDX is pinned to the first column of the table - the canonical flavor,
which is what these routines evaluated before FLAVOR reached the HELAS
layer. Merged subprocesses can therefore only be evaluated for that one
flavor; threading the index through would change an API Herwig's Matchbox
calls, so it is left as a separate decision.

madloop_matchbox shares that template and failed differently: it supplies a
proc_prefix from the start, so the colour DATA came out as MG5_0_CF against
an unprefixed declaration. CF and DENOM are plain locals of each matchbox
routine, so color_data_prefix now writes those statements without a prefix.
Plain matchbox is unaffected (it reaches that point before its own prefix is
set) and so is every other exporter.

Verified by compiling the generated code, which the exporter's own make
never does (it is a no-op): 11 subprocesses over four LO outputs, covering
TMP_JAMP(72) and NMASK_FLAV up to 28, plus born_matrix.f from a
madloop_matchbox output of g g > t t~ [virt=QCD]. Also fixes GET_JAMP in
matrix_standalone_matchbox.inc leaving LNJAMP implicitly typed, which made
the named COMMON 40 vs 64 bytes.

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

Companion to the all-gluon standalone_mg7 fix: the missing input/authors.md
was what made the crash noisy and misleading (3x DEBUG line next to the
MG5_debug notice), though the debug report itself was always written.

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

--use_crossing was parsed by do_add only and silently dropped on an output
line. Parsed in both through a shared pop_use_crossing_flag, ANDed into
opt['use_crossing'] in both exporter factories, and _output_folds_crossings()
gates the expansion so a folding backend told to skip the crossing writes the
folded subprocesses back out instead of losing them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in --use_crossing on the output line (51f55cb), the colour-flow-basis
fix for the flow codes (8d10421) and the authors.md post_banner guard.

Conflict: _output_standalone_mg7 gained a parameter on both sides -- theirs
`out_options` (output-line flags), mine `color_basis` (needed because two
outputs compared number-by-number must share a colour basis, the sum being
accumulated in a different order in each). Kept both; the body had already
merged both additions.

Their new --use_crossing tests are unaffected by the recorded-crossing
narrowing in ebe7bc1: they exercise the crossing-off source and the
identity crossing, and test_invalid_overlapping_swap_returns_zero drives an
overlapping swap, which the guard deliberately exempts (spincol_cross == 0
keeps its "the denominator ASSIGNS 0" contract).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
matrix_standalone_matchbox_splitOrders_v4.inc pinned FLAV_IDX to 1 in
MATRIX and BORN, so only column 1 of FLAV_TABLE was reachable: for
`p p > j j QCD^2==4` that is 1 of the 28 flavor combinations of
P1_QQx_QQx, and 65 columns across the eight subprocesses collapsed to 8.

It is threaded now, with exactly the argument lists
matrix_standalone_splitOrders_v4.inc already uses, because the callers
were already written against those lists and the pinned template did not
match them:

  - check_sa_born_splitOrders.f (the shared driver, written next to every
    split-orders matrix element) calls SMATRIX_SPLITORDERS(P,FLAVOR,
    MATELEMS) -- three arguments against a two-argument subroutine.
  - loop_matrix.f of a madloop_matchbox output calls SMATRIXHEL_SPLIT-
    ORDERS(P_USER,USERHEL,IC,BORNBUFF(0)) -- four against three. ANS was
    bound to IC, so the Born went into an INTEGER array of ones and
    BORNBUFF, which MadLoop reads back as the Born ME and as its
    stability reference REF, stayed zero. The same mismatch was fixed for
    the non-matchbox MadLoop in 89d968a; the matchbox template was
    missed.

Both callers pass an all-ones INTEGER array where FLAV_IDX is expected,
which resolves to its first element, so the canonical flavor is what they
still get -- bit-identical to before -- and MadLoop now gets a Born at
all. A new template unit test pins the four argument lists together.

The entry points are an external API (Herwig's Matchbox binds them by
name), but the non-split matchbox template had already moved to
SMATRIX(P,FLAV_IDX,ANS) and BORN(P,NHEL,FLAVOR); keeping the split-orders
one pinned would have left Herwig with two different BORN signatures
depending on whether the process has split orders. BORN here takes the
same FLAVOR(NEXTERNAL) array and resolves it with GET_FLAVOR_INDEX,
zeroing JAMP/LNJAMP for a combination the ME does not cover.

Three things had to come with the index, none of which the pinned
template needed:

  - GOODHEL/NTRY are now per flavor. Which helicity rows vanish depends
    on the flavor, so one shared table would filter rows out for the
    flavor that scanned first.
  - BROKEN_SYM, and with it the GET_FLAVOR_INDEX/GET_FLAVOR helpers,
    which the template did not emit at all. IDEN is one constant per
    matrix element: the merged `_quark _quark > _quark _quark` carries
    IDEN=72, right for u u > u u and a factor 2 wrong for u c > u c.
  - an out-of-range FLAV_IDX returns zero instead of indexing off the
    end of the per-flavor arrays.

Verified by compiling the generated code, which the matchbox exporter's
make never does, and by running it. All eight subprocesses of the LO
output compile. Their 65 flavor columns were evaluated at one phase-space
point and every one of them reproduces, to better than 1e-12 relative,
the dedicated subprocess it stands for in an `apply_flavor_grouping
False` output -- including the 6+6+12 columns that need BROKEN_SYM.
Column 1 of each is bit-identical to the pinned build. A madloop_matchbox
output of u u~ > u u~ [virt=QCD] builds and runs check_sa_born_split-
Orders, which returns 2.8276928588371737, bit-identical to the same
driver built from a plain `output standalone` of that process.

The matchbox IO test reference is regenerated. Beyond born_matrix.f, it
also picks up three files that were already stale on this branch: the
LNJAMP declaration of 73ca0f8 in the non-split matrix.f, and the
dormant crossing demo in check_sa.f plus a blank line in
f2py_matrix_wrapper.f from earlier merged work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`output matchbox` writes two drivers beside every matrix.f and neither
could be built. The exporter's `make` is a no-op, so nothing ever said so.

check_sa.f: prefixed, and cut down to what the template provides.

  Matchbox names every routine after the process id (MG5_1_SMATRIX), but
  write_check_sa was handed the --prefix of the output line -- empty
  unless asked for -- so the driver called SMATRIX and GET_FLAVOR_INDEX
  by their unprefixed names. The prefix now comes from one place,
  get_proc_prefix, which write_matrix_element_v4 uses as well, so the two
  cannot drift again.

  That is not enough on its own: the driver also carries a density block
  and a crossing demonstration calling GET_DENSITY and
  GET_PDG_FOR_FLAVOR, which only the default matrix template writes.
  get_matrix_template / matrix_template_provides answer what the file
  being linked against actually contains, and both blocks are emitted
  behind that. This is not matchbox-specific: `output standalone` on any
  split-orders process (p p > j j QCD^2==4) shipped a check_sa.f that
  failed on _get_pdg_for_flavor_ too.

f2py_matrix_wrapper.f: not written for matchbox.

  It is written against the default template's API and calls GET_value,
  GET_value_idx, GET_DENSITY and IS_BORN_HEL_SELECTED -- none of which
  either matchbox template has -- and treats MATRIX as a function where
  the split-orders one has a subroutine. Supplying all that means porting
  the density/value stack into matchbox for a python interface nobody
  uses: Herwig links the Fortran directly. write_f2py_interface turns it
  off, together with flavor_dispatch.py and the matrix2py rules finalize
  appends to SubProcesses/makefile for a wrapper that is no longer there.

check_sa_born_splitOrders.f and nsqso_born.inc: written where they belong.

  Both went through a bare filename, i.e. into whatever directory mg5_aMC
  was launched from -- nsqso_born.inc long enough ago that it has a
  .gitignore entry. They now go next to the matrix element, derived from
  the writer's own path, which leaves MadLoop (which chdir's first)
  exactly where it was. The split-orders driver also gets the prefix, and
  the makefile stops linking it over the check_sa binary.

While verifying, the newly-working driver turned up a wrong number, so:
only fold the color sum for a template that reads a folded matrix.

  get_color_data_lines writes the folded color matrix -- one row per
  reversal pair, off-diagonal doubled -- for whatever template is in use,
  but reading it back needs the JFOLD gather that only
  matrix_standalone_v4.inc and the madevent templates have. Everything
  else sums CF straight, so the folded matrix was silently misread:

    matchbox      g g > g g   47.68  instead of 55.18
    standalone    g g > g g   Infinity  (split-orders template with
                              `set color_basis trace`)
    MadLoop born  g g > g g   Infinity  (`output standalone` on
                              g g > g g [virt=QCD])

  All three now return 55.179250628823411, which is also what the default
  standalone gives through the completely different DDM color basis.
  The gate is on the standalone exporter, so madevent (a separate class,
  and its templates do read a folded matrix) is untouched, and outputs
  that were already correct are byte-identical: matrix.f is unchanged for
  every subprocess of p p > j j and p p > j j QCD^2==4. The FKS born is
  written from born_fks.inc, which has no gather either, through a path
  get_matrix_template does not describe -- it is only covered here by
  accident and needs its own fix.

Verified by compiling and running the generated code. All eight
subprocesses of the LO matchbox output build check_sa and run it; the
merged flavor columns still reproduce their dedicated (grouping-off)
matrix elements to 1e-12, with g g > g g moving to the corrected value on
both sides. `make check_sa_born_splitOrders` builds and runs in both the
LO matchbox and the madloop_matchbox directories, the latter unchanged at
2.8276928588371737. Matchbox default_opt also regains 'output_options',
without which the class could not be constructed at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The CPU branch of sigmaKin runs the event-page loop under
'#pragma omp parallel for default( none )', which means every variable the
loop body touches has to be named in the shared() clause -- anything missing
is a hard compile error, not a warning. Three sigmaKin arguments used inside
the loop were absent from it:

  iflavorVec        passed to calculate_jamps
  allrnddiagram     the storeChannelWeights test and the diagram sampling
  allDiagramIdsOut  the sampled diagram id written back per event

so the generated CPPProcess.cc did not compile at all once OpenMP was on.
All three are plain (non-const-qualified) pointer parameters, exactly like
allmomenta and allChannelIds which were already listed, so none of them is
predetermined-shared. Add them to _OMPLIST1.

Nothing caught this because nothing ever builds the OpenMP path: it is opt-in
via USEOPENMP=1 (#758), madmatrix.mk force-disables it on Darwin, and no CI
job nor any contrib/ driver script sets it -- they all ship the export
commented out.

The new acceptance test therefore does not go through USEOPENMP either. It
takes the compile command the generated makefile itself would run (from
'make -n', so it keeps following the real build flags) and re-runs it with
whatever OpenMP flags the local compiler accepts, which keeps it meaningful
on macOS as well as on the gcc CI runner. It then checks the object really
carries an OpenMP runtime call, so it cannot go vacuous if the parallel
region is ever compiled out.

Verified by hand beyond the test: with the OpenMP flags forced on, g g > t t~
and g g > t t~ g build and run, and check_sa.exe gives output byte-identical
to a serial build at 1, 4 and 8 threads.

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

get_color_data_lines can emit the color matrix in three forms, and two of them
are a contract with the file being written, not plain DATA: the compressed
encoding leaves the entries to a run-time INIT_CF, and the folded form writes
one row per JAMP reversal pair with the off-diagonal doubled. The method is
shared by every fortran exporter, and the FKS one fills templates that honour
neither -- born_fks.inc, born_fks_hel.inc, the four split-orders ones (born,
bhel, real, cnt) and the sudakov goldstone one all declare
CF(NCOLOR*(NCOLOR+1)/2), sum it straight, and carry no INIT_CF call.

2822859 gated the folding on ProcessExporterFortranSA.get_jamp_folding, which
asks get_matrix_template -- what write_matrix_element_v4 writes. The FKS writers
do not go through it, so the gate only covered them by accident: it reported the
gather-less split-orders template because an aMC@NLO process always carries
split orders (amcatnlo_interface fills them with every coupling order of the
model, so the list is never empty). Measured on g g > g g [real=QCD] at a fixed
phase space point, against 88.00353346603369 from `output standalone` of the
same born: SBORN = 88.00353346603369 as shipped, born.f carrying the full 21
entries for NCOLOR=6 -- and 17.35289594455156 with the gate bypassed, where
born.f gets 6. The templates really cannot read a folded matrix; only an
always-true predicate stood between that and a wrong born.

The encoding is the same hazard without even the accident. INIT_CF is emitted
by write_matrix_element_v4 alone, so an FKS matrix element whose basis crossed
the size threshold would have shipped a CF with no DATA and no routine to fill
it, i.e. silently zero. Out of reach at the NCOLOR the current templates see,
unconditional once it is not.

So the question is asked where the answer is known -- at the call site, which is
the only place that knows which template it is about to fill. get_fks_color_data_lines
passes plain=True and every entry of the upper triangle is written.

Not jamp_fold = False on the exporter: it writes V0_*/born_matrix.f as well,
through write_bornmatrix, and that one *is* write_matrix_element_v4 and may keep
both compact forms. Not teaching get_jamp_folding the template either: one
exporter instance fills five of them, so it would need this same plumbing to
answer at all, for an answer that is uniformly "written out in full".

Generated code is unchanged: born.f, born_hel.f and all five matrix_<i>.f come
out byte-identical, and SBORN still gives 88.00353346603369.

msP/msF were checked the same way and are covered by construction --
get_matrix_template names their templates outright. That only shows up in the
trace basis: the default DDM basis puts g g > g g at NCOLOR=2, too small to
fold, so a default msP/msF test proves nothing. With `set color_basis trace`
both emit 21 entries where the default standalone emits 6 plus NCOLORFOLD.

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