Skip to content

madspace: 2 -> 1 processes with PDFs, one energy and one PDF per beam - #154

Closed
oliviermattelaer wants to merge 9 commits into
mainfrom
claude/madspace-2to1-topology-pdf-2da50a
Closed

oliviermattelaer wants to merge 9 commits into
mainfrom
claude/madspace-2to1-topology-pdf-2da50a

Conversation

@oliviermattelaer

Copy link
Copy Markdown
Contributor

Summary

  • 2 → 1 with PDFs in madspace (p p > z, p p > h): Diagram accepts one outgoing leg for a collision. PhaseSpaceMapping gets a 2 → 1 branch: s_hat = m², one random number (the rapidity), weight 2π |ln τ| / s. The flat mode accepts 3 masses. Leptonic 2 → 1 and massless final states raise a clear error, and so does the launcher.
  • One energy and one PDF per beam in run_card.toml: [beam] ebeam1, ebeam2, pdf1, pdf2.
    • beam.e_cm (= 2√(E1E2)) and beam.pdf remain readable and settable; setting either sets both beams.
    • Cards written with e_cm / pdf are translated on read.
    • set ebeam1 … works from launch scripts, and LO→MG7 conversion keeps asymmetric beams.
  • Frames: the phase space is generated in the beams' centre-of-mass frame and boosted by y₀ = ½ ln(E1/E2) into the lab frame before the cuts. Events are written in the user's frame and rapidity cuts act there. pT, masses and ΔR for massless particles are boost invariant.
    • Chili's y bound is widened by |y₀|.
    • Colour-ordered ΔR bounds are not used when y₀ ≠ 0; the cut itself still applies.
  • Mirror handling for asymmetric beams (bug this change would otherwise have introduced). The Integrand used to mirror accepted lab-frame events and never swapped the PDFs. That is only valid for identical beams.
    • With PhaseSpaceMapping(mirror_beams=True) the orientation is a mirror_index condition drawn before the mapping.
    • The mirror is taken in the beams' frame, then boost, then cuts.
    • Each leg uses the PDF of the beam it comes from (_pdfs_swapped); mirror flavours weigh 2, others 1.
    • Identical beams keep the existing post-cut path unchanged.
  • Second PDF grid: passed to Integrand (pdf_grid2, globals prefix beam2) and SystematicsCalculator (nominal_pdf2, also reloaded by the gridpack).
    • With different sets, PDF member variations are dropped with a warning; the scale variations use both sets.
    • <mgrwt> beam labels follow the beam direction.
  • LHE <init> carries both beam energies and both LHAPDF ids.

Validation

All runs use a fixed μ = 91.188 and NNPDF4.0 LO unless stated otherwise.

check mg7 reference
p p > z, 6.5+6.5 TeV 49225 ± 17 pb madevent 49220 ± 27 pb
p p > e+ e-, 7+4 TeV, ptl>10, ΔR>0.4, no η cut 1817.4 ± 2.9 pb madevent 1814 ± 1.9 pb
same, ⟨y_ee⟩ in the LHE 0.281 ± 0.016 madevent 0.249 ± 0.016 (before the mirror fix mg7 gave 0.001)
same with |η_l| < 2.5 941.2 ± 1.9 pb, max |η| in LHE = 2.500 |η_lab| < 2.5 applied offline to madevent's no-cut sample: 947.5 ± 6.5 pb
p p > z, pdf1=NNPDF40 / pdf2=NNPDF23 vs swapped 44211 ± 75 vs 44223 ± 75 pb, identical scale bands 43315 vs 45121 pb before the mirror fix
  • New madspace/tests/test_beam_frame.py (9 tests) covers the 2 → 1 mapping and its inverse, boosted mappings (rambo/propagator, hadronic/leptonic), lab-frame η cuts and mirror_beams.
  • madspace pytest: 1541 passed, 11 skipped. The one earlier failure was the new test file, fixed. test_flow/test_mlp need torch and were not run; lhapdf python bindings were stubbed out because they abort on this Python 3.14.
  • test_banner.py RunCardMG7 tests: 17/17, including new per-beam and legacy-card tests.
  • test_mg7_launch.py: 40/40. test_keyboard_interrupt_reaches_the_error_handling failed once in a full run; it also fails alone on the base commit, so it's a flaky test that predates this PR.

Notes

  • madevent with ebeam1 ≠ ebeam2 is not a valid reference for η cuts. With etal = 2.5 its LHE leptons span η ∈ [−2.5, 2.5 + 2y₀], and its cross section is ~4.5% below the lab-frame value. Not touched here.
  • fact_scale1/fact_scale2 follow the legs, not the beams, for mirrored events. This only matters for fixed and different per-beam factorisation scales.
  • DifferentialCrossSection(input_momentum_fraction=False) still reconstructs x from energies assuming symmetric beams. The launcher never uses that mode.
  • Mixed lepton–hadron beams (a PDF on one beam only) are not part of this change.

🤖 Generated with Claude Code

oliviermattelaer and others added 2 commits September 15, 2026 09:26
2 -> 1 (p p > z, p p > h): a collision Diagram may have one outgoing leg.
PhaseSpaceMapping integrates dPhi_1 = 2 pi delta(s_hat - m^2) against the
luminosity: s_hat is fixed at the mass and the single random number sets the
rapidity. Leptonic beams or a massless final state are refused.

run_card.toml [beam] now has ebeam1/ebeam2 and pdf1/pdf2. beam.e_cm and
beam.pdf stay readable and settable (both beams), and cards written with
e_cm/pdf are translated on read. The phase space is generated in the beams'
centre-of-mass frame (s = 4 E1 E2) and boosted by 0.5 ln(E1/E2) into the lab
frame before the cuts, so events are written, and eta cuts applied, in the
frame the user gave.

Asymmetric beams break the Integrand's mirror shortcut (mirroring accepted
lab events with unswapped PDFs). With mirror_beams the orientation is drawn
before the mapping, the mirror is taken in the beams' frame, and each leg
takes the PDF of the beam it comes from. Identical beams keep the old path.
The second beam's grid is passed to Integrand and SystematicsCalculator
(pdf_grid2 / nominal_pdf2); PDF member variations are dropped when the sets
differ, scale variations use both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The run card now stores pdf1/pdf2, so the `pdf = "..."` substitution
matched nothing and the reference runs silently used the default
NNPDF4.0 set instead of NNPDF23_lo_as_0130_qed (3jets, dy1j, ttx1j and
ttx_decay off by 2-43%). Pin pdf1 and pdf2 (or a legacy pdf line) and fail
loudly when nothing was pinned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The initial-state mirror covers the beam-swapped copy of a subprocess by
rotating the event by pi about x (py, pz -> -py, -pz, kernel_mirror_momenta),
which moves each leg onto the other beam. With mirror_beams the mapping draws
the orientation first, so the rotation happens before the boost into the lab
frame and before the cuts. Without it the Integrand keeps the cheaper path:
generate, cut, then mirror the accepted event. Everything downstream of that
mirror then sees an orientation nothing upstream ever looked at.

Two places were reading the wrong side of it.

The matrix element. Integrand::build_common_part kept feeding the cross
section momenta_acc, the momenta as generated, while the event was written
mirrored. It now takes the mirrored set when there is one, which is what
madevent does (it flips the momentum array that then goes to both the matrix
element and the event record, super_auto_dsig_group_v4.inc "Flip momenta
(rotate around x axis)") and what SystematicsCalculator::matrix_elements
already assumed, since it reads the momenta back out of the event buffer.
This is the same one-line change as "madspace: evaluate the matrix element on
the momenta the event is written with" on the mirror-me-momenta branch,
carried onto the post-cut path that only exists here; on the mirror_beams
path momenta_acc is already the written orientation and nothing changes.
Feeding the unmirrored momenta only ever worked because |M|^2 is invariant
under the rotation -- true for any Lorentz invariant matrix element, false
for a polarised one evaluated in a frame that holds the polarised particle at
rest, where HELAS quantises the spin along the frame z axis (48% on
g q > z{+} q in the Z rest frame, 23 sigma between p p > z{+} j and
p p > z{-} j end to end).

The cuts. Nothing stated that the cuts have to be blind to the mirror, and
nothing enforced it; it happened to hold because pt, |eta|, delta_r, mass and
sqrt_s all are. Observable::mirror_invariant classifies each observable (and
the one it is ordered by -- sorting by a quantity that flips picks a different
particle), Cuts::non_mirror_invariant_cuts names the offenders, and Integrand
refuses the post-cut mirror when any cut is one, next to the asymmetric-beams
check it already had. mg7 does not hit that refusal: launch.py now turns
mirror_beams on for a subprocess whose cuts are not mirror invariant, the same
switch asymmetric beams already trigger, and logs why.

This is not latent. The run_card [cuts] table takes any observable name, and
the template even advertises "eta" -- p p > e+ e- with lepton-eta.min = 0.0:

  post-cut mirror   276.3 +- 2.1 pb, 1017 of 2000 events (50.9%) written
                    with a lepton at eta < 0, i.e. outside the cut
  orientation first 233.9 +- 2.1 pb, no event outside the cut

and the second is right: lepton-eta.max = 0.0, which a pp collider's symmetry
forces to be equal, gives 232.9 +- 2.2 pb (0.3 sigma). The old number is 18%
high because it integrates one beam orientation twice, and a single ordering
of a valence-sea pair is forward-skewed.

Nothing on the invariant-cut path moves: default cuts keep the post-cut mirror
(no switch logged) and g _quark > e+ e- _quark gives 59.0349 +- 0.0490 pb
against the 59.0235 +- 0.0287 reference, 0.2 sigma.

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

Copy link
Copy Markdown
Contributor Author

Pushed b44c4a78f: the mirrored orientation is the one the cuts and the ME see

Follow-up to the mirror_beams machinery this PR adds. The post-cut mirror that identical beams still use (_mirror_before_cuts == false) rotates the event by pi about x after everything upstream has looked at it, and two consumers were reading the wrong side of that rotation.

The matrix element

Integrand::build_common_part fed the cross section momenta_acc, the momenta as generated, while the event was written mirrored. It now takes the mirrored set when there is one. Same one-line change as "madspace: evaluate the matrix element on the momenta the event is written with" on claude/madspace-mirror-me-momenta, carried onto the post-cut path that only exists on this branch — so expect a trivial conflict there when that lands, resolved by keeping either copy.

On the mirror_beams path nothing changes, and I checked that rather than assumed it: to_lab applies the mirror inside the mapping, before the boost into the lab frame and before the cuts, so momenta_acc is already the written orientation and momenta_mirror_acc is never produced.

The cuts — the actual point

Nothing stated that the cuts have to be blind to the mirror, and nothing enforced it. It happened to hold because pt, eta_abs, delta_r, mass and sqrt_s all are.

  • Observable::mirror_invariant() classifies every observable under py, pz -> -py, -pz, with no default: so a new observable has to declare which side it falls on. It also checks the ordering observable — sorting by a quantity that flips picks a different particle out of the event. An observable that matched no particle is the constant 0, so it counts as invariant (Cuts::build_function_impl already skips those).
  • Cuts::non_mirror_invariant_cuts() names the offenders, and Integrand refuses the post-cut mirror when there are any, next to the asymmetric-beams check this PR already added.
  • launch.py turns mirror_beams on for a subprocess whose cuts are not mirror invariant — the same switch asymmetric beams already trigger — and logs why, so mg7 never hits the refusal.

Option (a) from the two on the table, and the identical-beam fast path stays bit-for-bit for every cut set mg7 ships. Same distinction Template/LO/SubProcesses/cuts.f draws with PMIR under EBEAM(1).NE.EBEAM(2).

This is not latent

The run-card [cuts] table takes any observable name, and the template comment even advertises eta. p p > e+ e-, 2000 events, lepton-eta.min = 0.0:

cross section events written outside the cut
post-cut mirror (before) 276.3 +- 2.1 pb 1017 / 2000 (50.9%)
orientation first (after) 233.9 +- 2.1 pb 0

and the second is the right one: lepton-eta.max = 0.0, which a pp collider's symmetry forces to be equal, gives 232.9 +- 2.2 pb — 0.3 sigma from the new number, 14 sigma from the old. The old value is 18% high because it integrates one beam orientation twice, and a single ordering of a valence-sea pair is forward-skewed.

Nothing on the invariant-cut path moves

Default cuts keep the post-cut mirror (no switch logged), and g _quark > e+ e- _quark gives 59.0349 +- 0.0490 pb against the 59.0235 +- 0.0287 reference — 0.2 sigma, 0.019% relative.

Tests

22 new tests in madspace/tests/test_beam_frame.py:

  • the per-observable classification, including the ordering observable and the absent-particle case;
  • a mapping-level test that with mirror_beams the cuts act on the momenta that come out, for both orientations — and that a signed eta cut makes the two orientations disagree (627/2000 events flip verdict) while eta_abs cannot, which is exactly why the fast path is sound for the cuts mg7 ships;
  • the Integrand precondition (refused post-cut, allowed with mirror_beams, allowed for unmirrored flavors);
  • graph-level tests that the matrix element consumes the mirror_momenta output on the post-cut path and momenta_acc on the mirror_beams path.

I reverted each fix and rebuilt: exactly those two intended tests go red, nothing else. Full madspace suite 1569 passed / 11 skipped / 4 failed, the four being No module named 'scipy' in test_double_t.py, identical before the change.

One gap

No UpdateNotes.txt entry here. main restructured that file (new 0.3.0 section, which is where the ME fix's own entry sits), so adding one on this branch would manufacture a conflict in exactly that spot. The cut half needs a line next to it when the two meet.

🤖 Generated with Claude Code

oliviermattelaer and others added 4 commits September 18, 2026 10:55
Conflicts, all from the madspace documentation PR (#92) meeting this
branch's API additions; no logic conflicted:

- systematics.hpp, cuts.hpp, observable.hpp, integrand.hpp: main's Doxygen
  comments next to new members (_nominal_pdf2, the mirror-invariance
  queries, pdf2_prefix). Both kept; the new members' comments turned into
  Doxygen and the pdf_grid2 / nominal_pdf2 argument notes moved into the
  constructors' @PARAM lists.
- phasespace.hpp: main's Doxygen for random_dim_for, extended with the
  2 -> 1 case and @throws; beam_rapidity / mirror_beams documented as
  @PARAM of both constructors and mirror_index added to the documented
  conditions; the new accessors documented.
- madspace.cpp: main's pydoc::doc() bindings, with beam_rapidity,
  mirror_beams, pdf_grid2 and nominal_pdf2 inserted before the docstring,
  main's external_masses argument name kept, and the new PhaseSpaceMapping
  and Cuts methods added to main's documented method chains.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to the merge of origin/main (12e2e16):

- Observable::mirror_invariant(ObservableOption), the static per-option
  overload, becomes option_mirror_invariant() in observable.cpp's anonymous
  namespace. Nothing outside that file called it, and as an overload with its
  own doc comment it gave generate_docstrings two entries for one name
  (Observable::mirror_invariant / #2), which is why the Python method was
  bound without a docstring. It now gets pydoc::doc like every other binding.
- clang-format on the Integrand error message and black/isort on
  test_beam_frame.py, which the madspace pre-commit hooks run.

pydoc keys checked against docstrings generated with doxygen (ENABLE_DOCS=ON
only runs on pushes to main): all 613 doc() calls resolve. madspace suite
1605 passed, 11 skipped, 4 failed (scipy not installed, as before the merge).

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

- banner.py: both sides added an entry to _LO_SCALAR_MAP (ebeam1/ebeam2
  here, scalefact -> beam.scale_factor on main). All three kept.
- phasespace.cpp: main's boost_beam/boost_beam_inverse now take the
  external masses, so the boost of a momentum moving against it can be
  done in light-cone components and stay on shell. to_lab/from_lab pass
  lab_masses(_topology, _permutations) to every boost, including the
  beam-rapidity one; the mirror in between is a rotation and leaves the
  masses alone.

madspace pytest 1657 passed / 11 skipped, run_card 19/19, mg7 launch
47/47, and the asymmetric-beam and per-beam-PDF runs reproduce their
pre-merge cross sections (941.2, 1817.4, 44211, 44223 pb).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conflicts:
- mg7/launch.py: keep both new imports (gc from main, math from here), and
  both blocks added after the cuts -- the mirror-orientation choice from here
  and main's momentum_hists / weighted_histograms gating.
- test_banner.py: main's new output_format default (lhe_npy) together with
  this branch's per-beam [beam] assertions (ebeam1/ebeam2/pdf1/pdf2, no e_cm).

Semantic fix: main's TestForceLHEOutput.test_scan_card_survives edited an
`e_cm = 13000.0` line that the per-beam card no longer writes; it now puts the
scan on `ebeam1` instead, which tests the same thing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts:
- banner.py: main gave every [beam] parameter a one-line comment= help
  string; keep ebeam1/ebeam2/pdf1/pdf2 with help strings in that style.
- run_card.toml: one-line comments, as main now uses.
- RunCardLO_to_MG7_mapping.md: deleted on main; its per-beam rows move to
  docs/source/madevent_to_mg7.rst, and run_card.rst documents
  ebeam1/ebeam2/pdf1/pdf2.
- phasespace.hpp/.cpp, cuts.cpp, madspace.cpp: both sides added members
  (beam_rapidity/mirror_beams/cuts vs empty/_y_max_lab); keep both.
- integrand.cpp: keep the branch's comment, which extends main's.

Semantic fix: main's eta windows (Y = log(x1/x2)/2, the root two-body
decay and the t-channel/colour-ordered clamps) read the lab eta cuts as
bounds in the frame the momenta are generated in. With a beam rapidity y0
that frame is the beams' one, so every node_y_max is widened by |y0|, as
chili's bound already was; the mirror only flips the sign of y there.
Without it, lab-passing events fall outside the windows
(test_rapidity_windows_hold_in_the_lab_frame fails in all 6 cases).

test_mirror_beams_cuts_the_written_orientation compared a cut mapping with
an uncut one for the same random numbers; an |eta| cut now changes the
sampling, so it reads the cut mapping's own momenta.
At the launch question, set ebeam/ebeam1/ebeam2, lpp/lpp1/lpp2,
pdlabel/pdlabel1/pdlabel2 and lhaid were "invalid set command" (except the
ebeam ones, which reached _LO_SCALAR_MAP) and the run kept the defaults.
RunCardMG7.set_lo_beam_param now maps them, a trailing 1/2 acting on that
beam and the other names on both:

- ebeam*: beam.ebeam1/2, with units and mass expressions.
- pdlabel*/lhaid: beam.pdf1/2 through the label and lhaid tables of the
  run_card.dat conversion; an lhaid outside the table is looked up in the
  pdfsets.index of the LHAPDF data paths. 'lhapdf' alone changes nothing,
  'none' on both beams is beam.leptonic = true.
- lpp*: the single beam.leptonic switch (0, +-3, +-4 lepton, +-1 hadron),
  with a warning when the two beams were given different types.

A value mg7 cannot represent is reported and leaves the card alone.

from_LO keeps different pdlabel1/pdlabel2 per beam instead of reporting
them as dropped.
oliviermattelaer added a commit that referenced this pull request Oct 10, 2026
At the mg7 launch question, settings that the run_card.dat conversion
(from_LO) handles were still lost:
- "set ebeam1 6500", "set ebeam 6800", "set lpp1 0", "set pdlabel ...",
  "set lhaid ...", "set fixed_fac_scale ...", "set maxjetflavor 5" and the
  per-pdg cuts ("set pt_min_pdg {6: 100}") printed "invalid set command"
  and the run went on with the mg7 default;
- "set dynamical_scale_choice 3" and "set sde_strategy 2" (madevent's
  integer codes) went to the mg7 key of the same name, which only takes
  names, and aborted a scripted launch with InvalidCardEdition.

RunCardMG7.set_lo_param now translates them as from_LO does: the integer
codes and the et/shat/ckkw shortcuts to the mg7 names (HT and HT/n stay
with the generic editor), maxjetflavor to the jet group, the {pdg: value}
cuts to a pdg<N> group plus its cut (mxx_only_part_antipart choosing
pair_mass or sfos_pair_mass, in either order), pdlabel/lhaid to beam.pdf
through the same tables. Values with no mg7 equivalent warn and keep the
current setting instead of aborting.

mg7 has one energy, one PDF and one lepton flag for both beams, so the
per-beam settings of one launch question are combined: ebeam1 and ebeam2
give e_cm = 2 sqrt(E1 E2), lpp1 and lpp2 give beam.leptonic. What still
differs per beam is reported once, when the question closes
(MG7Selector.check_card_consistency), so "set ebeam1 X" followed by
"set ebeam2 X" stays quiet. PR #154 (one energy and one PDF per beam)
routes the beam names to its per-beam keys through set_lo_beam_param;
whichever merges second hands them over to it.

from_LO now builds the jet and pdg groups with the same helpers.
@theoheimel

Copy link
Copy Markdown
Contributor

I propose we rebase the last two commits onto #245 and close this branch because the other one has a big merge conflict now.

oliviermattelaer added a commit that referenced this pull request Oct 14, 2026
Same main commit as the PR #154 merge 96b1e38, merged as a real merge
rather than cherry-picked with -m 1, so main keeps its ancestry here.

Conflicts, resolved as in 96b1e38:
- banner.py [beam]: main gave every parameter a one-line comment= help
  string; keep ebeam1/ebeam2/pdf1/pdf2 with help strings in that style.
- RunCardLO_to_MG7_mapping.md: deleted on main (modified here for the
  per-beam rows); delete it. Its per-beam rows live in the new
  docs/source/madevent_to_mg7.rst, and docs/source/run_card.rst documents
  ebeam1/ebeam2/pdf1/pdf2 (both taken from 96b1e38).
- run_card.toml [beam]: one-line comments, as main now uses (from 96b1e38).

The madspace conflicts of 96b1e38 (phasespace, cuts, integrand,
python bindings, test_beam_frame) do not arise here: bcedaa0 already
merged that part of main, and the lab-frame eta windows are handled by
lab_fractions instead of widening node_y_max by |y0|.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants