Skip to content

Polarised cross-sections at fixed-order NLO (me_frame), and a live LO bug it uncovered - #65

Open
oliviermattelaer wants to merge 19 commits into
mainfrom
claude/nlo-polarised-boost-assessment-d97794
Open

Polarised cross-sections at fixed-order NLO (me_frame), and a live LO bug it uncovered#65
oliviermattelaer wants to merge 19 commits into
mainfrom
claude/nlo-polarised-boost-assessment-d97794

Conversation

@oliviermattelaer

@oliviermattelaer oliviermattelaer commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Makes polarised fixed-order NLO work. p p > z{0} z{0} j [QCD] was refused at parse time; it now runs end to end with the infrared poles cancelling to loop-library accuracy.

Polarisation of a massive particle is not boost invariant, so a polarised cross-section has to be evaluated in a definite frame. That is the me_frame run_card option, which existed only at LO. Nothing on the NLO side was boosted: not the Born, the real, any FKS counterterm, or the virtual.

A bug in released LO code, independent of the rest of this PR

me_frame is a shipped LO feature and it has been silently wrong. For a one-leg selection (me_frame=[3], the usual way to ask for Z polarisation) that leg ends up at rest, and HELAS vxxxxx switches quantisation axis at exactly zero momentum: at zero the axis is the frame's z axis, otherwise it is built from the momentum direction. boostx only reaches zero up to rounding, so on a fraction of events the longitudinal polarisation vector pointed along rounding noise — a different polarisation state, not a small perturbation. Measured on p p > z{0} j, me_frame=[3], nn23lo1, fixed scales:

cuts before after
ptj 30, etaj 4.0 1370 ± 1.7 1405 ± 1.6 pb (2.6%, 14σ)
ptj 20, etaj 5 1399 ± 3.4 1424 ± 3.8 pb (1.8%, 5σ)

Fixed by imposing the defining property of the frame — zeroing the selected leg's 3-momentum exactly — rather than hoping the arithmetic lands there. Results produced with me_frame selecting a single particle should be regenerated. A separate change is being prepared for the 3.7.3 release branch.

What was added

  • me_frame/frame_id in RunCardNLO; frame_id=0 unless a frame is requested, so unpolarised runs are untouched.
  • boost_to_frame.f: the boost, the leg masks, and frame wrappers for every matrix-element entry point (sborn_frame, sborn_sf_frame, extra_cnt_frame, smatrix_real_frame, binothlha_frame).
  • The collinear azimuthal factor rebuilt covariantly. xij_aor is a 0/0 limit of the collinear <ij>/[ij], and a boost maps exactly-parallel null vectors onto exactly-parallel null vectors, so the degeneracy survives in every frame and it can never be recomputed after a boost. xij_kperp carries the emission direction instead.
  • frame_info.inc per P directory, mapping the user's leg numbering to fortran positions. sort_proc renumbers the born legs, so the user's numbering is destroyed rather than merely permuted, and has to be captured before the sort.

Design points worth review

  • The boost is a pure function of the momenta passed in. SBORN caches amplitudes against (E, p_z) in a common block shared with sborn_sf, born_hel and extra_cnt, so two callers that disagree about the frame silently return amplitudes from the wrong one. Making the boost depend only on its argument removes the need to reason about which callers are reachable — reasoning that failed twice during development, once as a silent 3% shift and once as a hard stop.
  • Frames must be built from final-state particles. An initial-state frame is not infrared safe at NLO: the real emission and the reduced Born carry momentum fractions differing by a finite amount even in the singular limit, so the frame is discontinuous there and the subtraction stops cancelling. Mixing initial- and final-state legs is refused at card validation. me_frame = [1,2] names the partonic c.m. and is skipped rather than applied.
  • QCD only, for the modes that read the run_card. The wrappers are order-agnostic and QED will probably work, but nothing in that sector has been run, so it is refused rather than assumed.

Validation

check result
unpolarised p p > z j [QCD] 2.854e+04 ± 1.9e+02 pb, unchanged throughout
M1 p p > z{0} j [LOonly=QCD] vs LO madevent agree at 1.1σ, both sides shifting by identically +35 pb
M2 test_soft_col_limits, p p > z{0} j [real=QCD] 0 failures of 512
M3 check_poles, p p > z{0} z{0} j [QCD] 0 miscancellations in all 12 P dirs, agreement ~1e-14
acceptance test 12 P dirs, 12 pole logs, 0 miscancellations, 3.084e-01 ± 3.0e-03 pb

The acceptance test asserts on check_poles, not on a cross-section. The poles are proportional to the Born, so a frame mismatch surfaces there, whereas a total can look entirely plausible while being wrong — both failure modes occurred during development. Wired into CI as acceptancetest_105.

Three IO reference sets gain a frame_info.inc entry. Files added, none modified, which is itself the evidence that generated output is otherwise unchanged.

Also included

Two fixes are cherry-picked because they are coupled to the guard change: 79314749f and 4ab17d962 stop polarization being dropped when a ProcessDefinition is expanded, which otherwise made p p > z{0} j [virt=QCD] silently generate the unpolarised loop. a124ead32 (FD-gauge combine_name) is independent and a no-op for these processes, but is needed on main regardless.

Not covered

QED perturbation. Polarised check timing/stability/profile at NLO, newly admitted by the guard but never run. [noborn=QCD] with a massive polarised particle, still refused. A betamin=1d-4 guard papers over a real HELAS fragility with near-identity boosts of on-axis beams — recorded as B9 and not fixed.

Full design, diagnosis and blocker list: docs/nlo_polarisation_boost_plan.md.

🤖 Generated with Claude Code

oliviermattelaer and others added 19 commits August 12, 2026 16:45
Polarisation of a massive particle is not boost invariant, so a polarised
cross-section has to be evaluated in a definite frame. At LO that is the
me_frame run_card option; the NLO/FKS output had no equivalent at all, which
is one of the reasons polarised [QCD] processes are refused at parse time.

This is the plumbing milestone only: it adds the run_card option, the frame
bookkeeping and the boost routine, but wires up no call site. frame_id is
assigned in run_card.inc and read nowhere, and nothing calls the boost yet,
so the change is runtime-inert by construction and no cross-section can move.

 - me_frame/frame_id in RunCardNLO, with the frame block exposed only when a
   massive leg is polarised, and a $frame placeholder in the NLO run_card
   template (without it, banner.py appends the block past the end of the card
   and the test_default round-trip fails).
 - common/to_frame_me/frame_id in the NLO run.inc.
 - boost_to_frame.f: mapid_frame, get_frame_mask_born/real,
   get_me_frame_boost and boost_to_me_frame. The boost reports itself trivial
   both when nothing is selected and when the selected system is already at
   rest, so the default me_frame=[1,2] stays exactly free.
 - frame_info.inc per P dir, holding frame_map_born.

me_frame is expressed in the numbering of the process as the user wrote it,
but sort_proc() permutes the born legs *and* renumbers them, so that numbering
is destroyed rather than merely reordered: 'p p > j z' yields born 'g d > z d'
with the Z carrying number 3, not 4. get_user_leg_order() therefore records the
ordering before sort_proc runs, and it travels to the exporter on FKSProcess
and FKSHelasProcess.

No table is needed for the reals: the FKS convention already fixes the
real->underlying-Born correspondence in terms of i_fks alone (set_pdg in
chooser_functions.f), so get_frame_mask_real derives the real mask at runtime
from the Born one.

p p > z j [QCD] and p p > j z [QCD] both build, link and run clean at
2.854e+04 +- 1.9e+02 pb with the test_ME soft and collinear checks passing.

Plan and assessment: docs/nlo_polarisation_boost_plan.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wires the me_frame boost added in M0 into the Born, and lets the parser accept
a polarized massive particle for [LOonly=QCD], where the Born is the whole
computation and now has a frame. [QCD] and the other NLO modes are still
refused: their real, counterterms and virtual have no boost yet.

Validated against LO madevent for p p > z{0} j, matched PDF (nn23lo1), fixed
scales (91.188), ptj 30, etaj 4.0:

  unpolarised (control)     LO 6966 +- 6     LOonly 6964 +- 18    0.03%
  z{0}, Z rest frame [3]    LO 1370 +- 1.7   LOonly 1366 +- 3.1   1.1 sigma
  z{0}, partonic c.m. [1,2] LO 974.8 +- 1.0  LOonly 977.3 +- 2.3  1.0 sigma

The unpolarised control is what makes the polarised rows meaningful: it shows
the PDF/scale/cut/channel matching is right, so a disagreement cannot be blamed
on the setup. The frame dependence is 40% and both codes reproduce it. For
p p > z{0} z{0} the ZZ system is the partonic c.m. at Born level, so the boost
must be inert there, and it is: 0.5392 +- 0.0016 against 0.5401 +- 0.0017.

Three call sites are converted, not one. The fixed-order Born comes from
compute_born (fks_singular.f:1, via driver_mintFO.f:445), not from
bornsoftvirtual, which jumps to label 549 for abrv='born' and contributes
nothing. include_multichannel_enhance also calls SBORN, before either of them.

Converting only some of them is not a partial fix but a wrong answer: SBORN
caches its amplitudes against (E,p_z) in a common shared with sborn_sf,
born_hel and extra_cnt. A frame disagreement between two callers in the same
event either reuses amplitudes from the wrong frame (observed: a silent 3%
shift) or trips the "momenta not the same in Born" stop. Every reachable
caller must be converted together or none. That rule is what M2 has to respect
across its ~20 sites.

Unpolarised runs are unaffected: me_frame defaults to [1,2], whose momentum sum
is at rest in the partonic c.m., so get_me_frame_boost reports the boost
trivial and copies the momenta through untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Groundwork for boosting the collinear counterterms. Adds no call site and
changes no result: xij_kperp is stored but read nowhere, and
azifact_from_kperp is defined but called nowhere.

The ISR collinear counterterm multiplies the spin-correlated Born by
-exp(2 i psi), psi being the azimuth of the emission about the mother. Today
that factor comes from xij_aor, precomputed by the generator as
-exp(2 idir i phi_i_fks). xij_aor cannot survive a change of frame: it is a
0/0 limit of <ij>/[ij], and a boost maps exactly-parallel null vectors onto
exactly-parallel null vectors, so the degeneracy is there in every frame and
there is nothing to recompute it from. The azimuth therefore has to be carried
as a vector, which is what xij_kperp is.

azifact_from_kperp rebuilds the factor from the mother direction and that
vector, using the standard helicity basis

    e1 = ( cos(th)cos(ph), cos(th)sin(ph), -sin(th) )
    e2 = (       -sin(ph),       cos(ph),        0  )

This reproduces both incoming legs with no idir bookkeeping:

    j_fks=1, n=+z : psi = phi_i      -> -exp( 2 i phi_i)
    j_fks=2, n=-z : psi = pi - phi_i -> -exp(-2 i phi_i)

the doubled angle turning the basis flip into a harmless 2 pi. That is also
why the R_y(pi) rotation the old ISR code applies for j_fks=2 has to be
deleted rather than boosted when the counterterms are converted: it only ever
compensated a basis convention that this form handles by construction.

Checked against -exp(2 idir i phi_i) over 37 azimuths on both beams: worst
deviation 2.4e-16, one ulp. Exact bit-identity is not reachable this way,
since (a+ib)^2 and exp(2 i phi) differ in the last ulp, so the counterterms
will keep using xij_aor whenever the boost is trivial and take this route only
when a frame is actually requested; unpolarised runs then stay bit-identical
by construction rather than by measurement. A longitudinal boost leaves the
reconstruction exactly unchanged, which makes a longitudinal-only me_frame a
usable null test later.

Unpolarised p p > z j [QCD] is unchanged at 2.854e+04 +- 1.9e+02 pb with the
test_ME soft and collinear checks passing.

The FSR half is not done: generate_momenta_massless_final stores no
xij_kperp yet, and which of its two branches actually runs still has to be
established.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the covariant azimuth groundwork. Still inert: xij_kperp is written
but read nowhere. Unpolarised p p > z j [QCD] unchanged at 2.854e+04 +- 1.9e+02
pb with the test_ME soft and collinear checks passing.

B4 asked two questions about FSR before this could be written. Both answer
favourably.

Which branch runs: the same one as ISR. sreal dispatches to sborncol_fsr or
sborncol_isr on pmass(j_fks) and j_fks<=nincoming, but the collinear and
soft-collinear counter-events are handed the literal 'one' for y_ij_fks in
either case (fks_singular.f:793, :904). So FSR also always takes
azifact = xij_aor, and its IXXXXX/OXXXXX branch is live only in the sliver
vtiny=1e-8 <= 1-y < tiny=1e-6, which colltest never enters since it sets
tiny=1d-12.

Equivariance: it holds for FSR too, which matters because the whole
subtraction rests on the reduced Born and the real event sharing a frame in
the singular region. The spectator boost has
shybst = -(shat-sumrec^2)/(2 sumrec sqrt(shat)) with recoil = p_total-p_mother,
which in the partonic c.m. vanishes exactly when E_m = |p_m|, i.e. for a
massless mother. Since m_mother^2 = 2 E_i E_j (1-y), shybst = O(1-y) -> 0.

The reconstruction itself needed nothing new. The FSR emission is generated
about a +z mother as (cos(phi_i),sin(phi_i),0) and then passed through
rotate_invar, which is R_z(phi) R_y(theta) and so maps x,y onto exactly the
e1,e2 of the helicity basis azifact_from_kperp uses. The rotated vector
therefore has azimuth phi_i again and the same routine serves both cases,
differing only by a conjugation -- spacelike against timelike splitting:

    ISR net factor = -dconjg( azifact_from_kperp(mother, kperp) )
    FSR net factor = -        azifact_from_kperp(mother, kperp)

Checked at Lambda=1 against the shipped -(cphi_m - i sphi_m)**2 * xij_aor over
672 configurations of (theta_m, phi_m, phi_i): worst deviation 1.4e-15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Converts all 43 matrix-element call sites to the frame wrappers, and fixes the
default that this exposed. Unpolarised p p > z j [QCD] is back to exactly
2.854e+04 +- 1.9e+02 pb with the test_ME soft and collinear checks passing.

B5 is now handled structurally instead of by inspection. Rather than argue
about which SBORN callers are reachable in which mode, the boost is made a pure
function of the momenta passed: callers that share momenta necessarily get the
same boost, and callers with different momenta already had to reset the shared
cache between them. That matters because the reachability argument is exactly
what went wrong twice in M1 -- once as a silent 3% shift, once as the
"momenta not the same in Born" stop.

New wrappers in boost_to_frame.f: sborn_frame (M1), sborn_sf_frame,
extra_cnt_frame and smatrix_real_frame, the last using the real-emission mask
derived from the Born one and i_fks. Converted: fks_singular.f 30,
montecarlocounter.f 5, montecarlocounter_alt.f 5, add_write_info.f 1 (which
retires B7, the event-writing path), check_poles.f 1, test_soft_col_limits.f 1.
The EW Sudakov files and symmetry_fks_v3.f are left alone deliberately: they
are a separate feature and a separate executable, and neither shares a process
with the integration, so neither can trip the cache.

The sweep then moved the unpolarised cross section to 2.834e+04 +- 1.9e+02.
That is 0.74 sigma, but the preceding runs had been bit identical, so it was a
real change. MadFKS does not hand the matrix elements momenta in their own
partonic c.m.: shy_lbst is non-zero for any real emission, so the two initial
momenta carry different energies (genps_fks.f:3074-3082) and the event sits in
a frame boosted along z. Honouring me_frame=[1,2] literally therefore boosted
every configuration longitudinally -- an identity for |M|^2, but not bit for
bit, and enough to send the adaptive grids elsewhere. Control: the same build
with me_frame=[0], every mask empty, reproduces 2.854e+04 exactly.

The semantics were right and are kept -- [1,2] means the partonic c.m., as at
LO, and M1 agreed with LO on that setting at 1.0 sigma -- but the default was
wrong, since an unpolarised run must not pay for machinery it never asked for.
frame_id is now 0, meaning skip, unless me_frame actually appears in the
run_card. The LO path is untouched: there the momenta really do arrive in the
lab frame, so [1,2] has always been a meaningful boost.

Still to come in M2: the azimuthal wiring, which must land as a unit --
azifact_from_kperp when the boost is non-trivial, getaziangles on the boosted
mother instead of the ISR hardcode, and deletion of the R_y(pi) flip.

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

Two corrections to the me_frame handling, both pointed out in review.

First, skip the identity boost structurally rather than numerically. Applying a
boost that is mathematically the identity still goes through boostx and
perturbs the momenta in the last bits, which is enough to move an adaptive
integration -- as the previous commit found the hard way, at 0.7% on an
unpolarised run. LO already avoids this in two places: the call site skips
frame_id=6 (auto_dsig_v4.inc:183), and boost_to_frame() skips the
all-final-state selection (genps.f:1782), whose comment says of the other case
"1 1 0 0 0 .... should not go within this function". get_me_frame_boost now
folds in both, returning trivial for nothing-selected, exactly the initial
state, exactly the whole final state, or an already-at-rest system. The first
version tested only the last of those, which at NLO never fires: MadFKS works
in a frame boosted along z, so neither spelling of the partonic c.m. is at rest
there and both were being applied as real longitudinal boosts.

Second, refuse frames built from the initial state. This is a stronger point
than the numerical one and it constrains what the feature accepts rather than
what it skips: the real emission and the reduced Born carry momentum fractions
differing by a finite amount even in the singular limit, so a frame defined
from the initial state is discontinuous across that limit and the subtraction
stops cancelling. No azimuthal fix can repair that. RunCardNLO.check_validity
now rejects an me_frame mixing initial-state legs with final-state ones and
says why. [1,2] and the full final state stay accepted, since they only name
the partonic c.m. and are skipped. The run_card comment now directs users to
define the frame from final-state particles only, which is what the physics
wants anyway: the polarised system is a set of final-state spectators, present
unchanged in both the real and the Born.

Re-validated: unpolarised p p > z j [QCD] 2.854e+04 +- 1.9e+02 (baseline),
p p > z{0} j [LOonly=QCD] 1366 +- 3.1 in the Z frame and 977.3 +- 2.3 in the
partonic c.m., both identical to the M1 numbers. The structural skip reproduces
the boost exactly for LOonly, as it must: with no emission shy_lbst=0 and the
Born really is in the partonic c.m.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wires the covariant azimuth into the collinear counterterms. Inert for
everything currently enabled, and NOT enabled itself: test_ME still fails on a
polarised boosted run, so [real=QCD] stays refused at parse time.

azifact_me_frame rebuilds the frame from the same Born momenta the matching
sborn_frame call receives, boosts the mother and xij_kperp together, and
returns -exp(2 i psi). Boosting both is legitimate because xij_kperp is
orthogonal to the mother and a boost preserves that, so projecting onto the
helicity basis of the boosted mother recovers the azimuth.

sborncol_isr and sborncol_fsr take it as a package. In the boosted branch they
apply neither the cphi_mother=1 shortcut, nor the R_y(pi) flip, nor
getaziangles: all three only ever compensated for the mother lying on the beam
axis or for a basis convention, and the psi form carries them by construction.
That is B3. The legacy branch is left exactly as it was, so runs without a
frame stay bit identical rather than merely equivalent.

The gate fails: p p > z{0} j [real=QCD] with me_frame=[3] gives soft 0.48 and
collinear 0.37. Bisection:

  unpolarised [QCD] regression, no frame   2.854e+04, PASSES
  (A) polarised [real=QCD], no frame       PASSES
  (B) unpolarised [real=QCD], me_frame=[3] PASSES
      polarised [real=QCD], me_frame=[3]   FAILS

so it needs polarised and boosted together. (B) is weaker than it looks: for an
unpolarised matrix element the boost is a physical no-op, so it only shows the
boost does not crash or corrupt momenta and cannot test frame consistency
between the real and the Born. (A) is the informative one -- the polarised
subtraction itself is sound.

Ruled out: not the azimuthal phase alone, since the soft test fails too and the
soft counterterm has no azimuthal factor; not the real/Born leg mapping, since
get_frame_mask_real was checked against the shipped fks_info.inc and selects
PDG 23 in the real for every FKS configuration including the i_fks=4 ones; not
localised to ISR or FSR, since failures are uniform across every configuration
and every P dir. The pattern points at the real and the counterterm Born being
evaluated in frames differing by a finite amount rather than by O(xi)/O(1-y).

Next step is instrumentation, not more reasoning: compare the boost 4-vector
taken for the real against the one taken for the reduced Born at the same
counter-event. D3 already asks for that assertion; it should become a runtime
check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Q term is non-zero only for a vector mother (m_type.eq.8), and the born
q q~ > z z has quark mothers on both incoming legs, so the azimuthal phase
never enters that process. p p > z z [real=QCD] with me_frame=[3,4] is
therefore a clean probe of boost consistency with the phase machinery out of
the picture, and it is reported to have worked in the past with a boost alone.

This also revises the gate advice recorded earlier. The scope narrowing there
was written as a warning that a quark-mother process would pass even with a
wrong phase; that same property is what makes this process useful now, as a
null for the phase and a pure test of everything else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…al=QCD]

The M2 blocker was not frame consistency. It is a branch point in HELAS, and
the same bug is live at LO in a shipped feature.

me_frame=[3] -- "the Z rest frame", the standard way to ask for Z polarisation
-- selects a single leg, so the boost puts that leg at rest, and there it has
to be exactly right rather than right to rounding. vxxxxx branches on
pp.eq.rZero: at exactly zero the quantisation axis is the frame's z axis, and
the lambda=0 vector is (0,0,0,1); otherwise it is built from
p(3)*p(0)/(vmass*pp), which for p(3) ~ pp ~ 1d-14 is O(1) pointing along
rounding noise. boostx reaches p=0 only up to the rounding of lf, so whether
the residual lands on zero is decided independently for the real and for the
reduced Born, and the two are then evaluated with different quantisation axes.
Measured: soft ratio 1 to 1d-7 when the residual was exactly zero, 2.2d-3 when
it was 3d-14, one to one with no intermediate values -- a factor 460 in |M|^2
from a 3d-14 momentum.

Fix: when the selection is one leg, impose the defining property of the frame
and zero that leg's 3-momentum exactly after boosting. Only nsel==1 needs it;
with two or more legs it is their sum that is at rest and no leg sits on the
branch point.

The same defect is in Template/LO/SubProcesses/genps.f, where me_frame has
always been available, so a fraction of polarised LO events has been getting a
rounding-noise polarisation axis. Fixed there too, which M1 required anyway
since its gate compares [LOonly=QCD] against LO madevent.

Also adds a betamin=1d-4 guard in get_me_frame_boost. A near-identity boost is
the numerically dangerous one: it tilts the on-axis beams just enough that
p(0)+p(3) in the HELAS massless spinors is a few ulp, missing the exact on-axis
branch. Measured p(0)+p(3) = 3.3d-9, 3.3d-11, 3.4d-13, 0, 0, 0 down a soft scan
with the ratio deviating 9d-6, 6.5d-4, 2.1d-2, 2d-6, 4d-7, 2d-6 -- growing like
1/beta^2 exactly in that band. This guards the symptom, not the cause: the
underlying HELAS fragility is recorded as B9 and is not fixed.

Validated (all reproduced independently of the session that found it):

  unpolarised p p > z j [QCD]        2.854e+04 +- 1.9e+02, unchanged
  M2 gate, p p > z{0} j [real=QCD],
    me_frame=[3]                     0 FAILED / 448 PASSED, xsec 2.035e+03
  LO p p > z{0} j, me_frame=[3]      1370 +- 1.7 -> 1405 +- 1.6  (2.6%, 14 sigma)
  M1 gate re-closed                  LO 1405 +- 1.6 vs LOonly 1401 +- 3.1, 1.1 sigma

Both sides of the M1 gate moved by exactly +35 pb, which is the check that the
LO and NLO fixes are the same fix rather than two that happen to agree.

Unpolarised runs are untouched by construction: frame_id=0 leaves every mask
empty and get_me_frame_boost returns trivial on nsel==0 before reaching either
new branch.

[real=QCD] is now accepted for polarised massive particles. [QCD] and the modes
including the virtual are still refused.

Root cause found by a dedicated investigation session; the p p > z z lead that
isolated it (Q vanishes for quark mothers, so the azimuthal machinery is out of
the picture) came from review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wires the me_frame boost through the virtual. Not enabled: check_poles fails on
a polarised boosted run, so [QCD] and the other modes including the virtual
stay refused at parse time. [LOonly=QCD] and [real=QCD] are unaffected and
remain enabled and validated.

binothlha_frame boosts the momenta handed to BinothLHA, which passes them
straight to sloopmatrix_thres and takes born_wgt from the caller rather than
recomputing it, so a single call site covers the virtual.

check_poles on p p > z{0} j [QCD] with me_frame=[3]: poles do not cancel, 20
miscancellations. test_ME is clean on the same run (0 FAILED, 40 PASSED), so
the M2 work is unaffected and this is purely the virtual.

The coefficients show it is not a missing factor:

  COEFFICIENT DOUBLE POLE:  MadFKS -3.23e-3  OLP -4.88e-5   ratio 66
  COEFFICIENT DOUBLE POLE:  MadFKS -5.13e-3  OLP -9.43e-5   ratio 54
  COEFFICIENT SINGLE POLE:  MadFKS -4.06e-3  OLP -7.56e-5   ratio 54

MadFKS builds its poles from the boosted Born and the OLP's come from MadLoop;
the ratio is O(50-60) and not constant, so MadLoop is evaluating a different
polarisation state rather than the same one scaled wrongly.

Two MadLoop settings were suspected, both now excluded by test rather than by
argument:

 - NRotations_DP/QP re-evaluate the loop at a rotated phase-space point and
   expect |M|^2 unchanged. That fails for a particle at rest, whose axis is the
   frame z axis and does not rotate with the momenta -- but both default to 0,
   so the rotation test never runs. A latent hazard worth documenting, not this
   bug.
 - ImprovePSPoint (default 2) deforms the phase-space point to restore exact
   onshellness and could move the deliberately-zeroed leg off zero. Setting it
   to -1 does not fix it: still 20 miscancellations, ratio still ~58.

Next step is the instrumentation that solved M2: print what MadLoop actually
receives and which quantisation axis it uses, and compare with the Born. Worth
checking first whether MadLoop applies the polarisation restriction the same
way the tree-level Born does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
check_poles.f was half converted by the M2 sweep. Its Born line went through
sborn_frame, while the BinothLHA call immediately below it still received the
unboosted p_born. getpoles builds the MadFKS poles from common/pborn/p_born via
sborn_frame regardless of its own p argument, so the gate compared MadFKS poles
in the Z rest frame against MadLoop evaluated in the partonic c.m., and failed
every point.

The integration was already correct -- fks_singular.f:7123 has used
binothlha_frame since the previous commit -- and check_poles.f compiles only
into the check_poles executable (POLES in the P-dir makefile; the integration
uses driver_mintFO.o), so no cross section can move. Only the gate was lying.

Evidence: the MadFKS/OLP pole ratio equals Born(me_frame)/Born(partonic c.m.)
to 1e-13 at all 20 points. It is exactly constant within a point -- both poles
are proportional to the Born -- and only the per-point value varies.

p p > z{0} j [QCD], me_frame=[3], nn23lo1, fixed scales 91.188, ptj 30,
etaj 4.0: check_poles now cancels 20/20 in all 12 P dirs (was 0/20), test_ME
unchanged, calculate_xsect NLO completes at 2.193e+03 +- 1.2e+01 pb. Same for
p p > z{0} z{0} and p p > z{0} z{0} j. Unpolarised check_poles output is
byte-identical with and without this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ProcessDefinition.__iter__ rebuilt each expanded leg as Leg({'id':..,
'state':..}) and never carried polarization across. Its docstring says "not
used by MG which used some smarter version (use by ML)", and that is exactly
the exposure: loop_interface.py:885 iterates it and re-issues each combination
as a text command, so by the time nice_string runs the {0} is already gone.

Effect: p p > z{0} j [virt=QCD] generated P0_gu_zu -- no z0 -- with 24 helicity
configurations covering all three Z polarisations. A user asking for a
polarised loop silently received the unpolarised one.

Only the MadLoop path was affected. Explicit flavours were always correct
(u u~ > z{0} g [virt=QCD] gives P0_uux_z0g, 8 configurations), and tree level
is correct with multiparticles because MultiProcess.generate_multi_amplitudes
copies polarization properly. This mirrors what that function does.

list() so each yielded process owns its polarization instead of sharing the
definition's list object.

After: P0_gu_z0u with 8 configurations in all seven subprocess MEs; unpolarised
p p > z j [virt=QCD] still gives 24, unchanged. Validated by the standalone's
own analogue of the pole check -- the IR pole coefficient is a colour factor
times the Born, so 1eps/(born*ao2pi) must be identical restricted or not while
the Born differs: g u > z u gives -4.1666666666641756 polarised (Born
3.1963946124086368e-04) against -4.1666666666665897 unpolarised (Born
0.55343246189280271). Both -25/6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A polarised massive particle needs a frame. There are two ways to have one and
the guard now recognises both.

From the run_card: modes loonly, real and all -- the last being what the parser
calls [QCD] with no mode keyword (LoopOption='all') -- for a purely QCD
perturbation. Every piece of those computations is boosted into me_frame: the
Born (M1), the reals and the full set of FKS counterterms (M2), and the virtual
through binothlha_frame (M3). check_poles cancels 20/20 in every P dir of
p p > z{0} j [QCD] with me_frame=[3].

From the user: mode virt outputs standalone MadLoop, where the caller supplies
the phase-space point and so chooses the frame. There is no run_card, no
me_frame and nothing for the code to get wrong, whatever the perturbation
orders. This was previously refused on the grounds that "it has no run_card so
it has no frame", which had the implication backwards.

Still refused, each for a reason: mixed and pure QED perturbation in the
boosted modes, because the frame wrappers are order-agnostic and the QED
counterterms go through them too, so it is likely to work but nothing in the
QED sector has been run.

Also drops the stale commented-out `order != 'qcd'` guard and the dead `order`
it parsed.

test_check_generate encoded the old restriction ('u u~ > w+{L} [QCD]' sat in
the invalid list). The massive-colourless QCD forms and both [virt=..]
spellings move to the accepted list; [QED QCD] and [QED] stay rejected. That
test caught the first version of this change quietly opening mixed-order NLO.

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

M3 is the last physics milestone. Records the root cause (a half-converted
check_poles.f), the diagnosis, the gate results for three processes, what the
guard now accepts and refuses, and the merge coupling with the two sibling
fixes.

Also records two things the previous revision got wrong, because both cost
real time:

- "the ratio is O(50-60) and *not* constant" was a misreading. The three
  quoted COEFFICIENT lines are three different phase-space points, one line
  each. Within a point the ratio is exactly constant, which says "same
  observable, wrong Born" -- a frame mismatch -- and points at the driver
  rather than at MadLoop. That single misreading sent the whole search into
  MadLoop internals. Group diagnostics by phase-space point before concluding.
- MadLoop's polarisation restriction was never at fault. HelConfigs.dat holds
  exactly the Born's NHEL rows, NCOMB == MAX_BHEL.

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

ProcessDefinition.get_process hardcoded 'polarization':[] on every leg it
built, so `check ... p p > z{0} j` silently validated the *unpolarized*
process. run_multiprocs_no_crossings, the tree-level check driver, dropped
it the same way; it now delegates to get_process.

get_process only receives ids, so the polarization is read positionally
from self['legs'] (initial-state legs then final-state legs), mirroring
MultiProcess.generate_multi_amplitudes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…avor-grouped check counts

Two independent causes behind three failing acceptance tests in
tests/acceptance_tests/test_cmd.py.

1. aloha_writers.combine_name -- genuine bug (test_check_gauge_epem_vevex_wpwm)

   The second ("FFV2_FFS1") naming scheme unconditionally appended
   %(propa)s. For an amplitude (outgoing == 0) HelasAmplitude.
   get_helas_call_dict fills propa='' and puts the FLV_Coupling 'M' flag
   into 'tags' instead, so the call site emitted FFV2_FFS1_0 while ALOHA
   wrote FFV2_FFS1M_0. The first ("FFV1_2") scheme already switches to
   %(tags)s for amplitudes; this makes the second scheme do the same.

   The mismatch hit every consumer of combine_name, not just one export:

     * python/f2py (used by process_checks.evaluate_matrix_element):
       `check gauge e+ e- > ve ve~ w+ w-` died with
       NameError: name 'FFV2_FFS1_0' is not defined
     * fortran standalone: `set gauge FD; generate vt vt~ > ta+ ta-;
       output standalone` emitted CALL FFV2_FFS3_0 against a shipped
       FFS3M_0.f, i.e. an undefined symbol at link time. With the fix the
       call is FFV2_FFS3M_0, `make check` links and ./check runs.
     * C++ (export_cpp and madmatrix both call this same function for the
       call site), fixed by the same change.

   Verified: check gauge e+ e- > ve ve~ w+ w- now reports Unitary /
   Feynman / Axial / FD agreeing to 3e-15.

2. flavor grouping -- stale expectations (test_check_pp_wpwm,
   test_check_gauge_pp_wpwm)

   Both asserted 'Summary: 4/4 passed, 0/4 failed' for `check p p > w+ w-`.
   Since flavor grouping became the default, the four light-quark
   subprocesses are carried by one merged matrix element and the gauge /
   lorentz / permutation blocks report 1/1 on `Q Qx > w+ w-`. Nothing is
   numerically wrong; only the counts collapsed.

   Judgement call, stated explicitly: the expectations are updated (option
   a), rather than making `check` bypass flavor grouping (option b).
   Grouping is the shipped default, so the merged matrix element is the
   object users actually get, and it is what `check` should validate.
   Turning grouping off inside `check` would also disable check_flavor --
   it needs a merged model to have anything to compare against -- so it
   would trade per-flavor gauge coverage for losing all validation of the
   newer, riskier grouped path.

   The coverage loss is real but bounded and is called out in the tests:
   the gauge block now exercises flavor index 1 only, while the
   flavor-grouping block still compares merged against unmerged for all
   eight flavor/ordering combinations. test_check_pp_wpwm now asserts that
   8/8 block as well, so the per-flavor coverage stays pinned where it
   actually lives. Users wanting the old per-flavor gauge table can still
   `set apply_flavor_grouping False`.

Verified with
  ./tests/test_manager.py -p A test_cmd.py test_check_pp_wpwm \
      test_check_gauge_pp_wpwm test_check_gauge_epem_vevex_wpwm
which pulls in all of TestCmdShell2. Before: 11 failures (these 3 plus 8
pre-existing). After: the same 8, unchanged, and no new ones. The
remaining FD-gauge failure, test_standalone_cpp_fd_output_consistency,
does not share this root cause -- it fails identically before and after,
and its process (`set gauge FD; generate _quark _quark > h _quark _quark
_quark _anti_quark QCD=0; output standalone_mg7`) builds and runs
correctly outside the test harness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
help_polarization only described the LO feature. It now says which NLO modes
accept a polarised massive particle and where each gets its frame: [QCD],
[real=QCD] and [LOonly=QCD] read me_frame from the run_card, while [virt=QCD]
and standalone MadLoop take the frame from the momenta the caller supplies. It
also warns that a frame must be built from final-state particles, since one
built from the initial state is not infrared safe at NLO and is refused.

Two corrections to what that help said before:

 - 'nhel=1' was presented as a requirement for polarisation. It is not, it is a
   variance choice: Monte-Carlo over helicities is an unbiased estimator of the
   same cross-section. Measured on p p > z{0} j, me_frame=[3]: nhel=1 gives
   1369 +- 1.7 and nhel=0 gives 1370 +- 1.7.
 - me_frame was described as if the partonic c.m. were the only meaningful
   default. At NLO [1,2] names a frame that is skipped rather than applied.

The acceptance test covers p p > z{0} z{0} j [QCD] with me_frame=[3,4] and
asserts on check_poles rather than on a cross-section. That is deliberate:
those two checks are the only ones sensitive to the frame handling. The poles
are proportional to the Born, so a Born/virtual frame mismatch shows up as a
per-point constant ratio between the MadFKS and OLP poles; and the collinear Q
term is the same order as the AP term, so a wrong azimuthal phase does not
cancel and the soft/collinear ratios plateau off 1. A cross-section assertion
would have caught neither -- both were broken at some point during development
while the total stayed plausible.

The test sets the run_card through the banner API rather than command-line
options, since do_calculate_xsect takes a fixed optparse parser and would
reject them, and uses nn23lo1 because the python lhapdf bindings are broken
under some interpreters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
M0 makes the FKS exporter write one frame_info.inc per P directory, holding
frame_map_born -- the map from the leg numbering the user wrote to the leg
positions the fortran uses, which me_frame needs because sort_proc renumbers
the born legs. The IO reference sets predate that file, so the comparison found
a generated file with no counterpart and raised "Missing ref. files".

Regenerated with ./tests/test_manager.py -U for the three affected tests:
test_wprod_fksew (2 P dirs), test_pptt_fksrealew (7) and test_ppzz_ewsudakov
(10). Nineteen files added, none modified -- which is the point worth checking:
the only change M0-M4 makes to generated output is this new file, consistent
with the unpolarised cross-section staying bit-identical through every
regression run.

Only these three surfaced it because the other FKS IO tests are bypassed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three gaps in what M4 claimed, all found by review rather than by me.

The plan still said M4 was partly done with help_polarization "to revisit".
That was a stale row: the commit that was supposed to update it used a text
substitution which silently matched nothing, because the M3 session had already
reworded it. Fixed, and this time the result was checked rather than the
substitution trusted.

help_polarization listed the NLO modes but never said the run_card path is QCD
only (the guard tests pert_orders == 'qcd'), so a reader would have concluded
[QED] worked. It now separates the two cases: [QCD], [real=QCD] and
[LOonly=QCD] read me_frame from the run_card and are QCD-only because nothing
in the QED sector has been validated, while [virt=QCD] and standalone MadLoop
take the frame from the caller's momenta and accept any order.

There was no CI check. The acceptance test existed in
tests/acceptance_tests/test_cmd_amcatnlo.py but acceptancetest.yml invokes
tests by explicit name and this one appeared nowhere, so it would never have
run. Added as acceptancetest_105, modelled on the test_calculate_xsect_nlo job.

The test had also never been executed -- committing it was premature. It has
now been run and, more to the point, checked for doing real work rather than
passing vacuously: 12 P directories, 12 check_poles.log, zero miscancellations,
and 'generate p p > z{0} z{0} j [QCD]' with '[3, 4] = me_frame' read back from
the generated cards. 3.084e-01 +- 3.0e-03 pb.

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.

1 participant