Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/acceptancetest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1484,3 +1484,21 @@ jobs:
run: |
cd $GITHUB_WORKSPACE
./tests/test_manager.py test_madspin_loop_induced -pA -t0 -l INFO

acceptancetest_107:
runs-on: ubuntu-24.04
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork == true
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/checkout_mg5
- uses: ./.github/actions/restore-pip-cache
- uses: ./.github/actions/restore_all

# Loop-induced with a polarised massive particle. Loop-induced goes
# through the LO madevent template, so this checks the LO me_frame boost
# reaches the MadLoop matrix element.
- name: test one of the test test_polarised_loop_induced_me_frame
run: |
cd $GITHUB_WORKSPACE
./tests/test_manager.py test_polarised_loop_induced_me_frame -pA -t0 -l INFO
./tests/test_manager.py test_polarised_loop_induced_me_frame_last_leg -pA -t0 -l INFO
4 changes: 2 additions & 2 deletions .github/workflows/unittest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ jobs:
- uses: actions/checkout@v5

# Runs a set of commands using the runners shell
- name: test one of the test test_ungrouping_lepton test_all_permutation test_permutation_from_id test_v31_syntax_crash test_the_exit_from_child_cmd test_the_exit_from_child_cmd2 test_get_final_part test_InvalidCmd test_check_generate test_cleaning_history test_generate_polarised test_help_category test_output_default test_shell_and_continuation_line test_simple_generate test_card_type_recognition test_help_category test_autocompletion test_modif_ML_card test_modif_madweight_card test_modif_param_card test_modif_pythia8_card test_modif_run_card test_modif_shower_card test_modif_reweight_card
- name: test one of the test test_ungrouping_lepton test_all_permutation test_permutation_from_id test_v31_syntax_crash test_the_exit_from_child_cmd test_the_exit_from_child_cmd2 test_get_final_part test_InvalidCmd test_check_generate test_polarisation_nlo_regimes test_cleaning_history test_generate_polarised test_help_category test_output_default test_shell_and_continuation_line test_simple_generate test_card_type_recognition test_help_category test_autocompletion test_modif_ML_card test_modif_madweight_card test_modif_param_card test_modif_pythia8_card test_modif_run_card test_modif_shower_card test_modif_reweight_card
run: |
cd $GITHUB_WORKSPACE
./tests/test_manager.py test_ungrouping_lepton test_all_permutation test_permutation_from_id test_v31_syntax_crash test_the_exit_from_child_cmd test_the_exit_from_child_cmd2 test_get_final_part test_InvalidCmd test_check_generate test_cleaning_history test_generate_polarised test_help_category test_output_default test_shell_and_continuation_line test_simple_generate test_card_type_recognition test_help_category test_autocompletion test_modif_ML_card test_modif_madweight_card test_modif_param_card test_modif_pythia8_card test_modif_run_card test_modif_shower_card test_modif_reweight_card -t0
./tests/test_manager.py test_ungrouping_lepton test_all_permutation test_permutation_from_id test_v31_syntax_crash test_the_exit_from_child_cmd test_the_exit_from_child_cmd2 test_get_final_part test_InvalidCmd test_check_generate test_polarisation_nlo_regimes test_cleaning_history test_generate_polarised test_help_category test_output_default test_shell_and_continuation_line test_simple_generate test_card_type_recognition test_help_category test_autocompletion test_modif_ML_card test_modif_madweight_card test_modif_param_card test_modif_pythia8_card test_modif_run_card test_modif_shower_card test_modif_reweight_card -t0



Expand Down
197 changes: 187 additions & 10 deletions docs/nlo_polarisation_boost_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ Status:
| M3 `[QCD]` | **done** — `check_poles` cancels 20/20 in every P dir, `calculate_xsect NLO` runs end to end, `[QCD]` enabled |
| M4 | **done** — guard open, `help_polarization` rewritten, acceptance test written, run and wired into CI |
| M5 NLO+PS | **done** — MC counterterm azimuth boosted, `generate_events` validated, second acceptance test in CI |
| M6 loop-induced | **done** — `[noborn=…]`/`[sqrvirt=…]` opened; the LO boost was already in the generated code, verified at runtime. Uncovered a MadLoop `improve_ps` bug on a last-leg `me_frame`, fixed by PR #91 and now covered by a test |

`[LOonly=QCD]`, `[real=QCD]`, `[QCD]` and `[virt=…]` all accept a polarised
massive particle today. The first three get their frame from `me_frame` in the
run_card; `[virt=…]` is standalone MadLoop, where the user supplies the
phase-space point and therefore picks the frame themselves (see M3).
`[LOonly=QCD]`, `[real=QCD]`, `[QCD]`, `[virt=…]`, `[noborn=…]` and
`[sqrvirt=…]` all accept a polarised massive particle today. The first three
get their frame from `me_frame` in the run_card; `[virt=…]` is standalone
MadLoop, where the user supplies the phase-space point and therefore picks the
frame themselves (see M3); the loop-induced pair is exported through the LO
madevent template and uses `me_frame` exactly as at LO (see M6).

**NLO+PS works too**, not only fixed order: `generate_events` on
`p p > z{0} j [QCD]` with `me_frame=[3]` gives 2.189e+03 +- 8.2e+00 pb with all
Expand Down Expand Up @@ -59,6 +62,9 @@ The live NLO polarisation path is massless-colourless loop-induced only, which
is exported through the **LO** madevent template and therefore inherits
`me_frame` for free. That is why an NLO boost was never needed.

(The claim in that last sentence is now verified at runtime, and the massive
refusal in the loop-induced modes is lifted — see M6.)

### Generation is alive and correct

`FKSLeg` inherits `Leg`, so `polarization` survives; `fks_common.py:747`
Expand Down Expand Up @@ -1083,11 +1089,10 @@ Still refused, each for a reason rather than by omission:
links, `sreal_deg`) go through the same `sborn_frame`/`sborn_sf_frame`, so
this is likely to work — but nothing in the QED sector has been run, and
the plan does not cover it. `[virt=QED]` is fine, being standalone.
- `[noborn=QCD]` with a *massive* polarised particle. Pre-existing and
unchanged: loop-induced is exported through the LO madevent template and so
already has `me_frame`, but `check()` has always rejected massive there.
Worth revisiting; not touched here because nothing in this work exercises
it. See section 1.

`[noborn=…]` / `[sqrvirt=…]` (loop-induced) with a massive polarised particle
was on this list. It has since been checked end to end and **opened** — see
M6.

`tests/unit_tests/interface/test_cmd.py::test_check_generate` encoded the old
restriction (`'u u~ > w+{L} [QCD]'` in the *invalid* list) and is updated: the
Expand Down Expand Up @@ -1159,7 +1164,7 @@ Remaining:
- decide whether `[noborn=QCD]` should accept a massive polarised particle.
It is loop-induced, exported through the LO madevent template, so it has
`me_frame` already; `check()` has always refused massive there and nothing
in this work exercises it.
in this work exercises it. **Done in M6.**

### M5 — NLO+PS (MC@NLO matching) — **DONE**

Expand Down Expand Up @@ -1399,6 +1404,176 @@ unrelated processes and is its own piece of work. It is recorded because it
explains why `test_MC` passed on the broken code, and why the M5 validation had
to be built by hand.

### M6 — Loop-induced: a massive polarised particle — **DONE**

The verdict is **omission, not limitation**. `[noborn=…]` and `[sqrvirt=…]`
now accept a polarised massive particle, for any perturbation order.

Section 1 claimed loop-induced already had the frame because it is exported
through the LO madevent template. That was checked end to end rather than
assumed, on `g g > z{0} z{0} [noborn=QCD]` with `me_frame=[3]` (the first Z at
rest -- the one-leg selection, which is the case that needs the M2 fix).

**The generated code really boosts.** `SubProcesses/P0_gg_z0z0/auto_dsig1.f`
carries the LO wrapper unchanged:

```fortran
IF(FRAME_ID.NE.6)THEN
CALL BOOST_TO_FRAME(PP, FRAME_ID, P1)
ELSE
P1 = PP
ENDIF
...
CALL SMATRIX1(P1, IFLAV, RHEL, RCOL, CHANNEL, 1, DSIGUU, ...)
```

and `SMATRIX1` hands `P1` straight to `ML5_..._SLOOPMATRIX_THRES`. The
`frame` block appears in the generated LO run_card, and `Source/run_card.inc`
comes out with `FRAME_ID = 8`, i.e. bit 3, the first Z. The boost routine is
`Template/LO/SubProcesses/genps.f`'s `boost_to_frame` — the same one M2 fixed,
so the fix is inherited by construction rather than ported.

**The quantisation axis survives MadLoop** (on `me_frame=[3]`; `[4]` needed
PR #91, see below). This was the one thing that could
not be settled by reading: `improve_ps` deforms the PS point before the loop is
evaluated (`ImprovePSPoint=2` by default), and moving the deliberately-zeroed
leg off zero is precisely what flips the HELAS `vxxxxx` branch. Instrumented
the `gg > z{0} z{0}` MadLoop dir's `loop_matrix.f` to print the selected leg's
`|p|` and energy immediately before and after `IMPROVE_PS_POINT_PRECISION`,
over a full survey (256 prints, 16 distinct phase-space points): `0.0` exactly
in and `0.0` exactly out, every point.

**History: it did not, for a frame built on the last external leg.** The
milestone was written on `me_frame=[3]` and the property held; `me_frame=[4]`
broke it, and the two are the same observable because the two Z are identical:

| `me_frame` | `\|p\|` in | `\|p\|` out | cross-section |
|---|---|---|---|
| `[3]` | `0.0` exactly | `0.0` exactly | 2.543e-02 pb |
| `[4]` | `0.0` exactly | **`1.5888e-14`** | 3.211e-02 pb, +26% |

An LO control on the same final state gave `[3]` and `[4]` bit-identical, so
the split was a MadLoop artefact, not physics. **Fixed by PR #91** ("MadLoop:
keep an external leg exactly at rest through improve_ps", merged to `main`
2026-09-03): the momentum-conservation residual is absorbed into the
largest-`|p|` final-state leg (`IABSORB`) instead of `NEXTERNAL`. That went on
its own branch because `improve_ps` is shared by every MadLoop output.
`test_polarised_loop_induced_me_frame_last_leg` is the regression and now runs
unskipped.

The reasoning that first talked this section into "the zero always survives"
was wrong in three places, all worth recording:

- **PSMC does not merely rescale.** Its *first* step overwrote
`NEWP(1:3,NEXTERNAL)` with the momentum-conservation residual,
unconditionally, before any rescaling, so a frame built on the last external
leg was hit before the argument about common factors applied. This is the
line PR #91 changed.
- **The ORIG half of the argument is vacuous in a boosted frame.** ORIG sets
`ERRCODE=200` as soon as the initial state has transverse momentum and
`GOTO 100`s out (`improve_ps.inc:540`) without touching `P`. Its onshellness
step never runs, so what it would have done to an exact zero is irrelevant.
- **There is a second entry point.** `SET_MP_PS(P_USER)`
(`loop/loop_matrix_standalone.inc:634`,
`loop_optimized/…:954`) re-improves the *raw* momenta in quad on the QP
escalation path, so a point can be improved twice by two different routes.

**The `NRotations` trap.** `ROTATE_PS` (`improve_ps.inc:382`) is an axis
permutation with sign flips and no arithmetic, so it preserves exact zeros.
That is not enough: a rotation is not a symmetry of a polarised amplitude with
a leg at rest, because HELAS pins that leg's axis to the frame z rather than
to the momentum. `NRotations_DP`/`NRotations_QP` default to 0, so the shipped
path is safe — but they are a documented remedy for suspicious processes, and
a user who turns them on here gets spurious instability flags and a wrongly
"rescued" answer.

**Note for whoever merges this with the loop-induced pole check**
(`claude/loop-induced-pole-check`). Two costs land on the same runs and
neither is a bug:

- a non-longitudinal `me_frame` boost gives the initial state transverse
momentum, and `improve_ps`'s ORIG algorithm refuses any such point
(`ERRCODE=200`), so **every** call falls through to the PSMC algorithm. The
log fills with "Attempting to rescue the precision improvement with an
alternative method", capped at 20 lines. It is not a failure and not a
precision downgrade: `IMPROVE_PS_POINT_PRECISION` promotes to `REAL*16` and
calls the MP routine unconditionally, so both algorithms were always quad —
`ERRCODE=200` is an algorithm switch, costing 12.3 microseconds per call
against millisecond-scale loop evaluations, under 1%. The lesson is the
other way round: calling it noise is what left the PSMC branch unread, and
PSMC is where the last-leg bug above lives.
- the pole check re-enables `COLLIERComputeUVpoles`/`COLLIERComputeIRpoles`
for loop-induced, which were previously off for speed.

A polarised loop-induced run pays both.

Unrelated but adjacent: `output standalone` on a loop-induced process is being
fixed on `claude/fix-loop-induced-standalone-flavor` (off `main`) --
`_flavor_enumeration_context` in `helas_objects.py` counted the L-cut
wavefunctions as external legs. Nothing here depends on it; the madevent
output this milestone uses was never affected.

**Cross-section, and the frame really moves it.** `g g > z{0} z{0}
[noborn=QCD]`, shipped run card except `nevents=100`, `use_syst=F` and
`me_frame`:

| `me_frame` | cross-section |
|---|---|
| `[3]` (first Z at rest) | **2.534e-02 +- 5.0e-05 pb** |
| `[1,2]` (partonic c.m., boost skipped) | 5.805e-02 +- 4.8e-04 pb |

A factor 2.3. That gap is what the acceptance test asserts on: a boost that
were silently skipped could not reproduce the first number (it sits 62 sigma
out).

`me_frame=[4]` must give the same number as `[3]`, the two Z being identical,
and now does. Measured on top of PR #91, same card, one run each:
**2.543e-02 +- 9.9e-05 pb** from both, with `FRAME_ID = 16` and `FRAME_ID = 8`
in their respective `run_card.inc` and **byte-identical `results.dat`**. Before
PR #91 the same pair gave 2.543e-02 against 3.211e-02.

Re-measured on top of M5 (`montecarlocounter.f`, `boost_to_frame.f`), where
nothing should move because loop-induced goes through the LO madevent template
and never reaches an FKS counterterm: 2.543e-02 +- 9.9e-05 pb, 0.8 sigma from
the row above -- and bit-identical to the same fresh generation on the pole-
check branch, both having started from the same `randinit`. The two numbers in
the table differ only by seed offset (24 vs 21).

**Cross-checked against the loop-induced pole check** (`MLPoleCheckThres`, on
`claude/loop-induced-pole-check`). That work and this one are orthogonal: it
adds a runtime guard, this one adds a spelling, and neither can stand in for
the other. In particular the pole check cannot see a frame error -- a
loop-induced amplitude is finite helicity by helicity and a boost is a
symmetry of it, so the poles cancel in every frame, including the wrong one.
Nor does polarisation trip it: the same argument makes `ANS(2,0)`/`ANS(3,0)`
shrink with `ANS(1,0)` when the helicity sum is restricted. Measured, with the
two changes merged and the check active at its default `1.0d-2`:
2.543e-02 +- 9.9e-05 pb, zero `##E02`, 0.8 sigma from the number above.

**What is not touched.** The QCD-only restriction on the run_card NLO modes
stays; it does not apply to loop-induced, which has no subtraction and
therefore no order-dependent counterterms to validate.

**Colour is not refused here either.** The colour-charged refusal is scoped to
the subtracted regime alone (`madgraph_interface.py`, the `if
subtracted_boost_ok:` branch), a decision taken on the base branch in
`ee63ba492`: a coloured polarised leg is also an FKS emitter, which is what is
untested, and loop-induced has no FKS. `g{L} g > z z [noborn=QCD]` and
`g g > t{L} t~ [noborn=QCD]` are therefore accepted, and the unit test asserts
so. Nothing in the LO path cares: `boost_to_frame` is purely kinematic and
`SMATRIX1` hands the boosted momenta straight to MadLoop. It is a *format*
decision only — no coloured polarised loop-induced process has been run.

**Write `g g`, not `p p`.** `p p > z z [noborn=QCD]` -- the spelling section 1
uses -- is not a physical request: `q q~ > z z` has a tree-level Born, so
"no Born" is contradictory for those channels. It generates, and then the
quark-initiated jobs die at runtime (`STOP energy is not conserved
(flag:CT692)`, and the survey then aborts on the missing `results.dat`).
That is correct behaviour, not a bug: it reproduces identically on the
**unpolarised** process, in the same four G dirs, and has nothing to do with
polarisation. The loop-induced process to write is `g g > z{0} z{0}
[noborn=QCD]`.

## 4. Acceptance test

One new test in `tests/acceptance_tests/test_cmd_amcatnlo.py`, modelled on
Expand All @@ -1418,6 +1593,8 @@ does it.
| M3 | `p p > z{0} z{0} j` | `[QCD]` | **run**: `check_poles` 20/20 in all 12 P dirs, 4.779e-01 +- 3.3e-03 pb |
| M5 | `p p > z{0} j` | `[QCD]` + `generate_events` | **run**: 2.189e+03 +- 8.2e+00 pb, absolute cross-section ratio 2.00 (2.23 before the fix) |
| M5 | `p p > z{0} z{0} j` | `[QCD]` + `generate_events`, `me_frame=[3,4]` | **run**: 3.134e-01 +- 2.2e-03 pb, `test_ME`/`test_MC`/`check_poles` 20/20 in all 12 P dirs |
| M6 | `g g > z{0} z{0}` | `[noborn=QCD]`, `me_frame=[3]` | **run**: 2.534e-02 +- 5.0e-05 pb, against 5.805e-02 +- 4.8e-04 pb in the partonic c.m. |
| M6 | `g g > z{0} z{0}` | `[noborn=QCD]`, `me_frame=[4]` | **run**: 2.543e-02 +- 9.9e-05 pb, byte-identical `results.dat` to `me_frame=[3]` (was 3.211e-02 before PR #91) |

**Which process tests what.** The two are complementary, but not in the obvious
way:
Expand Down
6 changes: 6 additions & 0 deletions madgraph/interface/madgraph_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -823,8 +823,14 @@ def help_polarization(self):
logger.info(" validated, so [QED] and mixed orders are still refused.")
logger.info(" - [virt=QCD] and standalone MadLoop take the frame from the momenta the")
logger.info(" caller supplies, so any perturbation order is allowed.")
logger.info(" - loop-induced is not restricted at all: [noborn=...] is exported")
logger.info(" through the LO madevent template and uses 'me_frame' exactly as at")
logger.info(" LO, [sqrvirt=...] squares one amplitude standalone.")
logger.info(" A polarized massless particle needs no frame and was always allowed.")
logger.info(" > A colour-charged polarized particle is refused in the [QCD]/[real=]/")
logger.info(" [LOonly=] modes only: it is also an FKS emitter, and that is untested.")
logger.info(" > Example: generate p p > z{0} z{0} j [QCD] with me_frame = [3,4]",'$MG:color:GREEN')
logger.info(" > Example: generate g g > z{0} z{0} [noborn=QCD] with me_frame = [3]",'$MG:color:GREEN')
logger.info(" > Both fixed-order (calculate_xsect) and NLO+PS event generation")
logger.info(" (generate_events, i.e. MC@NLO matching) are supported: the MC")
logger.info(" counterterms and the weights written to the LHE use the same frame as")
Expand Down
Loading
Loading