Skip to content

madspace: always draw the beam orientation before the mapping - #245

Merged
theoheimel merged 14 commits into
mainfrom
review-madspace-2to1-topology-pdf
Oct 14, 2026
Merged

theoheimel merged 14 commits into
mainfrom
review-madspace-2to1-topology-pdf

Conversation

@theoheimel

@theoheimel theoheimel commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #154 (base is its branch, so merge #154 first or retarget to main).

Summary

  • One mirror path. Mirrored flavors always draw the orientation before the phase-space mapping (mirror_beams = true), so boost, cuts and PDFs all see the orientation the event is written with. The post-cut mirror (momenta_mirror_acc, mirror_id_acc, the momenta_me selection) is removed.
  • Integrand throws if a flavor is mirrored and the mapping was not built with mirror_beams = true. launch.py sets mirror_beams from has_mirrored_flavors(); asymmetric beams without mirrored flavors only get the y0 boost.
  • Removed the cut-invariance machinery (Cuts::non_mirror_invariant_cuts, Cuts::mirror_invariant, Observable::mirror_invariant) and its bindings. Signed cuts (eta, y, phi, pz) with symmetric beams now just work instead of throwing.
  • Mapping conditions by name ("permutation_index", "mirror_index") instead of positional indexing, in PhaseSpaceMapping and Integrand.

Fix after merging main

Main's cut-aware rapidity windows assume the beams' frame is the lab frame. With beam_rapidity != 0 that lost ~9% of the cross section (52% accepted vs 81% after the fix) for rambo/propagator modes:

  • the x1/x2 window bound is widened by |y0|;
  • the decay and t-channel windows now get x1 e^{y0}, x2 e^{-y0} (lab_fractions), with the sign of y0 flipped for mirrored events.

New test test_cut_aware_sampling_loses_nothing_with_asymmetric_beams (with and without mirror_beams); I checked it fails with the mirror sign flipped.

Behavior changes to be aware of

  • Symmetric beams with mirrored flavors take the new path: cross sections should agree, but the orientation is a random draw, so odd observables (e.g. ) get statistical noise the old exactly-paired sample did not have. A p p > e+ e- comparison of against main has not been done.
  • test_mirror_beams_cuts_the_written_orientation was weakened: cut-aware sampling means the cut and uncut mappings no longer map the same random numbers to the same momenta, so it now checks that accepted points pass the cut as written.
  • 2 -> 1 does not use main's x1/x2 window yet (correct, but less efficient with a rapidity cut).

LHE incoming order

Events were written with the incoming particles in the flavor tables' leg order, so mirrored events had particle 1 moving along -z and orderings such as u g never appeared (reported by a collaborator; reproduced on this branch with p p > z j: only g u, with pz(1) < 0 in about half of the events). LHECompleter::complete_event_data now swaps the two incoming records when particle 1 has pz < 0, so particle 1 always comes from beam 1. On the same process both orderings now appear in equal numbers and pz(1) > 0 for every event. The systematics reweighting reads the event buffer and is unaffected. Test: test_first_incoming_particle_moves_along_plus_z. I did not check whether main shows the same ordering.

Testing

  • madspace pytest: 1731 passed (torch tests excluded)
  • test_mg7_launch.py, test_banner.py: 151 passed
  • Acceptance tests (test_check_xsec_processes_mg7.py) not run locally; relying on CI.

oliviermattelaer and others added 10 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>
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>
@oliviermattelaer
oliviermattelaer self-requested a review October 9, 2026 08:31
Mirrored events carry the flavor tables' leg order on swapped beams, so the
first incoming particle could move along -z and orderings like 'u g' never
appeared. Swap the incoming records when particle 1 has pz < 0.
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|.
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.

(cherry picked from commit 1120a16)
@oliviermattelaer
oliviermattelaer changed the base branch from claude/madspace-2to1-topology-pdf-2da50a to main October 14, 2026 12:10
@oliviermattelaer

oliviermattelaer commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor
  • cherry-pick done
  • test done (results below)
  • remerge with main (conflict fixed in 10faf1d)

Merge with main (10faf1d)

Only conflict: mg7/launch.py, build_lhe_meta. Kept this branch's per-beam LHAPDF ids (_lhapdf_id(1), _lhapdf_id(2)) together with main's weight_mode from lhe_weight_info. After the merge madspace was rebuilt; 90 madspace tests, 339 mg7 interface tests and 71 run-card tests pass.

Eta cuts with asymmetric beams and the mirror

Code: lab_fractions() gives the windows the boost from the frame the momenta are generated in to the lab, for both mirror orientations, and the window kernels only read ½ ln(x1/x2), so the rescaled x1, x2 are safe. The window on the rapidity of the partonic system (widened by |y0|) and the colour-ordered ΔR bound (dropped when y0 ≠ 0) are correct but conservative.

Mapping level (no matrix element): 10 topologies (DY, massive t t~, Z g, e e g g, a a and a g a in the t channel, Z j with a composite child, chili) × y0 ∈ {0, 0.28, −1} × mirror on/off:

  • every event passing the lab cuts maps back inside the windows (60/60)
  • windowed and window-free cut integrals agree within 0.4% (all pulls < 1.7σ at 6M points)

Full mg7 runs with |η| < 2.5, compared with a run without the eta cut where the cut is applied to its events afterwards:

process beams (GeV) cut run no-eta run, cut afterwards ratio
p p > e+ e- 7000 × 4000 940.08 ± 0.82 pb 939.83 ± 2.09 pb 1.0003 ± 0.0024
p p > e+ e- (after the main merge) 7000 × 4000 939.82 ± 0.58 pb 939.83 ± 2.09 pb 1.0000 ± 0.0023
p p > e+ e- 6500 × 6500 1126.35 ± 0.79 pb 1128.87 ± 2.56 pb 0.9978 ± 0.0024
p p > a a 7000 × 4000 24.974 ± 0.037 pb 24.983 ± 0.064 pb 0.9996 ± 0.0030
p p > a a 6500 × 6500 28.396 ± 0.046 pb 28.383 ± 0.076 pb 1.0005 ± 0.0031
p p > e+ e- j (lepton and jet η cut) 7000 × 4000, 5 seeds 188.70 ± 0.08 pb 188.44 ± 0.16 pb 1.0014 ± 0.0010
p p > e+ e- j (lepton and jet η cut) 6500 × 6500 237.49 ± 0.24 pb 237.13 ± 0.56 pb 1.0015 ± 0.0026
  • no written event outside |η| < 2.5 (largest |η| 2.5000)
  • the dσ/dη shapes of the cut runs match the cut-afterwards samples, with no dip at the edges
  • beams swapped (4000 × 7000): same cross section (DY 940.12 ± 0.81, a a 24.91 ± 0.04, e e j 188.81 ± 0.19 pb) and mirrored η distributions
  • without the eta cut ⟨y⟩ of the final state is +0.286, against ½ ln(7/4) = 0.280: the boost goes the right way
  • incoming particle 1 never has pz < 0 in the LHE files
  • DY 7000 × 4000 agrees with madevent including the asymmetric-beam cut fix (mg5amcnlo PR #416), 939.3 ± 1.5 pb

Conflict:
- mg7/launch.py build_lhe_meta: this branch writes the LHAPDF id of each
  beam (_lhapdf_id(1), _lhapdf_id(2)); main replaced the fixed
  weight_mode=3 by the one lhe_weight_info derives from the run. Keep both.

@oliviermattelaer oliviermattelaer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eta cuts with asymmetric beams and the mirror validated, main merged (details in the comment above).

@theoheimel
theoheimel merged commit 3ddb03c into main Oct 14, 2026
183 checks passed
@theoheimel
theoheimel deleted the review-madspace-2to1-topology-pdf branch October 14, 2026 19:11
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