diff --git a/.github/workflows/acceptancetest_madevent.yml b/.github/workflows/acceptancetest_madevent.yml index 457bb7c452..169d1e7aa8 100644 --- a/.github/workflows/acceptancetest_madevent.yml +++ b/.github/workflows/acceptancetest_madevent.yml @@ -89,6 +89,7 @@ jobs: cd $GITHUB_WORKSPACE ./tests/test_manager.py test_group_subprocess -pA -t0 -l INFO ./tests/test_manager.py test_group_subprocess_permuted_scale -pA -t0 -l INFO + ./tests/test_manager.py test_mlm_pdfrwt_flavour -pA -t0 -l INFO # ---- acceptancetest_contur ---- - name: "acceptancetest_contur: run the test" diff --git a/.github/workflows/acceptancetest_mg7.yml b/.github/workflows/acceptancetest_mg7.yml index e10dcb57a5..2cc8e69569 100644 --- a/.github/workflows/acceptancetest_mg7.yml +++ b/.github/workflows/acceptancetest_mg7.yml @@ -608,3 +608,23 @@ jobs: # LHAPDF_DATA_PATH (install_lhapdf_pdfset) holds only NNPDF23: unset, these # tests find NNPDF40_lo_as_01180 in the heptools data dir via lhapdf-config env -u LHAPDF_DATA_PATH ./tests/test_manager.py test_output_mg7_decay_run_card_has_no_cuts test_output_mg7_decay_subprocess_metadata test_color_flow_consistency_mg7 test_systematics_mg7_legacy test_systematics_mixed_order_mg7 test_mg7_reweighting_at_generation test_delphes_mg7 -pA -t0 -l INFO + + acceptancetest_mg7_launcher_cut_names: + needs: build_madspace + # Unit tests, but they belong here rather than in unittest.yml: they need a + # compiled madspace to check that the cut names the mg7 launcher writes are + # ones ms.Observable accepts, and the unit jobs have none (they would skip). + # No LHAPDF or model files: nothing is generated, the launcher's cut helpers + # are called on a stand-in run card. Hence no heptools restore, and no -pA: + # these live in the default tests/unit_tests path. + 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/install_madspace + - uses: ./.github/actions/restore-pip-cache + - name: test the mg7 launcher writes cut names madspace can build + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py test_every_cut_it_writes_builds_an_observable test_it_tightens_the_jet_pt_and_pair_mass_to_xqcut test_it_drops_the_jet_cone_cuts test_with_the_option_off_it_only_zeroes_cuts_above_xqcut test_it_leaves_an_unmerged_run_alone -t0 -l INFO diff --git a/Template/LO/SubProcesses/cluster.f b/Template/LO/SubProcesses/cluster.f index 8d069ea5d3..57b832abc9 100644 --- a/Template/LO/SubProcesses/cluster.f +++ b/Template/LO/SubProcesses/cluster.f @@ -658,14 +658,20 @@ logical function cluster(p, flavor, ivec) enddo mt2last=0 minpt2ij=1.0d37 -c Map merged flavor particles (PDG=81) to their actual quark flavors +c Map the quark legs (and merged 81 legs) to the representative of the +c coupling class of this event: FLAVOR(i) is the position of that +c representative in the merged quark group, which is its pdg since the +c quarks are always merged in pdg order (1,2,...). It is NOT the flavour +c of the event (s s~ > z gives 1, c c~ > z gives 2): the flavour +c combination (IPSEL) is only drawn after the scales are set, and rewgt +c sets the external identities to it after clustering. do i=1,nexternal - if(flavor(i).ge.1.and.flavor(i).le.5)then + if(flavor(i).ge.1.and.flavor(i).le.6)then do igraph=1,n_max_cg tmppdg=ipdgcl(ishft(1,i-1),igraph,iproc) - if(tmppdg.eq.81.or.(tmppdg.gt.0.and.tmppdg.le.5)) then + if(tmppdg.eq.81.or.(tmppdg.gt.0.and.tmppdg.le.6)) then ipdgcl(ishft(1,i-1),igraph,iproc)=flavor(i) - else if(tmppdg.eq.-81.or.(tmppdg.lt.0.and.tmppdg.ge.-5))then + else if(tmppdg.eq.-81.or.(tmppdg.lt.0.and.tmppdg.ge.-6))then ipdgcl(ishft(1,i-1),igraph,iproc)=-flavor(i) endif enddo diff --git a/Template/LO/SubProcesses/reweight.f b/Template/LO/SubProcesses/reweight.f index e0840a42c3..8237672454 100644 --- a/Template/LO/SubProcesses/reweight.f +++ b/Template/LO/SubProcesses/reweight.f @@ -1504,28 +1504,26 @@ double precision function rewgt(p, flavor, ivec) rewgt = 0d0 return endif -c Set incoming particle identities - ipdgcl(1,igraphs(1),iproc)=idup(1,ipsel,iproc) - ipdgcl(2,igraphs(1),iproc)=idup(2,ipsel,iproc) - if (btest(mlevel,2)) then - write(*,*) 'Set particle identities: ', - $ 1,ipdgcl(1,igraphs(1),iproc), - $ ' and ', - $ 2,ipdgcl(2,igraphs(1),iproc) - - endif - +c The flavours of this event are those of row IPSEL of leshouche.inc, +c as in DSIG. The rows always carry physical pdg codes (merged legs such +c as 81 are expanded per flavour), while FLAVOR(:) only holds the +c position of the coupling-class representative (see cluster). + do j=1,2 + if(abs(idup(j,ipsel,iproc)).ge.81.and. + $ abs(idup(j,ipsel,iproc)).le.83)then + write(*,*) 'Error: rewgt: merged particle ',idup(j,ipsel,iproc), + $ ' in leshouche.inc (row ',ipsel,'): no physical pdg for', + $ ' the PDF of beam ',j + stop 1 + endif + enddo if(ickkw.le.0)then c Store pdf information for systematics studies (initial) if(use_syst)then do j=1,2 n_pdfrw(j,ivec)=1 - if(abs(ipdgcl(j,igraphs(1),iproc)).eq.81) then - i_pdgpdf(1,j,ivec)=abs(flavor(j))*sign(1,ipdgcl(j,igraphs(1),iproc)) - else - i_pdgpdf(1,j,ivec)=ipdgcl(j,igraphs(1),iproc) - endif + i_pdgpdf(1,j,ivec)=idup(j,ipsel,iproc) s_xpdf(1,j,ivec)=xbk(ib(j)) s_qpdf(1,j,ivec)=sqrt(q2fact(j)) enddo @@ -1545,6 +1543,19 @@ double precision function rewgt(p, flavor, ivec) endif vec_igraph(ivec) = igraphs(1) ! save MLM-matched graph for this event +c Set the identities of the incoming partons and of the jets of the +c clustered graph to the flavours of this event: setclscales leaves there +c the representative of the coupling class of a grouped subprocess (e.g. +c d for s s~ > z, u for c c~ > z), which would otherwise enter the PDF +c ratios, the Sudakov factors and the pdfrwt information below. Other +c legs are left as set by cluster (nothing below reads their flavour). + do i=1,nexternal + if(i.le.2.or.isjet(idup(i,ipsel,iproc))) + $ ipdgcl(ishft(1,i-1),igraphs(1),iproc)=idup(i,ipsel,iproc) + if (btest(mlevel,2)) + $ write(*,*) 'Set particle identities: ', + $ i,ipdgcl(ishft(1,i-1),igraphs(1),iproc) + enddo c Store pdf information for systematics studies (initial) c need to be done after setclscales since that one clean the syscalc value @@ -1608,12 +1619,6 @@ double precision function rewgt(p, flavor, ivec) c Prepare checking for parton vertices do i=1,nexternal j=ishft(1,i-1) -c Set jet identities according to chosen subprocess - if(isjet(idup(i,ipsel,iproc))) - $ ipdgcl(j,igraphs(1),iproc)=idup(i,ipsel,iproc) - if (btest(mlevel,2)) - $ write(*,*) 'Set particle identities: ', - $ i,ipdgcl(j,igraphs(1),iproc) if(i.le.2)then goodjet(j)=isparton(ipdgcl(j,igraphs(1),iproc)) elseif(iqjets(i).gt.0) then @@ -1883,15 +1888,9 @@ double precision function rewgt(p, flavor, ivec) c factor in front of matrix element do i=1,2 if (lpp(IB(i)).ne.0) then - if( abs(i_pdgpdf(1,i,ivec)).ne.81)then s_rwfact(ivec)=s_rwfact(ivec)*pdg2pdf(abs(lpp(IB(i))), $ i_pdgpdf(1,i,ivec)*sign(1,lpp(IB(i))),IB(i), $ s_xpdf(1,i,ivec),s_qpdf(1,i,ivec)) - else - s_rwfact(ivec)=s_rwfact(ivec)*pdg2pdf(abs(lpp(IB(i))), - $ abs(flavor(i))*sign(1, i_pdgpdf(1,i,ivec))*sign(1,lpp(IB(i))),IB(i), - $ s_xpdf(1,i,ivec),s_qpdf(1,i,ivec)) - endif endif enddo if (asref.gt.0d0.and.n_qcd(ivec).le.nexternal)then diff --git a/UpdateNotes.txt b/UpdateNotes.txt index d8bb99a602..016fe9c68d 100644 --- a/UpdateNotes.txt +++ b/UpdateNotes.txt @@ -5,6 +5,10 @@ ANNOUNCEMENT: A new LTS (based on the current 3.5.X) is starting now and will act as the stable release for the coming years. 0.3.0 (XX/XX/XX): + OM: madevent MLM (ickkw=1) with use_syst: the entry of the incoming partons + used the flavour class of the grouped subprocess instead of the event's flavour + (pdg 1 for s s~, 2 for c c~), so the systematics (PDF and mu_F variations) of every + s/c-initiated event were computed with the d/u PDF (up to 9% for mu_F=2 on p p > z). OM: madevent LO vector mode (vector_size>1): the good-helicity scan saw a single beam orientation per warp, so up to half of p p > z was dropped. With helicity recycling, the madevent_forhel scan now runs event by event. Without it diff --git a/docs/mlm-merging-status.md b/docs/mlm-merging-status.md new file mode 100644 index 0000000000..594562ce6e --- /dev/null +++ b/docs/mlm-merging-status.md @@ -0,0 +1,371 @@ +# MLM merging in madspace — status, findings and open items + +Working notes for the `feat-mlm` branch after merging `main` into it. + +## What the feature is + +`madspace/src/phasespace/mlm_clustering.cpp` compiles the diagram topologies of a +subprocess into a state machine of possible clustering histories, encoded as a +flat integer array. `madspace/src/kernels/mlm.hpp` walks that state machine for +one phase-space point, picks a clustering history by minimising a clustering +measure at each step, and reads off: + +* the renormalisation scale, +* the factorisation scales, +* a clustering scale per outgoing jet (written to the LHE as `pt_clust_i`), +* the index of the diagram the history ends on (for colour flows and resonances + in the LHE output). + +`djb_clus`, `dj_clus`, `compute_scale` and `update_momenta` are ports of the +routines of the same name in `Template/NLO/SubProcesses/cluster.f`. + +## Merge with main + +The branch was 699 commits behind. Most of the conflict surface was not real: + +* The generated instruction-set headers (`compgraphs/*.inc`, the cpu/gpu runtime + mixins, `python/instruction_set.hpp`) are no longer checked in — main + generates them from `instruction_set.yaml` via `generate_headers.py`. Only the + two new `mlm_clustering_{hadronic,leptonic}` YAML entries were kept. +* `madevent.py` was split into `launch.py` / `run_interface.py`; the MLM hunks + were re-applied to `launch.py`. The matrix element is now built in a loop over + backends, so `me_inputs` is computed once before that loop. +* `run_card.toml` is now a template filled from `banner.py`, so `"mlm"`, + `beam.jet_radius` and `beam.max_jet_flavor` are declared there. The MLM + clustering options other than `max_jet_flavor` (`jet_radius`, `scale_scheme`, + `alphas_reweighting`, `pdf_reweighting`, `clustering_history`, ...) are + hidden: the template has no line for them, and `RunCardMG7.write` adds one at + the end of `[beam]` once they are set or differ from their default. +* `LHECompleter` was refactored on main; main's version was taken. The branch's + `TODO` about the permutation direction is gone because main fixed exactly + that, by inverting the permutation before using it. + +## Bugs found and fixed + +1. **`update_momenta` arguments were swapped.** The kernel called it as + `(..., p1_win, p2_win, ...)` against the signature + `(..., i_remove, i_keep, ...)`, so it kept `particle2` and removed + `particle1`. The state machine names the merged pseudo-particle by the lowest + bit of the combined mask, i.e. by `particle1`, so every later state referred + to a slot whose momentum had not been updated. It also made the + `i_keep < 2` initial-state branch unreachable, and inconsistent with the + caller's `is_initial = particle1 < 2`. + + The Fortran settles it: in `cluster_one_step`, `i` (removed) runs from 3 and + `j` (kept) runs `1..i-1`, so the kept index is the lower one and `jwin <= 2` + means "the kept particle is a beam". + +2. **The winner selection was not reset between clustering steps.** Only + `win_scale` was reset; `win_resonant` and `win_next_state` kept their values. + After a resonant step, no non-resonant candidate could ever win the next + step, and the walk would then jump to the previous step's target state. + +3. **Nothing guaranteed a winner at all.** `win_next_state` starts at `-1` and is + only assigned when a candidate beats the current best. A NaN measure never + compares less than anything, so a step in which every candidate was NaN left + `state = -1` and the kernel read outside the state machine — an actual crash, + reproducible at a few events per thousand for strongly asymmetric beam + energies. The boost and rotation `update_momenta` applies after an + initial-state clustering can leave a pair exactly collinear, which is where + the NaNs come from. + + Two fixes: `dj_clus` now zeroes a NaN result the way the Fortran does + ("prevent numerical inaccuracies"), and the selection takes the first + candidate unconditionally so a clustering is always chosen. + +4. **State machine states were expanded more than once.** In `find_clusterings` + the terminal branch guarded re-expansion with `if (current_states.size() == 0)` + but the recursive branch did not, so a state reached along several paths had + its whole transition list appended once per path. Harmless for the result, + but the table grows exponentially with multiplicity. + +5. **`bw_cutoff` was dead.** It was threaded from the run card all the way into + the kernel signature and never read: the resonance window was `mass ± width` + rather than `mass ± bw_cutoff * width`. + +6. **External masses were indexed by topology slot, not by external leg.** The + kernel indexes `external_masses` by leg, but the array was built as + `incoming_masses ++ outgoing_masses`, which is in slot order. Invisible for + the test processes, whose permutations only exchange identical gluons, but + wrong for any permutation that moves a mass between legs. The permutation is + now applied, and a mismatch between diagrams throws instead of being silently + ignored. + +7. **`alive` was hardcoded to `0xFFFFFF`** regardless of the particle count, and + an empty (dead-end) state would have been read out of bounds. Both now + handled. + +## Vertex metadata, previously not populated + +`is_qcd`, `is_jet1`, `is_jet2`, `mass_index` and the `massive_*` flags were all +hardcoded, so massless QCD was assumed everywhere and the resonance branch was +unreachable. They are now filled from the topology: + +* masses and widths come from `Topology::Decay`, and from + `t_propagator_masses()` for the t-channel chain, +* `Topology` gained `t_propagator_pdg_ids()`, filled alongside the existing + t-channel masses and widths, because the flavour of a t-channel line changes + along the chain and is needed to tell a QCD splitting from a QED one, +* the **external** pdg ids are not in `Topology` at all, so `MLMClustering` + takes them as a new constructor argument (`external_pdg_ids`, plus + `max_jet_flavor`). `launch.py` passes the `all_pids` it already computes. When + the argument is omitted the old behaviour — everything is a QCD jet — is kept, + so existing callers still work. + +`is_jet` mirrors `isjet()` in `Template/LO/SubProcesses/reweight.f` +(`|pdg| <= max_jet_flavor`, gluons, and the merged-flavour placeholder 81). A +vertex counts as QCD when all three of its lines carry colour, which is the same +rule `launch.py` already uses in `build_multi_channel_data`. A propagator pdg of +0 means "not supplied" and falls back to deciding from the two children. + +## Known differences from madevent, by design + +These are the open physics questions, not defects. They are what makes a direct +comparison against the validated madevent MLM disagree. + +* **Resonances.** MG5 reads the resonance structure off the SDE integration + channel, which madspace/madnis does not have. This implementation instead + prefers a resonant clustering over any non-resonant one, using the + Breit-Wigner window. That is a hard switch, so a phase-space distribution can + be discontinuous across the window boundary. + +* **The factorisation scale.** The kernel uses a single scale for both beams: + the smallest QCD clustering scale in the history, capped at mu_R. madevent + tracks the two QCD lines connected to each beam separately and sets + `mu_F(beam) = sqrt(pt2[jlast(beam)] * pt2[jcentral(beam)])`, so its two + factorisation scales differ from each other and neither is the softest + clustering. There is no equivalent of `jfirst`/`jlast`/`jcentral` here: the + kernel does not trace parton lines through the event the way `ipartupdate` + does. + +* **The renormalisation scale.** The kernel takes the geometric mean of all + clustering scales, replacing non-QCD ones by the largest scale. madevent takes + `(pt2[jlast1] * pt2[jcentral1] * pt2[jlast2] * pt2[jcentral2])^(1/8)`, i.e. + the geometric mean of four specific scales. + +* **No Sudakov reweighting**, which is the other half of what the NLO code does. + +## The generation-level merging cut: `phasespace.xqcut` + +MLM merging needs the matrix element to stop describing radiation the shower is +meant to produce, which madevent enforces with `xqcut`. madspace had no +equivalent, so its samples reached down to whatever the jet cuts allowed and +Pythia's matching veto had to throw the difference away. + +The rule is madevent's, from the "Check xqcut for vertices with jet daughters +only" block of `Template/LO/SubProcesses/reweight.f`: walking the clustering +history that was just selected, any step whose daughter is still a bare +final-state jet has to be at or above `xqcut`, or the event is dropped. The +test does not ask whether the vertex was a QCD one, and it reuses the same +"still a bare external leg" bookkeeping the emission scheme already needs. + +The clustering only runs for events that already passed the phase-space cuts, +so the veto comes back as a weight (1 or 0) that multiplies the event weight, +rather than as one of the cuts. An event below `xqcut` ends up at weight zero +and is never unweighted. + +`p p > t t~` + `t t~ j` at 13 TeV, everything else held fixed: + +| `xqcut` | cross section (pb) | +|--------:|-------------------:| +| 0 | 1462 ± 20 | +| 30 | 1181 ± 17 | +| 60 | 815 ± 13 | + +It is off by default (`xqcut = 0`), so turning it on is the only thing that can +change an existing result. + +## Validating the merging + +Differential jet rates reconstructed from a showered event record are what say +whether the merging works: each multiplicity sample has to switch off where the +next switches on, and their sum has to be smooth across the merging scale. That +tooling lives on its own branch (`claude/mlm-djr-plots`, +`madgraph/various/djr_from_hepmc.py`), since it is useful for madevent just as +much as for madspace. + +## Open items + +* **The trigger for MLM is `dynamical_scale_choice = "mlm"`, which is probably + the wrong switch and needs revisiting.** In madevent, merging is turned on by + `ickkw` (with `xqcut`), while `dynamical_scale_choice = -1` selects the CKKW + clustering scale. Conflating the two means MLM merging cannot be combined with + another scale choice, and the clustering scale cannot be used without merging. + A separate `[matching]`-style switch is the likely shape. + +* Whether the sqrt(s) fallback should stay a fallback. It is what madevent does + and it makes the output veto-safe, but a jet at sqrt(s) carries no + information; if merging is to work well, the fraction of jets landing there + needs to come down. + +* Pass the selected diagram index through to the matrix element. Neither + backend takes a per-event diagram yet: madmatrix accepts `diagram_in` and + ignores it, and the mg7 `api.cpp` rejects it as an unsupported input. Feeding + it `diagram_in` in place of `random_diagram_in` therefore left madmatrix + drawing its diagram, and with it the colour flow, at a fixed random number of + 0.5 for every event, so MLM keeps `random_diagram_in` until the backends + support it. Affects the LHE output only, not the scales. + +* The `dj_clus` massless/massive test is `mass > 0` here, where the Fortran uses + thresholds tied to `maxjetflavor` (`m >= 3` if `maxjetflavor > 4`, else + `m >= 1`). Equivalent for realistic masses; only differs for a tracked mass + between 0 and 1 GeV. + +* Different diagrams can put different propagators on the same partition of + external legs (a photon and a Z, say). The compiler keeps whichever has a + width, since that is the one the resonance test is about, but this is an + arbitrary choice where the diagrams genuinely disagree. + +## Comparison against the madevent MLM + +Process `p p > e+ ve j` at 13 TeV, 5000 unweighted events each, with the cuts, +the PDF set (`NNPDF23_lo_as_0130_qed`, lhaid 247000) and the collider energy +matched between the two runs. + +* mg7: `output mg7`, then `dynamical_scale_choice = "mlm"` in + `Cards/run_card.toml`. +* madevent: `output madevent`, then `dynamical_scale_choice = -1` (the CKKW + back-clustering scale) in `Cards/run_card.dat`. A second madevent run adds + `ickkw = 1` and `xqcut = 20` — neither is written into the run card for a + fixed-multiplicity process, so they have to be appended by hand — because + `addmothers.f` only writes the `` block when `ickkw > 0`. + +The hardest-parton pT distributions of the two runs agree closely, so the +differences below are the scale definitions rather than a different population +of phase space. + +### Renormalisation scale + +| | mean | q05 | q50 | q95 | +|--------------------|-----:|----:|----:|----:| +| mg7 MLM | 62.1 | 40.9 | 54.1 | 92.7 | +| madevent CKKW | 101.7 | 81.1 | 87.5 | 172.8 | +| mg7 / pT of the hardest parton | 1.58 | 0.94 | 1.62 | 2.13 | +| madevent / pT of the hardest parton | 2.89 | 1.42 | 2.87 | 4.15 | + +mg7's mu_R is about 0.61 of madevent's. This follows from the definitions and +is reproducible in closed form. For this process there are two clustering +steps: the resonant `(e+, nu) -> W`, and the jet clustered onto a beam. The +first is not a QCD splitting, so mg7 replaces its scale by the largest scale in +the history and takes the geometric mean, giving +`mu_R = sqrt(max(m_W, mT_j) * mT_j)`. Recomputing that directly from the LHE +momenta reproduces mg7's `scale` field exactly for 72.8% of events (all of the +`q qbar` ones), the rest being the case discussed below. madevent instead takes +the geometric mean of the four `jfirst`/`jlast`/`jcentral` scales, of which +three are the W here, so its mu_R sits close to m_W. + +Cross sections, same cuts and PDF, no merging on either side: mg7 +1557.5 +- 7.2 pb against madevent 1616 +- 8.4 pb, a 3.7% difference driven by +the scale. + +### Per-jet clustering scales + +When both codes assign a real clustering scale to the jet they agree exactly: +`pt_clust / pT_jet` is 1.000 at every quantile in both. Two differences remain. + +**mg7 leaves 27.2% of jets with `pt_clust = 0`; madevent leaves none.** The +cause is a topology whose clustering history contains no QCD splitting at all. +For the `g q > e+ ve q` subprocess one of the two channels is the s-channel +`g q -> q* -> (W)(q)`, whose only available clusterings are `(e+, nu) -> W` and +`(W, jet) -> q`. The second has a colourless parent, so it is not a QCD vertex, +and the branch that books a clustering scale onto a jet only runs for QCD +clusterings. In about 36% of `g q` events that history has the smaller measure +and wins, and the jet comes out with no scale. That also explains the remaining +27% of the mu_R comparison above: both clusterings are non-QCD there, so every +scale is replaced by the largest one and mu_R comes out as m_W exactly. + +This matters for merging, not just for bookkeeping: `pt_clust` is what the +matching veto compares against `qcut`, and a jet reported at zero is +indistinguishable from a lepton. + +**Non-jet legs were written as 0 by mg7 and as sqrt(s) = 13000 by madevent.** +madevent has an explicit fallback (`ptclus(...) = etot` in +`Template/LO/SubProcesses/reweight.f`) that sets any leg without a jet vertex to +the collider energy, so a veto of the form "reject if any pt_clust < qcut" can +never trip on it. + +### The sqrt(s) fallback, now implemented + +The kernel now does the same: any outgoing leg that no QCD clustering assigned a +scale to is reported at the collider energy rather than at zero. This needed the +collider energy in the kernel, which it did not have, so `MLMClustering` takes it +as a constructor argument (`cm_energy`, filled by `launch.py` from +`process.e_cm`) and it is threaded through as a new instruction input. + +Rerunning the same comparison afterwards: + +| | mg7 | madevent | +|---------------------------------------|------:|---------:| +| jets reported at zero | 0.0% | 0.0% | +| jets at the sqrt(s) fallback | 26.4% | 10.1% | +| `pt_clust / pT_jet`, where assigned | 1.000 | 1.000 | +| median assigned jet scale | 29.50 | 30.67 | +| non-jet legs | 13000 | 13000 | + +The convention now matches exactly, and where both assign a real scale they +still agree exactly. madevent reaches the fallback for 10.1% of its own jets, so +the mechanism is shared rather than an mg7 artefact — but mg7 reaches it 2.6 +times as often. + +### What is still open on this + +The fallback makes the LHE output usable by a matching veto; it does not remove +the reason mg7 lands there so much more often, which is the non-QCD clustering +history described above. Two further options, in increasing order of work: + +1. Restrict the clustering to histories that do cluster every jet, by rejecting + candidate transitions that would leave a jet unclustered — a change to the + state-machine compiler rather than the kernel. +2. Trace the parton lines through the event the way `ipartupdate` does, which is + also what would be needed for per-beam factorisation scales. + +### Which vertex a jet's scale comes from: `beam.jet_scale_scheme` + +madevent takes the **maximum** clustering scale over every jet vertex a leg's +*line* takes part in (`ptclus(leg) = max(ptclus(leg), ...)`, with the line +followed by `ipartupdate`), where the kernel originally took the scale of the +**first** clustering the bare leg took part in and froze it there. Both are now +available, selected by `beam.jet_scale_scheme`: + +* `"production"` (the default) - the hardest vertex on the parton line the leg + belongs to, i.e. the scale at which that line was produced. This is what + madevent does. +* `"emission"` - the vertex at which the leg itself was emitted, i.e. the + softest clustering it takes part in. + +**Why it matters.** `pt_clust_i` is not read by the jet-matching veto at all: +`madevent_interface.py` turns on `Beams:setProductionScalesFromLHEF` for an MLM +run, so Pythia reads it into `event[i].scale()` and uses it as the **starting +scale of the shower off parton i** (with `pTmaxMatch = 1`). The shipped +`JetMatching.h` uses the kinematical `pT()` for its veto, and only FxFx reads +the per-particle scale, as a tag. + +That makes `"production"` the motivated choice. It is the CKKW-style +prescription: reconstruct the branching history and restart the shower off each +line at the scale of the node that produced it, letting the veto handle the +overlap. In MLM the Sudakov suppression *is* the vetoed trial emissions, so +starting the shower at the last ME emission scale instead - which is what +`"emission"` does for a line that carries on - never attempts them, and the +relative normalisation between the n-jet samples comes out wrong. + +**What it took.** `"emission"` needs no knowledge of the event beyond the leg +indices; `"production"` needs the parton line followed through the clustering, +which is what `ipartupdate` exists for. The compiler now works out, per +transition and from the colour representations of the mother and the two +daughters, which daughter the line continues into (follow the first, the second, +the harder one, or both after a `g -> q qbar`), and stores it as a third word in +the state machine. The kernel keeps the resulting representative legs per slot, +the equivalent of `ipart`, and compares transverse momenta in the *original* +event when it has to pick the harder daughter, as the Fortran does. Colour +representations come from the `pdg_color_types` map already exported in the +subprocess metadata, with a Standard Model fallback. + +Where madevent's `ipartupdate` keys its leading cases on pdg equality +(`idmo.eq.idda1`), the colour-based table gives the same answer for every case +those cover. Where `ipartupdate` stops the run on an unrecognised vertex, the +compiler keeps the line on the first daughter: wrong, but local, and it costs +the clustering scale of one leg rather than the whole run. + +The two schemes coincide whenever no parton line carries on past the vertex that +emitted it, which is every leg of a 2 -> 3 process - and is why the earlier +`p p > e+ ve j` comparison could not have seen the difference. diff --git a/madgraph/interface/common_run_interface.py b/madgraph/interface/common_run_interface.py index aab4884416..0afa5de747 100755 --- a/madgraph/interface/common_run_interface.py +++ b/madgraph/interface/common_run_interface.py @@ -1472,9 +1472,15 @@ def create_plot(self, mode='Pythia8', event_path=None, output=None, tag=None): PY8_plots_root_path = pjoin(self.me_dir,'HTML', self.run_name,'%s_PY8_plots'%tag) - if 'ickkw' in self.run_card: - if int(self.run_card['ickkw'])==1 or \ - self.run_card['ktdurham']>0.0 or self.run_card['ptlund']>0.0: + # read through __getitem__: an mg7 run card answers these legacy keys + # (ickkw = 1 for an MLM sample) without containing them + try: + ickkw = int(self.run_card['ickkw']) + ckkw_cut = self.run_card['ktdurham'] > 0.0 or self.run_card['ptlund'] > 0.0 + except KeyError: + ickkw, ckkw_cut = 0, False + if ickkw or ckkw_cut: + if ickkw==1 or ckkw_cut: self.update_status('Create matching plots for Pythia8', level='pythia8') @@ -1491,6 +1497,10 @@ def create_plot(self, mode='Pythia8', event_path=None, output=None, tag=None): self.run_name, '%s_pts.dat' % tag) for observable_name, data_path in [('djr',djr_path), ('pt',pt_path)]: + # Pythia8's main164 writes the DJRs only (no pts.dat): + # an observable without data is skipped, not the plots + if not os.path.isfile(data_path): + continue try: if not self.generate_Pythia8_HwU_plots( PY8_plots_root_path, merging_scale_name, diff --git a/madgraph/interface/madevent_interface.py b/madgraph/interface/madevent_interface.py index cbd5867d6a..3d119549aa 100755 --- a/madgraph/interface/madevent_interface.py +++ b/madgraph/interface/madevent_interface.py @@ -4499,26 +4499,51 @@ def get_pythia8_main164(self, PY8_Card): else: hepmc_version = self.get_auto_hepmc_version() + # The jet rates of an MLM shower (Main:InternalAnalysis) split per jet + # sample (InternalAnalysis:jetSamples), for the DJR plots: a main164 + # that cannot do it is patched (misc.patch_pythia8_main164_djrs). + jet_sample_djrs = bool(PY8_Card['Main:InternalAnalysis']) and \ + bool(PY8_Card['InternalAnalysis:jetSamples']) + executable = None + if jet_sample_djrs: + try: + executable = self._get_pythia8_main164(hepmc_format, + hepmc_version, True) + except self.InvalidCmd as error: + logger.warning('main164 could not be built to split the jet rates ' + + 'per jet sample, the DJR plots show their sum only:\n%s' % error) + if executable is None: + executable = self._get_pythia8_main164(hepmc_format, hepmc_version, False) + # a main164 that does not know the setting would refuse it + if not misc.pythia8_main164_knows_jet_samples(executable): + PY8_Card.vetoParamWriteOut('InternalAnalysis:jetSamples') + return executable + + def _get_pythia8_main164(self, hepmc_format, hepmc_version, jet_sample_djrs): + """get_pythia8_main164 for a given HepMC version, falling back to the + version Pythia8 was configured with under HEPMCoutput:format = auto.""" + try: - return self.get_pythia8_main164_for(hepmc_version) + return self.get_pythia8_main164_for(hepmc_version, jet_sample_djrs) except (MadGraph5Error, OSError) as error: if hepmc_format != 'auto': raise self.InvalidCmd(str(error)) logger.warning('%s\nPythia8 writes the HepMC version it was '% error + 'configured with instead.') try: - return self.get_pythia8_main164_for(None) + return self.get_pythia8_main164_for(None, jet_sample_djrs) except (MadGraph5Error, OSError) as error: raise self.InvalidCmd(str(error)) - def get_pythia8_main164_for(self, hepmc_version): + def get_pythia8_main164_for(self, hepmc_version, jet_sample_djrs=False): """main164 writing HepMC events (None: the version Pythia8 was configured with), see misc.get_pythia8_main164.""" return misc.get_pythia8_main164(self.options['pythia8_path'], fallback_dir=pjoin(self.me_dir, 'lib', 'PY8_main164'), hepmc_version=hepmc_version, - hepmc_paths=self.get_hepmc_paths(hepmc_version or 3)) + hepmc_paths=self.get_hepmc_paths(hepmc_version or 3), + jet_sample_djrs=jet_sample_djrs) def setup_Pythia8RunAndCard(self, PY8_Card, run_type): """ Setup the Pythia8 Run environment and card. In particular all the process and run specific parameters @@ -4662,6 +4687,16 @@ def setup_Pythia8RunAndCard(self, PY8_Card, run_type): logger.info("No user-defined value for Pythia8 parameter "+ "'JetMatching:nJetMax'. Setting it automatically to %d."%nJetMax) PY8_Card.MadGraphSet('JetMatching:nJetMax',nJetMax, force=True) + # the differential jet rates of the showered events, from which + # the DJR plots of the run are made (create_plot) + if 'main:internalanalysis' not in PY8_Card.user_set: + PY8_Card.MadGraphSet('Main:InternalAnalysis', True) + # and per jet sample (the 0/1/2-jet contributions) + if 'internalanalysis:jetsamples' not in PY8_Card.user_set: + PY8_Card.MadGraphSet('InternalAnalysis:jetSamples', True) + if PY8_Card['Main:InternalAnalysis'] and \ + 'internalanalysis:output' not in PY8_Card.user_set: + PY8_Card.MadGraphSet('InternalAnalysis:output', './djrs.dat') # cite the shower-kt MLM scheme only when it is actually enabled if common_run.citation is not None and \ PY8_Card['JetMatching:doShowerKt'.lower()]: @@ -4839,8 +4874,6 @@ def do_pythia8(self, line): 'pythia8_card_default.dat')) PY8_Card.read(pjoin(self.me_dir, 'Cards', 'pythia8_card.dat'), setter='user') - pythia_main = self.get_pythia8_main164(PY8_Card) - run_type = 'default' merged_run_types = ['MLM','CKKW'] if int(self.run_card['ickkw'])==1: @@ -4851,6 +4884,9 @@ def do_pythia8(self, line): # Edit the card and run environment according to the run specification HepMC_event_output = self.setup_Pythia8RunAndCard(PY8_Card, run_type) + # after the setup: an MLM shower writes the jet rates (InternalAnalysis), + # which selects a main164 that also splits them per jet sample + pythia_main = self.get_pythia8_main164(PY8_Card) if self.options['run_mode']==0 or (self.options['run_mode']==2 and self.options['nb_core']==1): @@ -4999,6 +5035,23 @@ def do_pythia8(self, line): raise self.InvalidCmd('Pythia8 shower interrupted with return'+\ ' code %d.\n'%ret_code+\ 'You can find more information in this log file:\n%s'%pythia_log) + # main164's differential jet rates (an MLM sample), in mb: to + # the _djrs.dat in pb that the DJR plots are made from + djr_file = pjoin(self.me_dir,'Events',self.run_name,'djrs.dat') + if os.path.isfile(djr_file): + try: + djr_HwU = histograms.HwUList.from_pythia8_main164( + djr_file, scale=1e9) + except Exception as error: + logger.warning('Could not read the DJR histograms in %s: %s', + djr_file, error) + djr_HwU = None + if djr_HwU is not None: + djr_HwU.output(pjoin(self.me_dir,'Events',self.run_name,'djrs'), + format='HwU') + shutil.move(pjoin(self.me_dir,'Events',self.run_name,'djrs.HwU'), + pjoin(self.me_dir,'Events',self.run_name,'%s_djrs.dat'%tag)) + os.remove(djr_file) else: if self.run_card['event_norm']=='sum': logger.error("") @@ -5200,10 +5253,29 @@ def wait_monitoring(Idle, Running, Done): # djr plots djr_HwU = None n_added = 0 - for split_dir in split_dirs: + for i_split, split_dir in enumerate(split_dirs): djr_file = pjoin(split_dir,'djrs.dat') if not os.path.isfile(djr_file): continue + # Pythia8's main164 writes its own, simpler histfile (see + # HwUList.from_pythia8_main164): only the summed + # histograms, without the per-sample cross sections, in mb + # and normalised to the full cross section in each job + try: + new_djr_HwU = histograms.HwUList.from_pythia8_main164( + djr_file, scale=1e9 * partition_for_PY8[i_split] + / float(sum(partition_for_PY8))) + except Exception as error: + logger.warning('Could not read the DJR histograms in %s: %s', + djr_file, error) + continue + if new_djr_HwU is not None: + if djr_HwU is None: + djr_HwU = new_djr_HwU + else: + for i, hist in enumerate(djr_HwU): + djr_HwU[i] = hist + new_djr_HwU[i] + continue xsecs = self.extract_cross_sections_from_DJR(djr_file) if len(xsecs)>0: n_added += 1 diff --git a/madgraph/interface/madgraph_interface.py b/madgraph/interface/madgraph_interface.py index d93315f37c..a67e23d574 100755 --- a/madgraph/interface/madgraph_interface.py +++ b/madgraph/interface/madgraph_interface.py @@ -8146,6 +8146,14 @@ def install_pythia8_main164(self, prefix, config_file, add_options): logger.warning('Pythia8 is installed but its main164 could not be ' + 'compiled, so MadEvent cannot shower with it yet:\n%s' % error) return + # the main164 an MLM shower uses to split its jet rates per jet sample, + # patched if needed: built now rather than at the first MLM shower + try: + misc.get_pythia8_main164(pythia8_path, jet_sample_djrs=True) + except (MadGraph5Error, OSError) as error: + logger.info('main164 could not be built to split the jet rates per ' + + 'jet sample (the DJR plots of MLM showers will show their sum ' + + 'only):\n%s' % error) # the HepMC3 of a Pythia8 built against it (pythia8_hepmc3) if not self.options['hepmc3_path'] and misc.find_hepmc(3, [pjoin(prefix, 'hepmc3')]): self.options['hepmc3_path'] = pjoin(prefix, 'hepmc3') diff --git a/madgraph/iolibs/export_cpp.py b/madgraph/iolibs/export_cpp.py index 3d9d3df423..debabcae47 100755 --- a/madgraph/iolibs/export_cpp.py +++ b/madgraph/iolibs/export_cpp.py @@ -3645,11 +3645,34 @@ def create_proc_characteristics(self, matrix_elements): # Never let this break the output: on any surprise just fall back to # -1 (systematics then simply cannot reconstruct the reweighting). try: - for diagram in me.get('diagrams'): - qcd_orders.add(diagram.calculate_orders().get('QCD', 0)) + me_qcd_orders = [diagram.calculate_orders().get('QCD', 0) + for diagram in me.get('diagrams')] + qcd_orders.update(me_qcd_orders) except Exception as error: logger.debug('could not determine the QCD order: %s', error) qcd_orders.add(None) + me_qcd_orders = [] + # the number of jets an MLM matching has to match in the shower + # (Pythia8's JetMatching:nJetMax), as export_v4.write_auto_dsig_file + # counts it: light coloured final-state partons, at most the QCD + # order of the amplitude + try: + model = me.get('processes')[0].get('model') + + def is_light_parton(pid): + particle = model.get_particle(pid) + return (particle is not None + and particle.get('mass') == 'ZERO' + and particle.get('color') > 1) + + n_partons = max( + len([1 for pid in proc.get_final_ids() if is_light_parton(pid)]) + for proc in me.get('processes')) + pc['max_n_matched_jets'] = max( + pc['max_n_matched_jets'], + min(max(me_qcd_orders, default=0), n_partons)) + except Exception as error: + logger.debug('could not count the matched jets: %s', error) # a single value of alpha_s (uniform QCD power) lets systematics # reconstruct the LO reweighting info without an block diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index f11fa087aa..6c70fbdbeb 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -192,6 +192,20 @@ def me_parameters(run_card): return parameters +def mlm_pdf_ladder(run_card, mlm_clustering, is_decay=False, leptonic=False): + """Whether an MLM sample's beam densities walk the clustering ladder, the + pdf half of madevent's rewgt: under scale_scheme "madevent", and under + "clustering_mean" with clustering_mean_pdf_ladder. The Python side of + EnergyScale::mlm_pdf_reweighting, which decides the same for the kernel; + the systematics then vary the ladder as madevent's do.""" + beam = run_card["beam"] + return bool( + mlm_clustering and not is_decay and not leptonic + and beam["pdf_reweighting"] + and (beam["scale_scheme"] == "madevent" + or beam["clustering_mean_pdf_ladder"])) + + def interference_helicities_summed(run_card): """Whether [generation] interference_helicity is "summed". @@ -320,7 +334,20 @@ def load_cards(self) -> None: self.param_card = ParamCard(self.param_card_path) with open(os.path.join("SubProcesses", "subprocesses.json")) as f: self.subprocess_data = json.load(f) - if self.run_card["phasespace"]["merge_subprocesses"]: + merge_subprocesses = self.run_card["phasespace"]["merge_subprocesses"] + if merge_subprocesses and self.run_card["beam"]["dynamical_scale_choice"] == "mlm": + # A merged subprocess bundles matrix elements whose legs differ - + # q q~ > l l g next to q g > l l q - while the MLM clustering is + # built once per subprocess from a single assignment of flavours to + # legs. It would cluster the gluon-initiated elements as if their + # beam were a quark, and pick the history per diagram from the + # weights of one element only. + logger.warning( + "merge_subprocesses is not supported with dynamical_scale_choice " + "= mlm; the subprocesses are kept separate" + ) + merge_subprocesses = False + if merge_subprocesses: with open(os.path.join("SubProcesses", "merged_subprocesses.json")) as f: self.merged_subprocess_data = json.load(f) else: @@ -679,9 +706,57 @@ def parse_observable(self, name: str, order_observable: str) -> dict: name=name, ) + def apply_auto_ptj_mjj(self, cuts: dict) -> None: + """madevent's auto_ptj_mjj, from Template/LO/SubProcesses/setcuts.f. + + A merging cut already bounds every matrix-element jet from below, so + raising the jet pt and pair-mass cuts to xqcut removes no phase space + that survives the merging anyway while making the integration much more + efficient. The jet dR cuts go the other way and are dropped: + xqcut supersedes them, and a dR cut would carve a hole out of exactly + the region the parton shower is supposed to fill, leaving a gap in the + jet rates at the merging scale. + + With the option off, madevent still drops the dR cuts, and only zeroes + a pt or pair-mass cut that sits above xqcut - it never tightens. + """ + if self.is_decay or self.run_card["beam"]["dynamical_scale_choice"] != "mlm": + return + xqcut = self.run_card["phasespace"]["xqcut"] + if xqcut <= 0: + return + for key in ("jet-delta_r", "jet-lepton-delta_r"): + if cuts.get(key, {}).get("min", 0.0) > 0.0: + cuts[key]["min"] = 0.0 + auto = self.run_card["phasespace"]["auto_ptj_mjj"] + # Both halves of madevent's rule matter, and for different reasons. + # + # ptj reaches the phase space only for jets that are leaf children of + # the root: a jet inside a composite s-channel node - two jets off one + # gluon, say - has its pt_min reset to zero in PhaseSpaceMapping, and + # that node's invariant is then sampled from threshold upward knowing + # nothing about the cut. mjj is what bounds those nodes, because a + # pairwise invariant-mass minimum is carried into the propagator's own + # e_min and so into the s_min the sampler uses. + # + # Leaving mjj out is what makes the highest multiplicity misbehave at a + # tight xqcut: almost every point it proposes is vetoed, and the few + # survivors sit against a boundary it cannot see. + for key in ("jet-pt", "jet-pair_mass"): + if auto: + cuts.setdefault(key, {})["min"] = xqcut + elif cuts.get(key, {}).get("min", 0.0) > xqcut: + cuts[key]["min"] = 0.0 + def init_cuts(self) -> None: inf = float("inf") order_observable = self.run_card["cuts"].get("order_by", "pt") + cuts = { + key: dict(values) + for key, values in self.run_card["cuts"].items() + if key != "order_by" + } + self.apply_auto_ptj_mjj(cuts) self.cut_data = [ CutItem( observable_kwargs=self.parse_observable(key, order_observable), @@ -689,8 +764,7 @@ def init_cuts(self) -> None: max=values.get("max", inf), mode=values.get("mode", "all"), ) - for key, values in self.run_card["cuts"].items() - if key != "order_by" + for key, values in cuts.items() ] # [histograms] key that means "the distribution of the event weight", @@ -819,26 +893,54 @@ def init_beam(self) -> None: "half_transverse_mass": ms.EnergyScale.half_transverse_mass, "partonic_energy": ms.EnergyScale.partonic_energy, } - if beam_args["dynamical_scale_choice"] in dynamical_scales: - dynamical_scale_type = dynamical_scales[beam_args["dynamical_scale_choice"]] + # "mlm" is not one of the closed-form scale choices: it needs the diagram + # topologies of each subprocess, so the EnergyScale is built later, in + # MadgraphSubprocess.init_madspace, from an MLMClustering instance. + if beam_args["dynamical_scale_choice"] == "mlm": + self.mlm_clustering = True + self.scale_kwargs = {} + elif beam_args["dynamical_scale_choice"] in dynamical_scales: + self.mlm_clustering = False + self.scale_kwargs = dict( + dynamical_scale_type=dynamical_scales[ + beam_args["dynamical_scale_choice"] + ], + ren_scale_fixed=beam_args["fixed_ren_scale"], + fact_scale_fixed=beam_args["fixed_fact_scale"], + ren_scale=beam_args["ren_scale"], + fact_scale1=beam_args["fact_scale1"], + fact_scale2=beam_args["fact_scale2"], + scale_factor=float(beam_args.get("scale_factor", 1.)), + ) else: raise ValueError("Unknown dynamical scale choice") - self.scale_kwargs = dict( - dynamical_scale_type=dynamical_scale_type, - ren_scale_fixed=beam_args["fixed_ren_scale"], - fact_scale_fixed=beam_args["fixed_fact_scale"], - ren_scale=beam_args["ren_scale"], - fact_scale1=beam_args["fact_scale1"], - fact_scale2=beam_args["fact_scale2"], - scale_factor=float(beam_args.get("scale_factor", 1.)), - ) if self.is_decay and self.custom_functions.pop("scale", None) is not None: logger.warning("custom scale ignored: a decay uses the decaying mass") if self.is_decay and self.custom_functions.pop("pdf", None) is not None: logger.warning("custom pdf ignored: a decay has no parton densities") + if self.mlm_clustering and not self.is_decay: + # The merging needs the scales of its own clustering history: a + # custom scale or a factor on it would move the merging scale away + # from the one the shower is matched to. + if self.custom_functions.pop("scale", None) is not None: + logger.warning( + "custom scale ignored under dynamical_scale_choice = mlm") + if float(beam_args.get("scale_factor", 1.)) != 1.: + logger.warning( + "scale_factor ignored under dynamical_scale_choice = mlm") if self.is_decay: # One scale is available for a decay -- the decaying mass -- so use - # it, fixed, whatever the card asks for. + # it, fixed, whatever the card asks for. That includes "mlm": a + # decay has no beams to cluster into, so there is no merging to do. + if self.mlm_clustering: + logger.warning( + "dynamical_scale_choice = mlm has no meaning for a decay; " + "using the decaying mass as a fixed scale instead" + ) + self.mlm_clustering = False + self.scale_kwargs.setdefault( + "dynamical_scale_type", ms.EnergyScale.half_transverse_mass + ) self.scale_kwargs.update( ren_scale_fixed=True, fact_scale_fixed=True, @@ -888,6 +990,17 @@ def init_beam(self) -> None: self.pdf_grid = ms.PdfGrid( os.path.join(self.pdf_dir, pdf_set, f"{pdf_set}_0000.dat") ) + # The grid only covers a band in Q. Asking for a density above its + # highest Q reads past the end of the interpolation and comes back as a + # NaN, so the scale is capped there. A dynamical scale rarely gets near + # it, but the madevent MLM scheme, which multiplies four clustering + # scales together under a fourth root, can. A custom PDF brings no grid + # to read the range from, so it is left uncapped. + grids = [grid for grid in (self.pdf_grid, self.pdf_grid2) + if isinstance(grid, ms.PdfGrid)] + self.max_scale = min(max(grid.q) for grid in grids) if grids else 0.0 + # the bottom of the same range, where the densities freeze + self.min_pdf_scale = max(min(grid.q) for grid in grids) if grids else 0.0 for context in self.contexts: if not isinstance(self.pdf_grid, ms.CustomPdf): self.pdf_grid.initialize_globals(context) @@ -1058,6 +1171,21 @@ def build_systematics(self): config = ms.SystematicsConfig() config.mur = [float(v) for v in syst["mur"]] config.muf = [float(v) for v in syst["muf"]] + # the emission scale of the MLM alpha_s vertices, which only an MLM + # sample with per-vertex alpha_s has + if (self.mlm_clustering and not self.is_decay + and self.run_card["beam"]["alphas_reweighting"] != "none"): + config.alps = [float(v) for v in syst["alps"]] + else: + config.alps = [1.0] + # the pdf reweighting ladder of an MLM sample, varied as madevent's + # systematics vary it; a varied scale is kept inside the PDF grid, + # where LHAPDF freezes it for madevent + config.pdf_ladder = mlm_pdf_ladder( + self.run_card, self.mlm_clustering, self.is_decay, self.leptonic) + if config.pdf_ladder: + config.ladder_min_scale = float(self.min_pdf_scale) + config.ladder_max_scale = float(self.max_scale) config.together = bool(syst["together"]) config.dyn_scales = self.resolve_dynamical_scales() # alternative dynamical scales get the same factor as the nominal one @@ -1273,7 +1401,8 @@ def format_variation(up, down): # values line up across rows, with extra spacing between the two return f"{'+%.3g' % up:>6}% {'-%.3g' % down:>6}%" - scale_count = sum(1 for v in self.systematics.variations if v.is_scale) + scale_count = sum(1 for v in self.systematics.variations + if v.is_scale or v.is_emission) member_count = len(self.systematics.members) rows = [("Variations per event:", f"{self.systematics.weight_count} ({scale_count} scale, {member_count} PDF members)")] @@ -1283,6 +1412,9 @@ def format_variation(up, down): scale = systematics_summary.scale_percentages(summary) if scale is not None: rows.append(("Scale variation:", format_variation(*scale))) + emission = systematics_summary.emission_percentages(summary) + if emission is not None: + rows.append(("Emission scale variation:", format_variation(*emission))) for _entry, up, down in systematics_summary.pdf_percentages(summary): rows.append(("PDF variation:", format_variation(up, down))) if self.event_generator_config.verbosity == ms.Verbosity.pretty: @@ -2795,13 +2927,79 @@ def cut_pids(observable_kwargs): else None ) - self.scale = ms.EnergyScale( - particle_count=self.particle_count, - custom_scale=self.process.custom_function( - "scale", self.particle_count, representative_pids - ), - **self.process.scale_kwargs - ) + # The scale range exists to keep the densities inside the PDF grid, so + # a run without beam densities - a lepton collider or a decay - has + # none: a floor there would only throw away events whose scale is + # legitimately low, a decay of a particle lighter than min_scale say. + if self.process.leptonic: + min_scale = max_scale = 0.0 + else: + min_scale = self.process.run_card["beam"]["min_scale"] + max_scale = self.process.max_scale + if self.process.mlm_clustering: + mc_data = self.build_multi_channel_data() + all_pids = (clean_pids(self.meta["incoming"]) + + clean_pids(self.meta["outgoing"])) + self.scale = ms.EnergyScale( + ms.MLMClustering( + topologies=[topo[0] for topo in mc_data.topologies], + permutations=mc_data.permutations, + diagram_indices=mc_data.diagram_indices, + cm_energy=self.process.e_cm, + jet_scale_scheme=getattr( + ms.MLMClustering.JetScaleScheme, + self.process.run_card["beam"]["jet_scale_scheme"], + ), + scale_scheme=getattr( + ms.MLMClustering.ScaleScheme, + self.process.run_card["beam"]["scale_scheme"], + ), + pdg_color_types={ + int(key): value + for key, value in self.meta["pdg_color_types"].items() + }, + xqcut=self.process.run_card["phasespace"]["xqcut"], + bw_cutoff=self.process.run_card["phasespace"]["bw_cutoff"], + jet_radius=self.process.run_card["beam"]["jet_radius"], + hadronic=not self.process.leptonic, + external_pdg_ids=all_pids, + max_jet_flavor=self.process.run_card["beam"]["max_jet_flavor"], + parton_line_scheme=getattr( + ms.MLMClustering.PartonLineScheme, + self.process.run_card["beam"]["parton_line_scheme"], + ), + alphas_scheme=getattr( + ms.MLMClustering.AlphasScheme, + self.process.run_card["beam"]["alphas_reweighting"], + ), + pdf_reweighting=self.process.run_card["beam"][ + "pdf_reweighting" + ], + clustering_measure=getattr( + ms.MLMClustering.ClusteringMeasure, + self.process.run_card["beam"]["clustering_measure"], + ), + clustering_history=getattr( + ms.MLMClustering.ClusteringHistory, + self.process.run_card["beam"]["clustering_history"], + ), + clustering_mean_pdf_ladder=self.process.run_card["beam"][ + "clustering_mean_pdf_ladder" + ], + ), + min_scale=min_scale, + max_scale=max_scale, + ) + else: + self.scale = ms.EnergyScale( + particle_count=self.particle_count, + custom_scale=self.process.custom_function( + "scale", self.particle_count, representative_pids + ), + min_scale=min_scale, + max_scale=max_scale, + **self.process.scale_kwargs, + ) if self.process.run_card["run"]["dummy_matrix_element"]: self.matrix_elements = [None] * len(all_api_paths) @@ -3528,6 +3726,11 @@ def build_integrands( partial_weights = self.process.systematics_enabled() madnis_args = self.process.run_card["madnis"] custom_bias = self.process.custom_function("bias", self.particle_count, flavors) + # With an MLM history picked per diagram, the cross section at the + # scales of that history is the first matrix-element evaluation rescaled + # by alpha_s and PDF ratios when every diagram has the same power of + # alpha_s (-1: mixed powers, evaluate the matrix element again) + mlm_history_qcd_power = int(self.meta.get("qcd_power", -1)) integrands = [] for channel in phasespace.channels: integrands.append(ms.Integrand( @@ -3561,6 +3764,7 @@ def build_integrands( madnis_args["compressed_channel_weight_count"], custom_bias, None if self.process.leptonic else self.process.pdf_grid2, + mlm_history_qcd_power, )) #print(integrands[1].function()) #for i in integrands: print(i.function()) diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index 90f36f9b90..42bcc709af 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -66,10 +66,19 @@ fixed_fact_scale = %(beam.fixed_fact_scale)s ren_scale = %(beam.ren_scale)s fact_scale1 = %(beam.fact_scale1)s fact_scale2 = %(beam.fact_scale2)s -# options: transverse_energy, transverse_mass, half_transverse_mass, partonic_energy +# options: transverse_energy, transverse_mass, half_transverse_mass, partonic_energy, mlm dynamical_scale_choice = %(beam.dynamical_scale_choice)s # multiplies the dynamical scale; fixed scales are unaffected scale_factor = %(beam.scale_factor)s +# heaviest quark flavour treated as a jet by the MLM back-clustering +max_jet_flavor = %(beam.max_jet_flavor)s +# the other MLM clustering options (scale_scheme, alphas_reweighting, +# pdf_reweighting, clustering_history, ...) are hidden; one that is set (at the +# launch prompt, or by adding its line to this section) is kept in this card +# floor on mu_R and mu_F, since a PDF grid has no densities below it: the +# scales are raised to it, and under dynamical_scale_choice = "mlm" the event +# is dropped instead, as madevent does. Ignored without beam PDFs (0 disables it) +min_scale = %(beam.min_scale)s [generation] events = %(generation.events)s @@ -89,6 +98,7 @@ interference_helicity = %(generation.interference_helicity)s # options: exact, s enable = %(systematics.enable)s mur = %(systematics.mur)s # renormalization scale factors muf = %(systematics.muf)s # factorization scale factors +alps = %(systematics.alps)s # MLM emission scale factors (dynamical_scale_choice = "mlm" only) together = %(systematics.together)s # true: all mur x muf combinations, false: one at a time # alternative dynamical scale choices, as for [beam] dynamical_scale_choice dynamical_scale = %(systematics.dynamical_scale)s @@ -116,7 +126,7 @@ max_batch_size = %(vegas.max_batch_size)s [phasespace] mode = %(phasespace.mode)s # options: auto, multichannel, flat, both -merge_subprocesses = %(phasespace.merge_subprocesses)s +merge_subprocesses = %(phasespace.merge_subprocesses)s # ignored under dynamical_scale_choice = "mlm" sde_strategy = %(phasespace.sde_strategy)s # options: diagrams, denominators t_channel = %(phasespace.t_channel)s # options: propagator, rambo, chili flat_mode = %(phasespace.flat_mode)s # options: propagator, rambo, chili @@ -125,6 +135,14 @@ drop_qcd_s_channel = %(phasespace.drop_qcd_s_channel)s # channel count above whi invariant_power = %(phasespace.invariant_power)s bw_cutoff = %(phasespace.bw_cutoff)s cut_decays = %(phasespace.cut_decays)s # apply the [cuts] to decay products of on-shell particles +# minimum kt separation required of a matrix-element jet emission, the +# counterpart of xqcut in the legacy run_card (0 disables it; only used +# with dynamical_scale_choice = "mlm") +xqcut = %(phasespace.xqcut)s +# tighten ptj and the jet-pair invariant mass to xqcut when it is set, so that +# the phase space is not sampled where the merging cut would throw it away. +# madevent's auto_ptj_mjj, from Template/LO/SubProcesses/setcuts.f +auto_ptj_mjj = %(phasespace.auto_ptj_mjj)s adaptive_symmetry_sampling = %(phasespace.adaptive_symmetry_sampling)s [custom_functions] diff --git a/madgraph/iolibs/template_files/mg7/systematics_summary.py b/madgraph/iolibs/template_files/mg7/systematics_summary.py index 4fa8526b35..948594d778 100644 --- a/madgraph/iolibs/template_files/mg7/systematics_summary.py +++ b/madgraph/iolibs/template_files/mg7/systematics_summary.py @@ -46,17 +46,18 @@ def nominal_cross_section(summary): return value or None -def scale_percentages(summary): +def scale_percentages(summary, group='scale'): """``(up, down)`` percent for the scale envelope, or None. Positive magnitudes: ``up`` is how far above the nominal the envelope - reaches, ``down`` how far below. + reaches, ``down`` how far below. ``group`` names the envelope in the + summary: 'scale', or 'emission' (see emission_percentages). """ nominal = nominal_cross_section(summary) if nominal is None: return None - band = summary.get('scale') + band = summary.get(group) if not band: return None low, high = band.get('min'), band.get('max') @@ -66,6 +67,13 @@ def scale_percentages(summary): (nominal - low) / abs(nominal) * 100.) +def emission_percentages(summary): + """``(up, down)`` percent for the MLM emission scale envelope (madevent's + alpsfact), or None, with the conventions of scale_percentages.""" + + return scale_percentages(summary, 'emission') + + def pdf_percentages(summary): """``[(entry, up, down), ...]``, one per PDF set that has an uncertainty. diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index b6cc892d63..eaa49f8703 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -2097,6 +2097,20 @@ def default_setup(self): # Visible parameters # ================== self.add_param("Main:numberOfEvents", 0) + # main164's own analysis: the differential jet rates log10 d_{n,n+1} + # of the showered events, written to InternalAnalysis:output (the + # djrs.dat the DJR plots are made from). Switched on by MadGraph7 for + # an MLM sample. + self.add_param("Main:InternalAnalysis", False, hidden=True, + always_write_to_card=False) + self.add_param("InternalAnalysis:output", './djrs.dat', hidden=True, + always_write_to_card=False) + # main164's setting to also write those jet rates per jet sample + # (number of matrix-element partons), on for an MLM sample. When the + # installed main164 does not know it, MadGraph7 builds a patched copy + # (misc.patch_pythia8_main164_djrs); off, the plots show their sum. + self.add_param("InternalAnalysis:jetSamples", False, hidden=True, + always_write_to_card=False) # for MLM merging # -1.0 means that it will be set automatically by MadGraph7 self.add_param("JetMatching:qCut", -1.0, always_write_to_card=False) @@ -2568,9 +2582,6 @@ def group_params(params): # special case for HepMC needs two flags if 'HepMC:output' == param_entry: output.write(' %s=%s\n'%('Main:HepMC', 'on')) - if 'Main:InternalAnalysis'.lower() in self.user_set and \ - self['Main:InternalAnalysis'].lower() == 'on': - output.write('InternalAnalysis:output = ./djrs.dat\n') output.write(template%(param_entry, value_entry.replace(value,new_value))) @@ -4467,6 +4478,38 @@ def get_model_flavour_scheme(proc_def): return None +def mlm_matching_needed(proc_def): + """Whether the generated processes call for MLM merging: they come in more + than one multiplicity, and the highest one only adds jets (light quarks, + b quarks, gluons) to the lowest one. madevent then turns on ickkw = 1 with + xqcut = 30, MadGraph7 its "mlm" scale choice with the same xqcut.""" + try: + multiplicities = [len(proc[0]['legs']) for proc in proc_def] + except (TypeError, KeyError, IndexError): + return False + if not multiplicities: + return False + min_particle, max_particle = min(multiplicities), max(multiplicities) + if min_particle == max_particle: + return False + # take one of the processes with min_particle + for procmin in proc_def: + if len(procmin[0]['legs']) == min_particle: + idsmin = [l['id'] for l in procmin[0]['legs']] + break + for procmax in proc_def: + if len(procmax[0]['legs']) != max_particle: + continue + idsmax = [l['id'] for l in procmax[0]['legs']] + for i in idsmin: + if i in idsmax: + idsmax.remove(i) + if all(j in [1,-1,2,-2,3,-3,4,-4,5,-5,21,81,-81] for j in idsmax): + # all are jets => matching is ON + return True + return False + + class RunCardLO(RunCard): """an object to handle in a nice way the run_card information""" @@ -4582,7 +4625,8 @@ def default_setup(self): #matching self.add_param("scalefact", 1.0) self.add_param("ickkw", 0, allowed=[0,1], hidden=True, comment="\'0\' for standard fixed order computation.\n\'1\' for MLM merging activates alphas and pdf re-weighting according to a kt clustering of the QCD radiation.") - self.add_param("highestmult", 1, fortran_name="nhmult", hidden=True) + self.add_param("highestmult", 1, fortran_name="nhmult", + hidden=True) self.add_param("ktscheme", 1, hidden=True) self.add_param("alpsfact", 1.0, hidden=True) self.add_param("chcluster", False, hidden=True) @@ -5263,48 +5307,17 @@ def create_default_for_process(self, proc_characteristic, history, proc_def): # Check if need matching - min_particle = 99 - max_particle = 0 - for proc in proc_def: - min_particle = min(len(proc[0]['legs']), min_particle) - max_particle = max(len(proc[0]['legs']), max_particle) - if min_particle != max_particle: - #take one of the process with min_particle - for procmin in proc_def: - if len(procmin[0]['legs']) != min_particle: - continue - else: - idsmin = [l['id'] for l in procmin[0]['legs']] - break - matching = False - for procmax in proc_def: - if len(procmax[0]['legs']) != max_particle: - continue - idsmax = [l['id'] for l in procmax[0]['legs']] - for i in idsmin: - if i not in idsmax: - continue - else: - idsmax.remove(i) - for j in idsmax: - if j not in [1,-1,2,-2,3,-3,4,-4,5,-5,21,81,-81]: - break - else: - # all are jet => matching is ON - matching=True - break - - if matching: - self['ickkw'] = 1 - self['xqcut'] = 30 - #self['use_syst'] = False - self['drjj'] = 0 - self['drjl'] = 0 - self['sys_alpsfact'] = "0.5 1 2" - self['systematics_arguments'].append('--alps=0.5,1,2') - self.display_block.append('mlm') - self.display_block.append('ckkw') - self['dynamical_scale_choice'] = -1 + if mlm_matching_needed(proc_def): + self['ickkw'] = 1 + self['xqcut'] = 30 + #self['use_syst'] = False + self['drjj'] = 0 + self['drjl'] = 0 + self['sys_alpsfact'] = "0.5 1 2" + self['systematics_arguments'].append('--alps=0.5,1,2') + self.display_block.append('mlm') + self.display_block.append('ckkw') + self['dynamical_scale_choice'] = -1 # For interference module, the systematics are wrong. @@ -6714,6 +6727,9 @@ def __init__(self, *args, **opts): self.section_of = {} # set of internal keys (section.key) relevant during gridpack execution self.gridpack_params = set() + # declared default of each fixed parameter: a hidden one (no line in + # the template) is only written out once it differs from it + self.toml_defaults = {} # free-form sections kept as nested dicts self.dynamic_sections = collections.OrderedDict() # unknown sections preserved for round-trip @@ -6746,6 +6762,7 @@ def add_toml_param(self, section, key, value, gridpack=False, auto=False, **opts # not a fortran parameter: never goes to an include file opts.setdefault('include', False) self.add_param(internal, value, **opts) + self.toml_defaults[internal] = copy.deepcopy(value) self.section_of[internal] = section self.toml_sections.setdefault(section, []) if key not in self.toml_sections[section]: @@ -6882,8 +6899,136 @@ def default_setup(self): comment="multiplies the dynamical scale; fixed scales are unaffected") self.add_toml_param('beam', 'dynamical_scale_choice', "half_transverse_mass", allowed=['transverse_energy', 'transverse_mass', - 'half_transverse_mass', 'partonic_energy'], + 'half_transverse_mass', 'partonic_energy', 'mlm'], comment="dynamical scale used unless the scale is fixed") + # Jet radius entering the kt clustering measure of the MLM back-clustering + # (only used when dynamical_scale_choice = "mlm"). 1.0 is madevent's + # hidden run_card d (common/to_dj/D) and FxFx's value, and Pythia's + # JetMatching:coneRadius default. + self.add_toml_param('beam', 'jet_radius', 1.0, + comment="jet radius of the MLM clustering measure (dynamical_scale_choice = mlm)", + hidden=True) + # Heaviest quark flavour treated as a jet by the MLM back-clustering + # (the counterpart of maxjetflavor in the legacy run_card). + self.add_toml_param('beam', 'max_jet_flavor', 4, + comment="heaviest quark flavour the MLM back-clustering treats as a jet") + # How the MLM back-clustering assigns a clustering scale to each + # outgoing jet (written to the LHE as pt_clust_i, and used by Pythia as + # the shower starting scale for that parton). + # "production": the hardest vertex on the parton line the leg belongs + # to, i.e. the scale at which that line was produced. + # This is what madevent does. + # "emission": the vertex at which the leg itself was emitted, i.e. + # the softest clustering it takes part in. + self.add_toml_param('beam', 'jet_scale_scheme', "production", + allowed=['production', 'emission'], + comment="MLM clustering scale of each outgoing jet: production or emission", + hidden=True) + # How mu_R and mu_F are read off the clustering history: + # "clustering_mean": geometric mean of every clustering scale, with a + # single factorisation scale. + # "madevent": follow each beam's parton line and use + # (s_jlast1 s_jcentral1 s_jlast2 s_jcentral2)^(1/4), + # with a separate factorisation scale per beam. + # Left at "clustering_mean" by default because it moves the cross + # section, unlike jet_scale_scheme. + self.add_toml_param('beam', 'scale_scheme', "clustering_mean", + allowed=['clustering_mean', 'madevent'], + comment="how mu_R and mu_F are read off the MLM clustering history", + hidden=True) + # How a beam's parton line is decided to carry on past a vertex (only + # read when scale_scheme = "madevent"): + # "goodjet": a line stays a parton line only while every clustering + # it has been through was a jet vertex, so one non-jet + # vertex in its history stops it for good. What madevent + # does. + # "flavor": decided from the flavour of the emitted object alone, + # which keeps lines alive past vertices where madevent + # would have stopped them. + self.add_toml_param('beam', 'parton_line_scheme', "goodjet", + allowed=['goodjet', 'flavor'], + comment="when a beam's parton line carries on past an MLM clustering", + hidden=True) + # Evaluate alpha_s at the scale of each clustering vertex instead of + # once at the event scale, which is what madevent does for a merged + # sample (the rewgt loop in Template/LO/SubProcesses/reweight.f). A + # merged sample without it is short by roughly one factor of + # alphas(pt_emission)/alphas(mu_R) per extra jet, compounding with + # multiplicity. + # "per_vertex": alphas(pt_i) at each vertex, what madevent does + # "geometric_mean": one coupling at the geometric mean of the pt_i, + # raised to the number of vertices + # "none": a single coupling at mu_R for the whole event + # Only read when dynamical_scale_choice = "mlm". + self.add_toml_param('beam', 'alphas_reweighting', "per_vertex", + allowed=['per_vertex', 'geometric_mean', 'none'], + comment="where alpha_s is evaluated for an MLM merged event", + hidden=True) + # Re-evaluate the beam densities along the clustering ladder instead of + # once at the factorisation scale: the density is taken at the scale of + # the emission that pulled the parton out of the beam and walked back + # up, with the momentum fraction rescaled at each step. This is the + # pdf half of the same rewgt loop, madevent's hidden pdfwgt flag, which + # is on by default there. Only read when dynamical_scale_choice = "mlm", + # and under scale_scheme = "clustering_mean" only together with + # clustering_mean_pdf_ladder. + self.add_toml_param('beam', 'pdf_reweighting', True, + comment="re-evaluate the beam densities along the MLM clustering ladder", + hidden=True) + # The same ladder under scale_scheme = "clustering_mean": the beam + # densities are then evaluated as madevent evaluates them (per-beam + # factorisation scale, lowered to the scale that pulled the parton out + # of the beam and walked back up), while mu_R, the alpha_s vertex + # scales and the jet scales stay clustering_mean's. Without it + # clustering_mean takes both densities once at the softest clustering + # scale, which moves the merged rates just above qcut away from + # madevent's. Needs pdf_reweighting. + self.add_toml_param('beam', 'clustering_mean_pdf_ladder', True, + comment="also walk the pdf ladder under scale_scheme = clustering_mean", + hidden=True) + # Which measure scores a candidate final-state clustering. The two only + # differ for a non-resonant pair with a massless mother and one massive + # and one massless daughter (q* > q W, g* > g h), or a massive mother + # with two massless daughters: + # "fxfx": cluster_scale of Template/NLO/SubProcesses/cluster.f, + # sqrt(|p_j.(p_i+p_j)|)/2 and the invariant mass + # "madevent": DJ of Template/LO/Source/kin_functions.f, as madevent's + # LO clustering does: the massless one's transverse mass + # for a massless-massive pair, the kt measure otherwise + # Only read when dynamical_scale_choice = "mlm". + self.add_toml_param('beam', 'clustering_measure', "fxfx", + allowed=['fxfx', 'madevent'], + comment="measure scoring a candidate MLM final-state clustering", + hidden=True) + # Which diagrams the clustering history may follow: + # "all_diagrams": the smallest-measure history over every diagram at + # once, each line's flavour taken from the first + # diagram that has it. + # "diagram": pick one diagram per event with probability + # proportional to its |A_i|^2 and cluster along it + # alone, with its own line flavours. Tells a VBF + # diagram's W/Z lines from gluon fusion's gluons. + # "madevent": keep the all_diagrams history unless it calls a + # different number of legs merging jets than the + # picked diagram does, as setclscales reclusters + # along the integration channel. + # Anything but "all_diagrams" evaluates the matrix element twice per + # event: once for the diagram weights, once at the scales of the chosen + # history. Only read when dynamical_scale_choice = "mlm", which also + # turns phasespace.merge_subprocesses off: the clustering is built + # from one flavour assignment per subprocess. + self.add_toml_param('beam', 'clustering_history', "all_diagrams", + allowed=['all_diagrams', 'diagram', 'madevent'], + comment="which diagrams the MLM clustering history may follow", + hidden=True) + # Floor on mu_R and mu_F. Below the lowest Q of a PDF grid the + # densities are undefined, so the scales are raised to it, which is + # what LHAPDF's freezing does for madevent. Under + # dynamical_scale_choice = "mlm" an event whose scales fall under it is + # dropped instead, as madevent's setclscales does at 2 GeV. Ignored + # for a run without beam PDFs (lepton collider, decay). 0 disables it. + self.add_toml_param('beam', 'min_scale', 2.0, + comment="floor on mu_R and mu_F (0 disables it)") # -------------------------- [generation] ---------------------- self.add_toml_param('generation', 'events', 100000, gridpack=True, @@ -6921,6 +7066,11 @@ def default_setup(self): comment="renormalization scale factors") self.add_toml_param('systematics', 'muf', [0.5, 1.0, 2.0], typelist=float, gridpack=True, comment="factorization scale factors") + # madevent's sys_alpsfact, which it sets to "0.5 1 2" for a matched + # process: the alpha_s of every MLM clustering vertex at the factor + # times the vertex scale, in an "Emission scale variation" group + self.add_toml_param('systematics', 'alps', [0.5, 1.0, 2.0], typelist=float, gridpack=True, + comment="MLM emission scale factors (dynamical_scale_choice = mlm only)") self.add_toml_param('systematics', 'together', True, gridpack=True, comment="true: all mur x muf combinations; false: vary one scale at a time") self.add_toml_param('systematics', 'dynamical_scale', [], typelist=str, gridpack=True, @@ -6985,6 +7135,21 @@ def default_setup(self): comment="number of widths around the mass sampled with a Breit-Wigner") self.add_toml_param('phasespace', 'cut_decays', False, comment="apply the [cuts] to the decay products of on-shell particles") + # Generation-level merging cut, the counterpart of xqcut in the legacy + # run_card: a matrix-element jet emission below this scale is left to + # the parton shower, so the event is dropped. Only used with + # dynamical_scale_choice = "mlm"; 0 disables it. + self.add_toml_param('phasespace', 'xqcut', 0.0, + comment="MLM merging cut on the kt of matrix-element jets (0 disables it)") + # Counterpart of madevent's auto_ptj_mjj. With a merging cut in place + # the matrix-element jets are already bounded from below by xqcut, so + # the jet pt and pair-mass cuts are raised to it - free physically, and + # a large gain in integration efficiency - and the jet dR cuts are + # dropped, since xqcut supersedes them and a dR cut would otherwise + # carve a hole out of the region the shower is meant to fill. + # Only read when dynamical_scale_choice = "mlm" and xqcut > 0. + self.add_toml_param('phasespace', 'auto_ptj_mjj', True, + comment="raise the jet pt and pair-mass cuts to xqcut, drop the jet dR cuts") self.add_toml_param('phasespace', 'adaptive_symmetry_sampling', True, comment="sample symmetric channels adaptively") @@ -7175,8 +7340,9 @@ def __setitem__(self, name, value, *args, **opts): 'ievo_eva', 'evaorder', 'event_norm', 'dynamical_scale_choice', 'nevents', # keys read by the reused madevent post-processing tools (shower, ...). - # mg7 does no MLM/CKKW merging, so these all take their "no merging" - # value, which keeps the tools on the plain (unmatched) code path. + # mg7 does no CKKW merging, so those take their "no merging" value; an + # MLM run (dynamical_scale_choice = "mlm" with xqcut > 0) reports + # ickkw = 1 and its xqcut, so that the shower does the jet matching. 'ktdurham', 'ptlund', 'xqcut', 'maxjetflavor', 'sys_matchscale', 'dparameter', 'lhaid', 'iseed', 'python_seed', # read by MadSpin (check_launch, do_import) and by Banner.write for @@ -7198,6 +7364,16 @@ def __setitem__(self, name, value, *args, **opts): 'transverse_energy': 1, } + def is_mlm_merged(self): + """Whether the events are an MLM merged sample: clustered with + dynamical_scale_choice = "mlm" and vetoed at a merging cut xqcut > 0, + on hadronic beams. Such a sample needs MLM jet matching in the shower + (ickkw = 1 for the madevent tools) or it double counts.""" + beam = self['beam'] + return (beam['dynamical_scale_choice'] == 'mlm' + and not bool(beam['leptonic']) + and float(self['phasespace']['xqcut']) > 0) + def _legacy_compat(self, key): beam = self['beam'] leptonic = bool(beam['leptonic']) @@ -7225,7 +7401,9 @@ def _legacy_compat(self, key): return float(beam['scale_factor']) if key in ('mur_over_ref', 'muf_over_ref'): return 1.0 - if key in ('ickkw', 'ievo_eva', 'evaorder'): + if key == 'ickkw': + return 1 if self.is_mlm_merged() else 0 + if key in ('ievo_eva', 'evaorder'): return 0 if key == 'event_norm': return 'average' @@ -7233,15 +7411,23 @@ def _legacy_compat(self, key): if beam['fixed_ren_scale'] and beam['fixed_fact_scale']: return -1 # fixed scale: no dynamical-scale variation return self._dyn_scale_legacy.get(beam['dynamical_scale_choice'], -1) - # --- merging / matching: mg7 does none, keep the "off" values --- + # --- merging / matching: MLM through ickkw / xqcut, no CKKW --- if key in ('ktdurham', 'ptlund'): - return -1.0 # <=0 => no CKKW / no MLM shower matching - if key in ('xqcut', 'sys_matchscale'): + return -1.0 # <=0 => no CKKW-L merging + if key == 'xqcut': + if self.is_mlm_merged(): + return float(self['phasespace']['xqcut']) + return 0.0 + if key == 'sys_matchscale': return 0.0 if key == 'maxjetflavor': + # JetMatching:nQmatch: the heaviest quark the matching treats as a + # jet, which must be the one the clustering treated as a jet + if self.is_mlm_merged(): + return int(beam['max_jet_flavor']) return 5 if key == 'dparameter': - return 0.4 # only used by the (unused) matching machinery + return 0.4 # only used by the (unused) CKKW-L machinery # --- run / seed bookkeeping --- if key == 'lhaid': return self.get_lhapdf_id() @@ -7682,6 +7868,9 @@ def number(val): jet = self._lo_jet_group(flavours) self.dynamic_sections['multiparticles']['jet'] = jet changes['multiparticles.jet'] = jet + # the MLM clustering's jet flavours, which madevent's maxjetflavor + # also sets + put('beam.max_jet_flavor', flavours) else: # per-pdg cuts, written {pdg: value, ...} as in the run_card.dat @@ -8031,7 +8220,9 @@ def write(self, output_file, template=None, python_template=True, **opt): for key in keys: internal = '%s.%s' % (section, key) data[internal] = self.format_toml_value(self[internal]) + in_template = set(re.findall(r'%\(([\w.]+)\)s', text)) text = text % data + text = self.add_hidden_parameters(text, in_template) # free-form sections inserted at their markers for section in self.dynamic_section_names: @@ -8055,6 +8246,37 @@ def write(self, output_file, template=None, python_template=True, **opt): else: output_file.write(text) + def add_hidden_parameters(self, text, in_template): + """Add to the rendered card the hidden parameters (those with no line + in the template) that the user set or that no longer hold their + default, at the end of their section, so that they survive a rewrite + of the card.""" + missing = collections.OrderedDict() + for section, keys in self.toml_sections.items(): + for key in keys: + internal = '%s.%s' % (section, key) + if internal in in_template or internal not in self.hidden_param: + continue + if internal not in self.user_set and \ + self[internal] == self.toml_defaults.get(internal): + continue + line = '%s = %s' % (key, self.format_toml_value(self[internal])) + if self.comments.get(internal): + line += ' # %s' % self.comments[internal] + missing.setdefault(section, []).append(line) + for section, lines in missing.items(): + block = '\n'.join(lines) + '\n' + header = re.search(r'^\[%s\][ \t]*$' % re.escape(section), text, re.M) + if not header: + text = text.rstrip('\n') + '\n\n[%s]\n%s' % (section, block) + continue + # the section ends at the next table header, or at the end + following = re.search(r'^\[', text[header.end():], re.M) + end = header.end() + following.start() if following else len(text) + body = text[:end].rstrip('\n') + '\n' + text = body + block + ('\n' + text[end:] if following else '') + return text + def write_gridpack_card(self, output_file): """Write a minimal ``grid_run_card.toml`` containing only the parameters marked ``gridpack=True`` in :meth:`default_setup`, i.e. those actually @@ -8089,6 +8311,10 @@ def write_gridpack_card(self, output_file): # ------------------------------------------------------------------ # process dependent defaults (mirrors RunCardLO.create_default_for_process) # ------------------------------------------------------------------ + # merging cut set up for a process generated with several jet + # multiplicities, madevent's + default_mlm_xqcut = 30.0 + def create_default_for_process(self, proc_characteristic, history, proc_def): """Adjust default values depending on the process, then read the site/user default file (``input/default_run_card_mg7.toml``).""" @@ -8109,6 +8335,14 @@ def create_default_for_process(self, proc_characteristic, history, proc_def): hadronic = set([1, -1, 2, -2, 3, -3, 4, -4, 5, -5, 21, 22, 81, -81]) leptons = set([11, -11, 13, -13, 15, -15, 82, -82]) + if beam_id & hadronic: + # the heaviest quark the MLM clustering treats as a jet follows the + # flavour scheme of the model, as madevent's maxjetflavor does: 5 + # once the b is massless and in the proton + nflav = get_model_flavour_scheme(proc_def) or 4 + self['beam']['max_jet_flavor'] = max( + [nflav] + [abs(i) for i in beam_id if -7 < i < 7]) + if beam_id and beam_id <= leptons: # lepton collider: no PDF, lower default energy, no hadronic cuts self['beam']['leptonic'] = True @@ -8126,6 +8360,21 @@ def create_default_for_process(self, proc_characteristic, history, proc_def): if key not in self.user_set: self[key] = self.photon_pdf + # More than one multiplicity, the higher ones only adding jets: an MLM + # merged sample, as madevent sets it up (ickkw = 1, xqcut = 30, drjj = + # drjl = 0). The shower then does the jet matching (is_mlm_merged), and + # auto_ptj_mjj ties the jet cuts to xqcut. + if beam_id & hadronic and not self['beam']['leptonic'] and \ + mlm_matching_needed(proc_def): + self['beam']['dynamical_scale_choice'] = 'mlm' + self['phasespace']['xqcut'] = self.default_mlm_xqcut + cuts = self.dynamic_sections['cuts'] + for key in ('jet-delta_r', 'jet-lepton-delta_r'): + if isinstance(cuts.get(key), dict): + cuts[key].pop('min', None) + if not cuts[key]: + del cuts[key] + # 1 -> N decay: no cuts at all. A partial width is an inclusive # quantity, so any kinematic cut biases it low (the collider defaults # cost ~3% on t > b w+, w+ > e+ ve). The Breit-Wigner cutoff in @@ -8605,9 +8854,10 @@ def remove_jet_cuts(self): 'iseed', 'nhel', 'limhel', 'hel_recycling', 'hel_filtering', 'hel_splitamp', 'hel_zeroamp', # matching / merging - 'ickkw', 'xqcut', 'ktdurham', 'dparameter', 'ptlund', 'highestmult', + # (ickkw, xqcut and auto_ptj_mjj are translated: see MLM merging) + 'ktdurham', 'dparameter', 'ptlund', 'highestmult', 'ktscheme', 'alpsfact', 'chcluster', 'pdfwgt', 'asrwgtflavor', - 'clusinfo', 'auto_ptj_mjj', 'pdgs_for_merging_cut', + 'clusinfo', 'pdgs_for_merging_cut', # bias 'bias_module', 'bias_parameters', # unsupported cuts: needs the opposite-hemisphere condition on the two @@ -8725,6 +8975,31 @@ def nondefault(name): elif dyn not in (-1, 10) and nondefault('dynamical_scale_choice'): dropped.append('dynamical_scale_choice=%s (no mg7 equivalent)' % dyn) + # --- MLM merging --- + # madevent's ickkw = 1 back-clusters every event and vetoes it at + # xqcut; with its default dynamical_scale_choice -1 the scales are + # setclscales', which is mg7's "mlm" choice under scale_scheme + # "madevent". mg7 has no MLM with a closed-form scale choice: the + # clustering scales are used whatever dynamical_scale_choice said. + ickkw = int(lo['ickkw']) if 'ickkw' in lo else 0 + if ickkw == 1: + xqcut = float(lo['xqcut']) if 'xqcut' in lo else 0. + if xqcut > 0: + mg7.set('beam.dynamical_scale_choice', 'mlm') + mg7.set('beam.scale_scheme', 'madevent') + mg7.set('phasespace.xqcut', xqcut) + if 'auto_ptj_mjj' in lo: + mg7.set('phasespace.auto_ptj_mjj', bool(lo['auto_ptj_mjj'])) + if dyn != -1: + dropped.append('dynamical_scale_choice=%s with ickkw=1 ' + '(mg7 uses the MLM clustering scales)' % dyn) + else: + dropped.append('ickkw=1 with xqcut=%s (no merging cut: not ' + 'merged)' % xqcut) + elif nondefault('xqcut'): + dropped.append('xqcut=%s without ickkw=1 (no MLM merging)' + % lo['xqcut']) + # --- SDE strategy --- sde = lo['SDE_strategy'] if 'SDE_strategy' in lo else 1 mg7.set('phasespace.sde_strategy', @@ -8753,6 +9028,7 @@ def nondefault(name): if 'maxjetflavor' in lo: mg7.dynamic_sections['multiparticles']['jet'] = \ cls._lo_jet_group(int(lo['maxjetflavor'])) + mg7.set('beam.max_jet_flavor', int(lo['maxjetflavor'])) # --- cuts (rebuild from the LO card) --- cuts = collections.OrderedDict() diff --git a/madgraph/various/histograms.py b/madgraph/various/histograms.py index edd4fbc213..bad7f3c354 100755 --- a/madgraph/various/histograms.py +++ b/madgraph/various/histograms.py @@ -3200,6 +3200,73 @@ def get(self, name): raise NameError("no histogram with name: %s" % name) + @staticmethod + def from_pythia8_main164(file_path, scale=1.0): + """The differential jet rates written by Pythia8's main164 with + Main:InternalAnalysis = on: a holding, per run, the + histograms log10d01 ... log10d34 of the whole sample, one row per bin + with its lower edge and weighted content (and, from a main164 that + prints it, its uncertainty), and possibly the same per jet sample + (number of matrix-element partons) in blocks + (InternalAnalysis:jetSamples). Returns the histograms of the first run + as a HwUList: the per-sample + ones, titled |JETSAMPLE@n as the plots expect, when the file has them, + the whole-sample ones otherwise; None when it holds none, or when it is + the former MG5aMC_PY8_interface format (whose carries a weight + header), which parse_histos_from_PY8_XML_stream reads. + + main164 up to 8.317 writes '', which no XML parser takes, + so the file is read line by line. Every bin is multiplied by scale: + main164 fills in mb, each event weighted by sigma / (its own number of + events), so a job of a split run needs 1e9 n_job / n_total to give its + share in pb.""" + totals, samples = HwUList([]), HwUList([]) + current, sample, rows = None, None, [] + + def close(): + if current is None or len(rows) < 2: + return + width = rows[1][0] - rows[0][0] + new_histo = HwU() + new_histo.process_histogram_name( + current if sample is None else '%s |JETSAMPLE@%d' % (current, sample)) + new_histo.bins = BinList(weight_labels=['central', 'stat_error']) + for row in rows: + new_histo.bins.append(Bin((float(row[0]), float(row[0] + width)), + {'central': float(row[1]) * scale, + 'stat_error': float(row[2]) * abs(scale) if len(row) > 2 else 0.0})) + (totals if sample is None else samples).append(new_histo) + + with open(file_path) as stream: + for line in stream: + line = line.strip() + if line.startswith(': main164 with InternalAnalysis:jetSamples; + # : the first MadGraph7 patch of main164 + block = re.match(r'<(?:jetsample|jethistograms)\s+njet="(-?\d+)"', line) + if block: + sample = int(block.group(1)) + continue + if line.startswith('= 2: + rows.append([float(c) for c in columns[:3]]) + histos = samples if len(samples) else totals + return histos if len(histos) else None + def parse_histos_from_PY8_XML_stream(self, stream, run_id=None, merging_scale=None, accepted_types_order=[], consider_reweights='ALL', raw_labels=False): diff --git a/madgraph/various/misc.py b/madgraph/various/misc.py index 9ef94b8bb8..53f9cdc114 100755 --- a/madgraph/various/misc.py +++ b/madgraph/various/misc.py @@ -1875,8 +1875,132 @@ def get_pythia8_hepmc_flags(pythia8_path, hepmc_version, hepmc_paths=()): return '-I%s -L%s -Wl,-rpath,%s -l%s' % (pjoin(prefix, 'include'), libdir, libdir, 'HepMC3' if hepmc_version == 3 else 'HepMC') +# Version of the per-jet-sample DJR patch of main164 below. It is part of the +# build stamp, so that a change to the patch rebuilds the patched main164. +MAIN164_DJR_PATCH_VERSION = 2 + +def pythia8_main164_writes_jet_sample_djrs(source): + """Whether a main164.cc can histogram the differential jet rates per jet + sample by itself (its InternalAnalysis:jetSamples setting).""" + + return '"InternalAnalysis:jetSamples"' in source + +def pythia8_main164_knows_jet_samples(executable): + """Whether the main164 executable was built from a main164.cc that knows + InternalAnalysis:jetSamples (a Pythia8 setting it would refuse otherwise). + The source sits next to the executable, as get_pythia8_main164 builds it.""" + + try: + source = open(pjoin(os.path.dirname(executable), 'main164.cc')).read() + except (IOError, OSError): + return False + return pythia8_main164_writes_jet_sample_djrs(source) + +def patch_pythia8_main164_djrs(source): + """The main164.cc of Pythia8 with the change proposed to Pythia8: the + setting InternalAnalysis:jetSamples (default off) also writes the + differential jet rate histograms of its internal analysis per jet sample, + in blocks after the totals of each , and the + '' of Pythia8 <= 8.318 is fixed. The jet sample is the + number of matrix-element partons of MLM matching (nMEpartons().first, + getProcessSubset().size() for shower-kT, npNLO() for FxFx), or + Merging:nRequested less Merging:nRecluster for merging. + + Until a Pythia8 release has it, MadGraph7 builds this patched copy for + its MLM showers; Pythia8's own main164.cc is never modified. Returns None + when the source is not the main164.cc this was written against (Pythia8 + 8.316 to 8.318): the stock main164 is then used, and the DJR plots show + the summed histograms only.""" + + edits = [ + (' pythia.settings.addWord("InternalAnalysis:output", "analysis.dat");\n', + ' // Optionally also write the histograms of each jet sample separately.\n' + ' pythia.settings.addFlag("InternalAnalysis:jetSamples", false);\n'), + (" vector d01Hists, d12Hists, d23Hists, d34Hists;\n", + " // Optionally also for each jet sample, as known by merging or by\n" + " // MadGraph-style (not Alpgen-style) jet matching.\n" + ' bool doJetSamples = pythia.flag("InternalAnalysis:jetSamples");\n' + " shared_ptr jetMatchingPtr\n" + " = dynamic_pointer_cast(jetMatchingHook.hook);\n" + " vector > > jetSampleHists;\n"), + (' d34Hists.push_back(Hist("d34", 100., 0., 3.));\n', + " jetSampleHists.push_back(map >());\n"), + (" if (dij.size() > 3) d34Hists.back().fill(log10(dij[3]), w);\n", + " // Optionally also for the jet sample of the event: for jet matching\n" + " // the number of matrix-element partons (only those to be matched\n" + " // for shower-kT, those of the Born for FxFx), for merging the\n" + " // requested number of jets less the reclustered ones.\n" + " int nJetSample = -1;\n" + " if (doJetSamples && jetMatchingPtr) {\n" + ' if (pythia.flag("JetMatching:doFxFx"))\n' + " nJetSample = jetMatchingPtr->npNLO();\n" + ' else if (pythia.flag("JetMatching:doShowerKt"))\n' + " nJetSample = jetMatchingPtr->getProcessSubset().size();\n" + " else\n" + " nJetSample = jetMatchingPtr->nMEpartons().first;\n" + " } else if (doJetSamples && doMerging) {\n" + ' nJetSample = pythia.mode("Merging:nRequested")\n' + ' - pythia.mode("Merging:nRecluster");\n' + " }\n" + " if (nJetSample >= 0) {\n" + " vector& sampleHists = jetSampleHists.back()[nJetSample];\n" + " if (sampleHists.empty())\n" + ' sampleHists = { Hist("d01", 100., 0., 3.),\n' + ' Hist("d12", 100., 0., 3.), Hist("d23", 100., 0., 3.),\n' + ' Hist("d34", 100., 0., 3.) };\n' + " for (int iHist = 0; iHist < 4 && iHist < int(dij.size()); ++iHist)\n" + " sampleHists[iHist].fill(log10(dij[iHist]), w);\n" + " }\n"), + ] + for anchor, addition in edits: + if source.count(anchor) != 1: + return None + source = source.replace(anchor, anchor + addition) + + run_header = ' output << "\\n";\n' + run_end = ' output << "\\n";\n' + if source.count(run_header) != 1 or source.count(run_end) != 1: + return None + source = source.replace(run_header, + ' output << "\\n";\n') + source = source.replace(run_end, + " // Optionally print the histograms of each jet sample.\n" + " for (const auto& jetSample : jetSampleHists[i]) {\n" + ' output << "\\n";\n' + " for (const Hist& hist : jetSample.second) {\n" + ' output << "\\n";\n' + " hist.table(output, false, false);\n" + ' output << "\\n";\n' + " }\n" + ' output << "\\n";\n' + " }\n" + "\n" + run_end) + return ('// Patched by MadGraph7 (version %d of the per-jet-sample DJR patch,\n' + '// see patch_pythia8_main164_djrs in madgraph/various/misc.py).\n' + % MAIN164_DJR_PATCH_VERSION) + source + +def _pythia8_main164_djr_source(examples): + """The patched main164.cc for per-jet-sample DJRs, or None when it is not + needed (the installed main164 does it) or not possible (an unknown + main164.cc), see patch_pythia8_main164_djrs.""" + + try: + source = open(pjoin(examples, 'main164.cc')).read() + except (IOError, OSError): + return None + if pythia8_main164_writes_jet_sample_djrs(source): + return None + patched = patch_pythia8_main164_djrs(source) + if patched is None: + logger.info('The main164 of this Pythia8 version cannot be patched to ' + + 'histogram the jet rates per jet sample: the DJR plots show ' + + 'their sum only.') + return patched + def get_pythia8_main164(pythia8_path, fallback_dir=None, hepmc_version=None, - hepmc_paths=()): + hepmc_paths=(), jet_sample_djrs=False): """Return the path of a compiled main164, compiling it if needed. main164 is built in the Pythia8 examples directory with the Makefile that @@ -1889,7 +2013,13 @@ def get_pythia8_main164(pythia8_path, fallback_dir=None, hepmc_version=None, with, a dedicated main164 is built against the HepMC installation found in hepmc_paths or next to Pythia8: in /main164_hepmc, so that it is shared by all processes, or in _hepmc - if the examples directory is read-only.""" + if the examples directory is read-only. + + With jet_sample_djrs, main164 histograms the jet rates of an MLM shower + per jet sample too: when the installed main164.cc does not, a patched copy + (patch_pythia8_main164_djrs) is built in the same way, in a directory + named with _djr appended. When the patch does not apply, the stock main164 + is returned.""" executable, examples = find_pythia8_main164(pythia8_path) if not examples: @@ -1903,6 +2033,9 @@ def get_pythia8_main164(pythia8_path, fallback_dir=None, hepmc_version=None, '%s. MadGraph7 showers LO events with main164, an example program of ' % pythia8_path + "Pythia8 that exists since Pythia 8.311 (it can be installed with 'install pythia8').") + source = _pythia8_main164_djr_source(examples) if jet_sample_djrs else None + + hepmc = None if hepmc_version and hepmc_version != pythia8_hepmc_version(examples): if not os.path.isfile(pjoin(examples, 'main164.cc')): raise MadGraph5Error('main164.cc cannot be found in %s, so that ' % examples + @@ -1915,9 +2048,14 @@ def get_pythia8_main164(pythia8_path, fallback_dir=None, hepmc_version=None, 'no HepMC%d installation was found (it can be installed with ' % hepmc_version + "'install %s'), and Pythia8 was configured with another version." % default) hepmc = (hepmc_version,) + hepmc + + if hepmc or source: + suffix = ('_hepmc%d' % hepmc[0] if hepmc else '') + ('_djr' if source else '') stamp = _pythia8_main164_stamp(pythia8_path, examples, hepmc) - shared_dir = pjoin(examples, 'main164_hepmc%d' % hepmc_version) - local_dir = '%s_hepmc%d' % (fallback_dir, hepmc_version) if fallback_dir else None + if source: + stamp += '\ndjr patch %d' % MAIN164_DJR_PATCH_VERSION + shared_dir = pjoin(examples, 'main164' + suffix) + local_dir = fallback_dir + suffix if fallback_dir else None for build_dir in (shared_dir, local_dir): if build_dir and _pythia8_main164_is_built(build_dir, stamp): return pjoin(build_dir, 'main164') @@ -1927,9 +2065,10 @@ def get_pythia8_main164(pythia8_path, fallback_dir=None, hepmc_version=None, build_dir = local_dir else: raise MadGraph5Error('No writable directory to compile main164 ' + - 'for HepMC%d.' % hepmc_version) + (('for HepMC%d.' % hepmc[0]) if hepmc else 'with per-jet-sample jet rates.')) return _compile_pythia8_main164(examples, build_dir, stamp, - _pythia8_main164_makefile_inc(examples, hepmc)) + _pythia8_main164_makefile_inc(examples, hepmc), + source=source) if executable: return executable @@ -2027,10 +2166,12 @@ def _pythia8_main164_is_built(build_dir, stamp): return os.path.isfile(pjoin(build_dir, 'main164')) and \ os.path.isfile(stamp_file) and open(stamp_file).read() == stamp -def _compile_pythia8_main164(examples, build_dir, stamp=None, makefile_inc=None): +def _compile_pythia8_main164(examples, build_dir, stamp=None, makefile_inc=None, + source=None): """Compile main164 in build_dir and return its path. If build_dir is not the - Pythia8 examples directory, main164.cc and the Makefile are copied there, - with makefile_inc as Makefile.inc, and the build is stamped.""" + Pythia8 examples directory, main164.cc (or the given source in its place) + and the Makefile are copied there, with makefile_inc as Makefile.inc, and + the build is stamped.""" executable = pjoin(build_dir, 'main164') stamp_file = pjoin(build_dir, 'BUILD_STAMP') @@ -2042,6 +2183,9 @@ def _compile_pythia8_main164(examples, build_dir, stamp=None, makefile_inc=None) os.makedirs(build_dir) for name in ('main164.cc', 'Makefile'): shutil.copy(pjoin(examples, name), build_dir) + if source is not None: + with open(pjoin(build_dir, 'main164.cc'), 'w') as fsock: + fsock.write(source) with open(pjoin(build_dir, 'Makefile.inc'), 'w') as fsock: fsock.write(makefile_inc) diff --git a/madspace/CMakeLists.txt b/madspace/CMakeLists.txt index 5393463a95..c7989a423f 100644 --- a/madspace/CMakeLists.txt +++ b/madspace/CMakeLists.txt @@ -257,6 +257,7 @@ add_library( src/phasespace/channel_weight_network.cpp src/phasespace/pdf.cpp src/phasespace/matrix_element.cpp + src/phasespace/mlm_clustering.cpp src/phasespace/cross_section.cpp src/phasespace/scale.cpp include/madspace/util.hpp @@ -314,6 +315,7 @@ add_library( include/madspace/phasespace/channel_weight_network.hpp include/madspace/phasespace/pdf.hpp include/madspace/phasespace/matrix_element.hpp + include/madspace/phasespace/mlm_clustering.hpp include/madspace/phasespace/cross_section.hpp include/madspace/phasespace/scale.hpp include/madspace/mixmax/mixmax.hpp @@ -362,6 +364,7 @@ set( src/kernels/rambo.hpp src/kernels/cuts.hpp src/kernels/chili.hpp + src/kernels/mlm.hpp src/kernels/nn.hpp src/kernels/observables.hpp src/kernels/discrete.hpp diff --git a/madspace/generate_pyi.py b/madspace/generate_pyi.py index 2663d9363a..b83c686e40 100644 --- a/madspace/generate_pyi.py +++ b/madspace/generate_pyi.py @@ -57,6 +57,12 @@ def main(): "--enum-class-locations=Activation:MLP.Activation", "--enum-class-locations=CutMode:Cuts.CutMode", "--enum-class-locations=LRSchedule:AdamOptimizer.LRSchedule", + "--enum-class-locations=JetScaleScheme:MLMClustering.JetScaleScheme", + "--enum-class-locations=ScaleScheme:MLMClustering.ScaleScheme", + "--enum-class-locations=PartonLineScheme:MLMClustering.PartonLineScheme", + "--enum-class-locations=AlphasScheme:MLMClustering.AlphasScheme", + "--enum-class-locations=ClusteringMeasure:MLMClustering.ClusteringMeasure", + "--enum-class-locations=ClusteringHistory:MLMClustering.ClusteringHistory", # Fail the build on unresolvable names/expressions (e.g. an enum # default value needing its own --enum-class-locations entry) # instead of silently emitting a stub with holes in it. diff --git a/madspace/include/madspace/driver/channel_generator.hpp b/madspace/include/madspace/driver/channel_generator.hpp index 3a7a155e30..b8476a1e32 100644 --- a/madspace/include/madspace/driver/channel_generator.hpp +++ b/madspace/include/madspace/driver/channel_generator.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -219,6 +220,10 @@ class ChannelEventGenerator { int color_index, helicity_index, diagram_index, flavor_index; int ren_scale, alpha_qcd; int x1, fact_scale1, x2, fact_scale2, partial_weight_product; + int cluster_scales; + int alphas_scales; + // x, pdg, q, info, lead_q of the pdf ladder + std::array ladder; int subprocess_index; int bias; int random, rest; diff --git a/madspace/include/madspace/driver/io.hpp b/madspace/include/madspace/driver/io.hpp index 5dffe32755..4aca6da092 100644 --- a/madspace/include/madspace/driver/io.hpp +++ b/madspace/include/madspace/driver/io.hpp @@ -79,6 +79,14 @@ class ParticleRecord { static constexpr int f_particle_data = 1; static constexpr int f_lhe_particle = 2; static constexpr int f_clustering = 4; + // MLM alpha_s reweighting: the scale of the clustering vertex stored on + // outgoing particle 2 + i is vertex i's. Only kept in the combined buffer + // the systematics read, never written to an event file. + static constexpr int f_alphas_scales = 8; + // MLM pdf reweighting ladder: step s (each one that can carry a pdf + // ratio) on particle s, each beam's leading density on particle 0 and 1. + // Like f_alphas_scales only in the combined buffer, for the systematics. + static constexpr int f_pdf_ladder = 16; static std::vector layout(int fields) { std::vector ret; @@ -115,6 +123,21 @@ class ParticleRecord { if (fields & f_clustering) { ret.push_back({"cluster_scale", FieldLayout::f64, 1}); } + if (fields & f_alphas_scales) { + ret.push_back({"alphas_scale", FieldLayout::f64, 2}); + } + if (fields & f_pdf_ladder) { + ret.insert( + ret.end(), + { + {"ladder_x", FieldLayout::f64, 3}, + {"ladder_pdg", FieldLayout::f64, 3}, + {"ladder_q", FieldLayout::f64, 3}, + {"ladder_info", FieldLayout::f64, 3}, + {"ladder_lead_q", FieldLayout::f64, 3}, + } + ); + } return ret; } @@ -144,8 +167,20 @@ class ParticleRecord { // clustering data UnalignedRef cluster_scale() { return &_data[_offsets[0] + 0]; } - - void from_lhe_particle(const LHEParticle& particle) { + // scale alpha_s was evaluated at for one MLM clustering vertex, see + // f_alphas_scales + UnalignedRef alphas_scale() { return &_data[_offsets[1] + 0]; } + // pdf reweighting ladder, see f_pdf_ladder, as madevent books it for its + // systematics: per step the line's flavour, its momentum fraction before + // the step's z and the scale the step takes it to; ladder_info is + // active + 2 beam. + UnalignedRef ladder_x() { return &_data[_offsets[2] + 0]; } + UnalignedRef ladder_pdg() { return &_data[_offsets[2] + 8]; } + UnalignedRef ladder_q() { return &_data[_offsets[2] + 16]; } + UnalignedRef ladder_info() { return &_data[_offsets[2] + 24]; } + UnalignedRef ladder_lead_q() { return &_data[_offsets[2] + 32]; } + + void from_lhe_particle(const LHEParticle& particle, bool has_clustering = false) { lhe_pdg_id() = particle.pdg_id; lhe_status_code() = particle.status_code; lhe_mother1() = particle.mother1; @@ -159,6 +194,9 @@ class ParticleRecord { lhe_mass() = particle.mass; lhe_lifetime() = particle.lifetime; lhe_spin() = particle.spin; + if (has_clustering) { + cluster_scale() = particle.cluster_scale; + } } private: diff --git a/madspace/include/madspace/driver/lhe_output.hpp b/madspace/include/madspace/driver/lhe_output.hpp index fde1385ea2..b224372195 100644 --- a/madspace/include/madspace/driver/lhe_output.hpp +++ b/madspace/include/madspace/driver/lhe_output.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -94,6 +95,7 @@ struct LHEParticle { double lifetime; /// Cosine of the spin angle, or `9` if the spin is not specified. double spin; + double cluster_scale; }; /// Leading-order reweighting inputs of one event, written as the `` @@ -111,6 +113,19 @@ struct LOReweightInfo { double x1, x2; /// Factorization scales of the two beams, in GeV. double fact_scale1, fact_scale2; + /// MLM pdf reweighting ladder of each beam, as madevent's `` + /// books it: the beam parton at its leading scale, then one entry per + /// ladder step (line flavour, momentum fraction before the step, scale). + /// Empty: a single entry from pdg/x/fact_scale. + std::array, 2> ladder_pdgs{}; + /// See @ref ladder_pdgs. + std::array, 2> ladder_x{}; + /// See @ref ladder_pdgs. + std::array, 2> ladder_q{}; + /// Scales of the MLM alpha_s vertices (``); `qcd_power` counts + /// them too, the `` power is what is left at the + /// renormalisation scale. + std::vector asrwt{}; }; /** @@ -134,6 +149,18 @@ struct LHEEvent { double alpha_qcd; /// One entry per particle, incoming and outgoing. std::vector particles; + /// Whether the MLM clustering scales of the outgoing particles are written + /// (`pt_clust_` attributes of ``). + bool has_cluster_scales; + /// The renormalisation and factorisation scales, written as the standard + /// LHEF3 `` attributes. SCALUP carries only one number, and the two + /// codes do not agree on which: unwgt.f writes sqrt(max(q2fact)) there, + /// i.e. mu_F, while this writer puts mu_R. Emitting both explicitly means a + /// comparison does not have to guess. + double mu_r; + double mu_f1; + double mu_f2; + bool has_scales; /// Optional LHEF v3 weights (`` block); `rwgt_ids` and `rwgt` have /// the same length. std::vector rwgt_ids; diff --git a/madspace/include/madspace/driver/systematics.hpp b/madspace/include/madspace/driver/systematics.hpp index 59431c6616..25321c57c0 100644 --- a/madspace/include/madspace/driver/systematics.hpp +++ b/madspace/include/madspace/driver/systematics.hpp @@ -44,6 +44,22 @@ struct SystematicsConfig { std::vector mur{1.}; /// Factorization scale factors; `1.0` is the nominal point. std::vector muf{1.}; + /// Emission scale factors of an MLM merged sample, madevent's `alpsfact`: + /// the alpha_s of every clustering vertex is evaluated at the factor times + /// the vertex scale. `1.0` is the nominal point; the other values are + /// written in an "Emission scale variation" group. Only for events whose + /// record carries the vertex scales (ParticleRecord::f_alphas_scales). + std::vector alps{1.}; + /// If true, the events may carry an MLM pdf reweighting ladder + /// (ParticleRecord::f_pdf_ladder): every parton of the PDF set is then + /// made available, since a ladder step can be any of them. + bool pdf_ladder = false; + /// Range the varied scales of the pdf ladder are clamped to, normally + /// the Q range of the PDF grid, where LHAPDF freezes the density for + /// madevent; `0` disables a bound. + double ladder_min_scale = 0.; + /// See @ref ladder_min_scale. + double ladder_max_scale = 0.; /// If true, vary every `(mur, muf)` combination; if false, vary one /// factor at a time. bool together = true; @@ -97,13 +113,21 @@ struct Variation { /// Dynamical scale choice; `-1` for the scale the events were generated /// with. int dyn = -1; + /// Emission scale factor of the MLM alpha_s vertices (madevent's + /// `alpsfact`); `1` for the nominal vertex scales. + double alps = 1.; /// Whether this is a scale variation (a non-nominal `mur`, `muf` or `dyn` /// at the nominal PDF member). bool is_scale() const { return pdf_index == -1 && (mur != 1. || muf != 1. || dyn != -1); } + /// Whether this is an emission scale variation (a non-nominal `alps` at + /// the nominal scales and PDF member). + bool is_emission() const { return pdf_index == -1 && !is_scale() && alps != 1.; } /// Whether this is the nominal point (nominal scale and PDF member). - bool is_nominal() const { return pdf_index == -1 && !is_scale(); } + bool is_nominal() const { + return pdf_index == -1 && !is_scale() && !is_emission(); + } }; /// The variations belonging to one PDF set, for computing an uncertainty band. @@ -186,6 +210,9 @@ class SystematicsCalculator { /// Indices into @ref variations of the scale variations (the envelope /// group). std::vector scale_variation_indices() const; + /// Indices into @ref variations of the MLM emission scale variations + /// (their own envelope group). + std::vector emission_variation_indices() const; /// The PDF groups, one per set with members among @ref variations. std::vector pdf_groups() const; diff --git a/madspace/include/madspace/phasespace.hpp b/madspace/include/madspace/phasespace.hpp index d4e3990962..dafca32363 100644 --- a/madspace/include/madspace/phasespace.hpp +++ b/madspace/include/madspace/phasespace.hpp @@ -17,6 +17,7 @@ #include "phasespace/luminosity.hpp" #include "phasespace/madnis.hpp" #include "phasespace/matrix_element.hpp" +#include "phasespace/mlm_clustering.hpp" #include "phasespace/mlp.hpp" #include "phasespace/multichannel.hpp" #include "phasespace/observable.hpp" diff --git a/madspace/include/madspace/phasespace/integrand.hpp b/madspace/include/madspace/phasespace/integrand.hpp index 85b4d29247..0bab9ef189 100644 --- a/madspace/include/madspace/phasespace/integrand.hpp +++ b/madspace/include/madspace/phasespace/integrand.hpp @@ -118,6 +118,9 @@ class Integrand : public FunctionGenerator { * rescale the weight accordingly. * @param partial_weights If true, emit the per-factor * weights for buffered unweighting. + * Ignored under MLM pdf + * reweighting, whose densities are + * not at the factorisation scale. * @param channel_indices Global indices of this * integrand's channels. * @param active_flavors Flavour indices this integrand @@ -143,6 +146,15 @@ class Integrand : public FunctionGenerator { * the prefix @ref pdf2_prefix. * Not allowed with a @ref CustomPdf, * which receives the beam index. + * @param mlm_history_qcd_power Power of alpha_s in |M|^2 when it + * is the same for every diagram, + * -1 otherwise. With an MLM history + * picked per diagram, the matrix + * element at the scales of that + * history is then the first + * evaluation rescaled by the alpha_s + * and PDF ratios instead of a second + * evaluation. */ Integrand( const PhaseSpaceMapping& mapping, @@ -174,7 +186,8 @@ class Integrand : public FunctionGenerator { const std::vector& flavor_per_subproc_remap = {}, std::size_t compressed_channel_weight_count = 50, const std::optional& custom_bias = std::nullopt, - const std::optional& pdf_grid2 = std::nullopt + const std::optional& pdf_grid2 = std::nullopt, + int mlm_history_qcd_power = -1 ); /// Globals prefix under which the second beam's PDF grid is registered /// (PdfGrid::initialize_globals) when `pdf_grid2` is given. @@ -242,10 +255,34 @@ class Integrand : public FunctionGenerator { FunctionBuilder& fb, const NamedVector& args ) const override; NamedVector compute_channel_part_ret_types() const; + // Whether build_channel_part multiplies anything into weight_after_cuts: + // flavor or mirror sampling, or the merging weights and the scale veto of + // the history over every diagram. The common part leaves weight_after_cuts + // out of its product otherwise, as it is then one by construction. + bool has_channel_weight_after_cuts() const; NamedVector build_channel_part(FunctionBuilder& fb, const NamedVector& args) const; NamedVector build_common_part(FunctionBuilder& fb, const NamedVector& channel_out) const; + // The beam densities at the scales an MLM or dynamical scale choice + // produced, for the sampled flavour. + std::array evaluate_pdfs( + FunctionBuilder& fb, + const NamedVector& scales, + const std::array& x, + Value flavor_id + ) const; + // The factors an MLM clustering puts on the event weight: the merging cut, + // the alpha_s and the pdf reweighting, each with its veto. With `ladder`, + // also the pdf reweighting ladder itself, as the systematics need it to + // vary it (see emits_pdf_ladder in integrand.cpp). + ValueVec mlm_weights( + FunctionBuilder& fb, + const NamedVector& scales, + const std::array& x, + Value flavor_id, + NamedVector* ladder = nullptr + ) const; PhaseSpaceMapping _mapping; std::vector _diff_xs; @@ -258,8 +295,31 @@ class Integrand : public FunctionGenerator { // for the events whose orientation is mirrored std::array, 2> _pdfs_swapped; std::array, 2> _pdf_indices; + // The pdf reweighting asks for densities the flavour sampling never does: + // the flavour of a beam line partway up the clustering ladder. One density + // over every flavour that can turn up there, picked per event by index. + std::optional _pdf_rw; + // Flavour class (as the mlm kernel emits it) and sampled flavour option to + // an index into that density, flattened as class * option_count + option, + // with the class offsets alongside so that the two can be added as ints. + std::vector _pdf_rw_table; + std::vector _pdf_rw_class_offsets; + // pdg id of each parton of _pdf_rw, as a float for gather + std::vector _pdf_rw_pdgs; std::optional _running_coupling; std::optional _energy_scale; + // One row with a 1 for every matrix-element diagram the MLM clustering has + // a history of its own for, used to restrict the per-event diagram pick. + nested_vector2 _mlm_diagram_mask; + // MLMClustering::diagram_start_states padded to the matrix element's + // diagram count, so that any diagram the pick can return has an entry. + std::vector _mlm_start_states; + // see mlm_history_qcd_power in the constructor; -1 re-evaluates + int _mlm_history_qcd_power = -1; + // abs pdg id of every external leg per flavour option, row-major, for the + // clusterings that only exist for some flavours of a merged subprocess. + // Empty when the clustering does not depend on the flavours. + std::vector _mlm_leg_flavors; std::optional _prop_chan_weights; std::optional _subchan_weights; std::optional _chan_weight_net; diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp new file mode 100644 index 0000000000..58fa8c9dc3 --- /dev/null +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -0,0 +1,283 @@ +#pragma once + +#include + +#include "madspace/phasespace/base.hpp" +#include "madspace/phasespace/topology.hpp" + +namespace madspace { + +// Number of words each clustering transition occupies in the compiled state +// machine: (data, next_offset, trace_data). +inline constexpr int state_machine_item_size = 3; + +// How a clustering scale is assigned to an outgoing jet. +enum class JetScaleScheme { + // The scale of the vertex at which the leg itself was emitted, i.e. the + // softest clustering it takes part in. + emission = 0, + // The hardest vertex on the parton line the leg belongs to, which is the + // scale at which that line was produced. This is what madevent writes, and + // what Pythia uses as the shower starting scale for the leg when MLM + // merging turns on Beams:setProductionScalesFromLHEF. + production = 1, +}; + +// How the renormalisation and factorisation scales are read off the clustering +// history once it has been chosen. +enum class ScaleScheme { + // Geometric mean of every clustering scale, with the non-QCD ones replaced + // by the largest, and a single factorisation scale taken as the smallest + // QCD clustering scale capped at mu_R. + clustering_mean = 0, + // What madevent does: follow each beam's parton line through the + // clustering and take + // mu_R = (s[jlast1] s[jcentral1] s[jlast2] s[jcentral2])^(1/4) + // mu_F,b = sqrt(s[jlast_b] s[jcentral_b]) + // where jlast is the last initial-state clustering while the beam line is + // still a jet, and jcentral the last one while it is still coloured. The + // two beams get different factorisation scales. + madevent = 1, +}; + +// How the beam parton line is decided to carry on past a vertex. Only read by +// ScaleScheme::madevent. +enum class PartonLineScheme { + // From the flavour of the object emitted at that vertex alone. + flavor = 0, + // From goodjet of reweight.f: a line counts as a parton line only while + // every clustering it has been through was a jet vertex, so one non-jet + // vertex anywhere in its history stops it for good. This is what madevent + // does, and it stops beam lines earlier than flavor does. + goodjet = 1, +}; + +// How alpha_s is evaluated for a merged event. +enum class AlphasScheme { + // One coupling at the event's renormalisation scale, for the whole event. + none = 0, + // alphas(pt_i) at each clustering vertex where a parton is produced, which + // is what madevent does (the rewgt loop in reweight.f). A merged sample + // without it is short by one factor per emission. + per_vertex = 1, + // One coupling at the geometric mean of those vertex scales, raised to the + // number of them: the same idea with a single scale for the whole ladder. + geometric_mean = 2, +}; + +// Which clustering measure scores a candidate final-state pair. Only the pairs +// the two definitions disagree on are affected: a non-resonant final-state +// clustering whose mother is massless with one massive and one massless +// daughter (q* > q W, g* > g h), or whose mother is massive with two massless +// daughters. Initial-state and resonant clusterings are the same in both. +enum class ClusteringMeasure { + // cluster_scale of Template/NLO/SubProcesses/cluster.f, the FxFx + // definition: sqrt(|p_j . (p_i + p_j)|) / 2 for the massless-mother case and + // the invariant mass for the massive-mother one. + fxfx = 0, + // DJ of Template/LO/Source/kin_functions.f, which madevent's LO cluster.f + // uses for every final-state pair: a massless-massive pair scores the + // massless one's transverse mass (times 1 + 1e-6), and anything else the + // kt measure with the larger mass squared added. + madevent = 1, +}; + +// Which diagrams a clustering history may follow. +enum class ClusteringHistory { + // The smallest-measure history over the union of every diagram, with the + // properties of each line taken from the first diagram that has it. + all_diagrams = 0, + // Pick one diagram per event, with probability proportional to its + // single-diagram weight |A_i|^2, and cluster along that diagram alone: its + // own line flavours, vertices and merging jets. What an integration + // channel is to madevent's reclustering, without its first-event + // bookkeeping. + diagram = 1, + // setclscales of Template/LO/SubProcesses/reweight.f: keep the history + // over every diagram unless it calls a different number of legs merging + // jets than the picked diagram does, and fall back to that diagram then. + madevent = 2, +}; + +class MLMClustering : public FunctionGenerator { +public: + MLMClustering( + std::vector topologies, + nested_vector3 permutations, + nested_vector2 diagram_indices, + // Collider energy. An outgoing leg that no QCD clustering assigned a + // scale to is reported at this value rather than at zero, so that an + // MLM veto can never trip on it. + double cm_energy, + JetScaleScheme jet_scale_scheme = JetScaleScheme::production, + // Left at the existing definition by default: unlike + // jet_scale_scheme, this one moves the cross section. + ScaleScheme scale_scheme = ScaleScheme::clustering_mean, + // Signed color representation per pdg id, as exported in the + // subprocess metadata. Used to follow a parton line through the + // clustering; falls back to the Standard Model assignment for a pdg id + // that is not listed. + std::unordered_map pdg_color_types = {}, + // Generation-level merging cut, the counterpart of madevent's xqcut. + // A clustering that emitted a jet below this scale drops the event. + // Zero disables it. + double xqcut = 0, + double bw_cutoff = 15, + // D of the final-state kt measure; 1 as madevent's run_card d and FxFx. + double jet_radius = 1.0, + bool hadronic = true, + // pdg ids of the external particles, in leg order. When empty, every + // clustering is assumed to be a QCD splitting between jets. + std::vector external_pdg_ids = {}, + int max_jet_flavor = 4, + PartonLineScheme parton_line_scheme = PartonLineScheme::goodjet, + AlphasScheme alphas_scheme = AlphasScheme::per_vertex, + // Re-evaluate the beam densities along the clustering ladder instead of + // once at the factorisation scale, which is what madevent does for a + // merged sample (pdfwgt in the run card, hidden and on by default). + bool pdf_reweighting = true, + ClusteringMeasure clustering_measure = ClusteringMeasure::fxfx, + // Anything but all_diagrams also compiles one state machine per + // diagram, which build_along_diagram walks. + ClusteringHistory clustering_history = ClusteringHistory::all_diagrams, + // With pdf_reweighting, also walk the densities along the clustering + // ladder under scale_scheme = clustering_mean: the densities (their + // lowered leading scale, the ladder ratios and the per-beam scale the + // ladder ends at) are then madevent's, while mu_R, the alpha_s vertex + // scales and the jet bookkeeping stay clustering_mean's. On by default: + // without it clustering_mean takes both densities at the softest + // clustering scale, which is what moves its merged rates near qcut + // away from madevent's. + bool clustering_mean_pdf_ladder = true + ); + + // The same outputs as the function itself, for a history restricted to + // the given diagram index (as the matrix element numbers diagrams). A + // diagram no topology was supplied for falls back to the history over + // every diagram. + NamedVector + build_along_diagram(FunctionBuilder& fb, Value momenta, Value diagram) const; + // The same from an offset into cluster_state_machine, as + // diagram_start_states lists them, for a caller that keeps its own table. + // flavor_index and leg_flavors restrict the walk to the clusterings that + // exist for the flavours of each event: leg_flavors holds, row-major, the + // abs pdg id of every external leg for each flavour index. Left out, every + // clustering of the union stays allowed. + NamedVector build_from_start_state( + FunctionBuilder& fb, + Value momenta, + Value start_state, + Value flavor_index = Value(), + const std::vector& leg_flavors = {} + ) const; + // The history over every diagram, restricted to the flavours of each event + // as build_from_start_state does. + NamedVector build_with_flavors( + FunctionBuilder& fb, + Value momenta, + Value flavor_index, + const std::vector& leg_flavors + ) const; + // Whether any clustering of the compiled state machines depends on the + // flavours of the event. Without one, the flavours change nothing. + bool has_flavor_dependent_clusterings() const { return _has_same_flavor; } + + // The compiled clustering state machine, in the flat encoding the kernel + // walks. Exposed so that its structure can be checked directly. + const std::vector& cluster_state_machine() const { + return _cluster_state_machine; + } + const std::vector& external_masses() const { return _external_masses; } + const std::vector& bw_masses() const { return _bw_masses; } + const std::vector& bw_widths() const { return _bw_widths; } + AlphasScheme alphas_scheme() const { return _alphas_scheme; } + bool pdf_reweighting() const { return _pdf_reweighting; } + bool clustering_mean_pdf_ladder() const { return _clustering_mean_pdf_ladder; } + ScaleScheme scale_scheme() const { return _scale_scheme; } + ClusteringMeasure clustering_measure() const { return _clustering_measure; } + ClusteringHistory clustering_history() const { return _clustering_history; } + // Offset of each diagram's own state machine in cluster_state_machine, + // indexed by diagram index, or 0 for a diagram that has none. Empty under + // ClusteringHistory::all_diagrams. + const std::vector& diagram_start_states() const { + return _diagram_start_states; + } + // The flavours the pdf reweighting asks for that are neither the gluon nor + // a beam's own, in the order the kernel's flavour classes index them. The + // consumer turns these, the gluon and the per-channel beam flavours into + // the density it evaluates; see the flavour-class comment in + // mlm_clustering.cpp. + const std::vector& pdf_absolute_pdgs() const { return _pdf_absolute_pdgs; } + +private: + NamedVector build_function_impl( + FunctionBuilder& fb, const NamedVector& args + ) const override; + NamedVector build_kernel( + FunctionBuilder& fb, + Value momenta, + Value start_state, + me_int_t history_mode, + Value flavor_index, + const std::vector& leg_flavors + ) const; + + std::vector _cluster_state_machine; + std::vector _diagram_start_states; + std::vector _external_masses; + std::vector _bw_masses; + std::vector _bw_widths; + double _cm_energy; + JetScaleScheme _jet_scale_scheme; + ScaleScheme _scale_scheme; + PartonLineScheme _parton_line_scheme; + AlphasScheme _alphas_scheme; + bool _pdf_reweighting; + bool _clustering_mean_pdf_ladder; + ClusteringMeasure _clustering_measure; + ClusteringHistory _clustering_history; + std::vector _pdf_absolute_pdgs; + bool _has_same_flavor = false; + int _beam_flags; + int _jet_leg_mask; + double _xqcut; + double _bw_cutoff; + double _jet_radius; + bool _hadronic; +}; + +// The clustering along one diagram as a function of its own, taking the +// diagram index as a second argument. The integrand calls build_along_diagram +// directly; this is how that path is reached from Python. +class MLMClusteringAlongDiagram : public FunctionGenerator { +public: + MLMClusteringAlongDiagram(const MLMClustering& clustering); + +private: + NamedVector build_function_impl( + FunctionBuilder& fb, const NamedVector& args + ) const override; + + MLMClustering _clustering; +}; + +// The clustering over every diagram restricted to the flavours of each event, +// taking the flavour index as a second argument and the table of leg flavours +// (see MLMClustering::build_with_flavors) at construction. The integrand calls +// build_with_flavors directly; this is how that path is reached from Python. +class MLMClusteringWithFlavors : public FunctionGenerator { +public: + MLMClusteringWithFlavors( + const MLMClustering& clustering, std::vector leg_flavors + ); + +private: + NamedVector build_function_impl( + FunctionBuilder& fb, const NamedVector& args + ) const override; + + MLMClustering _clustering; + std::vector _leg_flavors; +}; + +} // namespace madspace diff --git a/madspace/include/madspace/phasespace/scale.hpp b/madspace/include/madspace/phasespace/scale.hpp index 8f8abab21c..c615c8b4bd 100644 --- a/madspace/include/madspace/phasespace/scale.hpp +++ b/madspace/include/madspace/phasespace/scale.hpp @@ -2,6 +2,7 @@ #include "madspace/phasespace/base.hpp" #include "madspace/phasespace/custom_function.hpp" +#include "madspace/phasespace/mlm_clustering.hpp" namespace madspace { @@ -63,12 +64,38 @@ class EnergyScale : public FunctionGenerator { /// Dynamical scale of type @p type for all three scales. /// @param particle_count Number of external particles. /// @param type The dynamical scale choice. - EnergyScale(std::size_t particle_count, DynamicalScaleType type) : - EnergyScale(particle_count, type, false, false, 0., 0., 0.) {} + /// @param min_scale Floor on the scales, see the full constructor. + /// @param max_scale Ceiling on the scales, see the full constructor. + EnergyScale( + std::size_t particle_count, + DynamicalScaleType type, + double min_scale = 0., + double max_scale = 0. + ) : + EnergyScale( + particle_count, + type, + false, + false, + 0., + 0., + 0., + 1., + std::nullopt, + min_scale, + max_scale + ) {} /// A single fixed value for all three scales. /// @param particle_count Number of external particles. /// @param fixed_scale The constant scale value. - EnergyScale(std::size_t particle_count, double fixed_scale) : + /// @param min_scale Floor on the scales, see the full constructor. + /// @param max_scale Ceiling on the scales, see the full constructor. + EnergyScale( + std::size_t particle_count, + double fixed_scale, + double min_scale = 0., + double max_scale = 0. + ) : EnergyScale( particle_count, half_transverse_mass, @@ -76,7 +103,11 @@ class EnergyScale : public FunctionGenerator { true, fixed_scale, fixed_scale, - fixed_scale + fixed_scale, + 1., + std::nullopt, + min_scale, + max_scale ) {} /** * @param particle_count Number of external particles. @@ -90,6 +121,12 @@ class EnergyScale : public FunctionGenerator { * left alone. * @param custom_scale Optional user-supplied function that replaces * the dynamical scale choice. + * @param min_scale Floor on mu_R and mu_F. The scales are clamped + * to it, so that a pdf is never asked for a + * density below the bottom of its grid. Zero + * disables it. + * @param max_scale Upper end of the same range, normally the top + * of the PDF grid. Zero disables it. */ EnergyScale( std::size_t particle_count, @@ -100,22 +137,111 @@ class EnergyScale : public FunctionGenerator { double fact_scale1, double fact_scale2, double scale_factor = 1., - const std::optional& custom_scale = std::nullopt + const std::optional& custom_scale = std::nullopt, + double min_scale = 0., + double max_scale = 0. + ); + /** + * Scales from an MLM clustering of the event (dynamical_scale_choice mlm). + * + * @param clustering The compiled clustering histories of the subprocess. + * @param min_scale As in the full constructor; here an event whose scales + * fall outside the range is also vetoed, through an + * extra `scale_weight` output, as setclscales does. + * @param max_scale As in the full constructor. + */ + EnergyScale( + const MLMClustering& clustering, + double min_scale = 0., + double max_scale = 0. ); + /// Whether the scales come from an MLM clustering of the event. + bool is_mlm() const { return _clustering.has_value(); } + bool mlm_alphas_reweighting() const { + return _clustering && + _clustering->alphas_scheme() != AlphasScheme::none; + } + // Whether the consumer has pdf ratios to apply. The kernel walks the + // clustering ladder under ScaleScheme::madevent, and under clustering_mean + // with clustering_mean_pdf_ladder; otherwise it leaves every reweighting + // slot inert and pdf_scale equal to fact_scale, so the ratios would all + // come out as exactly one. + bool mlm_pdf_reweighting() const { + return _clustering && _clustering->pdf_reweighting() && + (_clustering->scale_scheme() == ScaleScheme::madevent || + _clustering->clustering_mean_pdf_ladder()); + } + const std::vector& mlm_pdf_absolute_pdgs() const { + return _clustering.value().pdf_absolute_pdgs(); + } + // Whether the MLM history is picked per event from the diagram weights, + // which moves the clustering after a first matrix element evaluation. + bool mlm_history_per_diagram() const { + return _clustering && + _clustering->clustering_history() != ClusteringHistory::all_diagrams; + } + const std::vector& mlm_diagram_start_states() const { + return _clustering.value().diagram_start_states(); + } + // The scales of the MLM clustering walked from one diagram's start state + // (see MLMClustering::diagram_start_states), with the same scale range + // applied as the function itself applies. + // flavor_index and leg_flavors as in MLMClustering::build_from_start_state. + NamedVector build_mlm_from_start_state( + FunctionBuilder& fb, + Value momenta, + Value start_state, + Value flavor_index = Value(), + const std::vector& leg_flavors = {} + ) const; + // The MLM history over every diagram, restricted to the flavours of each + // event (MLMClustering::build_with_flavors), with the scale range applied. + NamedVector build_mlm_with_flavors( + FunctionBuilder& fb, + Value momenta, + Value flavor_index, + const std::vector& leg_flavors + ) const; + bool mlm_flavor_dependent() const { + return _clustering && _clustering->has_flavor_dependent_clusterings(); + } + // The band the pdf grid covers, for whoever has to keep a scale inside it. + double min_scale() const { return _min_scale; } + double max_scale() const { return _max_scale; } + + bool has_scale_range() const { + return _min_scale > 0. || _max_scale > 0.; + } + // Whether the scales come with a scale_weight output vetoing an event + // outside the range. Only the MLM clustering has one: madevent drops a + // merged event whose factorisation scale falls under 2 GeV, but for any + // other scale choice it lets LHAPDF freeze the density instead, which is + // what clamping the scale does here. + bool has_scale_veto() const { return is_mlm() && has_scale_range(); } + private: + NamedVector apply_scale_range( + FunctionBuilder& fb, NamedVector scales + ) const; + NamedVector build_function_impl( FunctionBuilder& fb, const NamedVector& args ) const override; - DynamicalScaleType _dynamical_scale_type; - bool _ren_scale_fixed; - bool _fact_scale_fixed; - double _ren_scale; - double _fact_scale1; - double _fact_scale2; - double _scale_factor; + // The MLM constructor leaves these at their defaults: the clustering + // decides the scales there. + DynamicalScaleType _dynamical_scale_type = half_transverse_mass; + bool _ren_scale_fixed = false; + bool _fact_scale_fixed = false; + double _ren_scale = 0.; + double _fact_scale1 = 0.; + double _fact_scale2 = 0.; + double _scale_factor = 1.; std::optional _custom_scale; + double _min_scale = 0.; + double _max_scale = 0.; + std::optional _clustering; }; } // namespace madspace diff --git a/madspace/include/madspace/phasespace/topology.hpp b/madspace/include/madspace/phasespace/topology.hpp index b87aa0b5e4..96f48b1240 100644 --- a/madspace/include/madspace/phasespace/topology.hpp +++ b/madspace/include/madspace/phasespace/topology.hpp @@ -192,6 +192,12 @@ class Topology { const std::vector& t_propagator_widths() const { return _t_propagator_widths; } + /// The pdg ids of the `t`-channel propagators, in the same order as + /// t_propagator_masses(). Needed to tell a QCD splitting off the initial + /// state from a QED/EW one. + const std::vector& t_propagator_pdg_ids() const { + return _t_propagator_pdg_ids; + } /// The `s`-channel decay-tree nodes. const std::vector& decays() const { return _decays; } /** @@ -240,6 +246,7 @@ class Topology { std::vector _t_integration_order; std::vector _t_propagator_masses; std::vector _t_propagator_widths; + std::vector _t_propagator_pdg_ids; std::vector _decays; std::vector _decay_integration_order; std::vector _outgoing_indices; diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index e92e6f962b..f78a3c0ee1 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -2786,6 +2786,252 @@ scale_partonic_energy: desc: "the energy scale" desc: "Dynamical scale set to the partonic center-of-mass energy. See @ref EnergyScale." +mlm_clustering_hadronic: + inputs: + - name: momenta + type: [float, n, 4] + desc: + - name: random + type: [float] + desc: + - name: start_state + type: [int] + desc: state machine offset the walk starts at, 0 for the history over every diagram + - name: state_machine + type: [int, single, m] + desc: + - name: external_masses + type: [float, n] + desc: + - name: bw_masses + type: [float, single, k] + desc: + - name: bw_widths + type: [float, single, k] + desc: + - name: bw_cutoffs + type: [float, single] + desc: + - name: jet_radius + type: [float, single] + desc: + - name: cm_energy + type: [float, single] + desc: + - name: jet_scale_scheme + type: [int, single] + desc: + - name: xqcut + type: [float, single] + desc: + - name: scale_scheme + type: [int, single] + desc: + - name: beam_flags + type: [int, single] + desc: + - name: jet_leg_mask + type: [int, single] + desc: + - name: parton_line_scheme + type: [int, single] + desc: + - name: alphas_scheme + type: [int, single] + desc: + - name: pdf_reweighting + type: [int, single] + desc: "0 off, 1 the pdf ladder under scale_scheme madevent, 2 also under clustering_mean" + - name: clustering_measure + type: [int, single] + desc: + - name: history_mode + type: [int, single] + desc: ClusteringHistory, how the start state and the history over every diagram are combined + - name: flavor_index + type: [int] + desc: sampled flavour of the event, i.e. the row of leg_flavors that applies to it + - name: leg_flavors + type: [int, single, f] + desc: abs pdg id of every external leg per flavour, row-major; a row of zeros (or a flavor_index past the table) leaves every clustering allowed + outputs: + - name: ren_scale + type: [float] + desc: + - name: fact_scale1 + type: [float] + desc: + - name: fact_scale2 + type: [float] + desc: + - name: outgoing_scales + type: [float, n-2] + desc: + - name: diagram_index + type: [int] + desc: + - name: xqcut_weight + type: [float] + desc: + - name: alphas_scales + type: [float, n-3] + desc: scale of each clustering vertex for the alpha_s reweighting + - name: alphas_weight + type: [float] + desc: zero when a reweighted vertex sits where the coupling is not usable + - name: pdf_scale1 + type: [float] + desc: scale the beam-1 density is evaluated at, below fact_scale1 when reweighting + - name: pdf_scale2 + type: [float] + desc: scale the beam-2 density is evaluated at, below fact_scale2 when reweighting + - name: pdf_rw_flavor + type: [int, n-1] + desc: flavour class of the beam line at each pdf reweighting step + - name: pdf_rw_x + type: [float, n-1] + desc: momentum fraction of the beam line at each step, relative to the beam + - name: pdf_rw_q_num + type: [float, n-1] + desc: scale the density is reweighted to at each step + - name: pdf_rw_q_den + type: [float, n-1] + desc: scale the density is reweighted from at each step + - name: pdf_rw_active + type: [float, n-1] + desc: one where a step contributes a pdf ratio, zero for an inert slot + - name: pdf_rw_beam + type: [float, n-1] + desc: which beam each step sits on, as 0 or 1 + - name: pdf_rw_x_prev + type: [float, n-1] + desc: momentum fraction of the beam line before each step's z, relative to the beam + + vectorized: False + +mlm_clustering_leptonic: + inputs: + - name: momenta + type: [float, n, 4] + desc: + - name: random + type: [float] + desc: + - name: start_state + type: [int] + desc: state machine offset the walk starts at, 0 for the history over every diagram + - name: state_machine + type: [int, single, m] + desc: + - name: external_masses + type: [float, n] + desc: + - name: bw_masses + type: [float, single, k] + desc: + - name: bw_widths + type: [float, single, k] + desc: + - name: bw_cutoffs + type: [float, single] + desc: + - name: jet_radius + type: [float, single] + desc: + - name: cm_energy + type: [float, single] + desc: + - name: jet_scale_scheme + type: [int, single] + desc: + - name: xqcut + type: [float, single] + desc: + - name: scale_scheme + type: [int, single] + desc: + - name: beam_flags + type: [int, single] + desc: + - name: jet_leg_mask + type: [int, single] + desc: + - name: parton_line_scheme + type: [int, single] + desc: + - name: alphas_scheme + type: [int, single] + desc: + - name: pdf_reweighting + type: [int, single] + desc: "0 off, 1 the pdf ladder under scale_scheme madevent, 2 also under clustering_mean" + - name: clustering_measure + type: [int, single] + desc: + - name: history_mode + type: [int, single] + desc: ClusteringHistory, how the start state and the history over every diagram are combined + - name: flavor_index + type: [int] + desc: sampled flavour of the event, i.e. the row of leg_flavors that applies to it + - name: leg_flavors + type: [int, single, f] + desc: abs pdg id of every external leg per flavour, row-major; a row of zeros (or a flavor_index past the table) leaves every clustering allowed + outputs: + - name: ren_scale + type: [float] + desc: + - name: fact_scale1 + type: [float] + desc: + - name: fact_scale2 + type: [float] + desc: + - name: outgoing_scales + type: [float, n-2] + desc: + - name: diagram_index + type: [int] + desc: + - name: xqcut_weight + type: [float] + desc: + - name: alphas_scales + type: [float, n-3] + desc: scale of each clustering vertex for the alpha_s reweighting + - name: alphas_weight + type: [float] + desc: zero when a reweighted vertex sits where the coupling is not usable + - name: pdf_scale1 + type: [float] + desc: scale the beam-1 density is evaluated at, below fact_scale1 when reweighting + - name: pdf_scale2 + type: [float] + desc: scale the beam-2 density is evaluated at, below fact_scale2 when reweighting + - name: pdf_rw_flavor + type: [int, n-1] + desc: flavour class of the beam line at each pdf reweighting step + - name: pdf_rw_x + type: [float, n-1] + desc: momentum fraction of the beam line at each step, relative to the beam + - name: pdf_rw_q_num + type: [float, n-1] + desc: scale the density is reweighted to at each step + - name: pdf_rw_q_den + type: [float, n-1] + desc: scale the density is reweighted from at each step + - name: pdf_rw_active + type: [float, n-1] + desc: one where a step contributes a pdf ratio, zero for an inert slot + - name: pdf_rw_beam + type: [float, n-1] + desc: which beam each step sits on, as 0 or 1 + - name: pdf_rw_x_prev + type: [float, n-1] + desc: momentum fraction of the beam line before each step's z, relative to the beam + + vectorized: False + --- title: Chili diff --git a/madspace/src/driver/channel_generator.cpp b/madspace/src/driver/channel_generator.cpp index fb0492beda..fbdbf7ffb7 100644 --- a/madspace/src/driver/channel_generator.cpp +++ b/madspace/src/driver/channel_generator.cpp @@ -34,6 +34,12 @@ int particle_extra_flags( if (index_map.contains("cluster_scales")) { flags |= ParticleRecord::f_clustering; } + if (index_map.contains("alphas_scales")) { + flags |= ParticleRecord::f_alphas_scales; + } + if (index_map.contains("ladder_x")) { + flags |= ParticleRecord::f_pdf_ladder; + } return flags; } @@ -321,6 +327,30 @@ void ChannelEventGenerator::init_field_indices() { } else { _field_indices.partial_weight_product = -1; } + if (index_map.contains("cluster_scales")) { + _field_indices.cluster_scales = index_map.at("cluster_scales"); + } else { + _field_indices.cluster_scales = -1; + } + if (index_map.contains("alphas_scales")) { + _field_indices.alphas_scales = index_map.at("alphas_scales"); + } else { + _field_indices.alphas_scales = -1; + } + if (index_map.contains("ladder_x")) { + const char* names[] = { + "ladder_x", + "ladder_pdg", + "ladder_q", + "ladder_info", + "ladder_lead_q", + }; + for (std::size_t k = 0; k < _field_indices.ladder.size(); ++k) { + _field_indices.ladder[k] = index_map.at(names[k]); + } + } else { + _field_indices.ladder.fill(-1); + } if (index_map.contains("subprocess_index")) { _field_indices.subprocess_index = index_map.at("subprocess_index"); } else { @@ -822,6 +852,54 @@ void ChannelEventGenerator::write_events( } } + if (_field_indices.cluster_scales != -1) { + auto cluster_view = + unweighted_events.at(_field_indices.cluster_scales).view(); + for (std::size_t i = 0; i < w_view.size(); ++i) { + auto scales = cluster_view[i]; + for (std::size_t j = 0; j < _particle_count; ++j) { + auto particle = event_buffer.particle(i, j); + particle.cluster_scale() = j >= 2 ? scales[j - 2] : 0.; + } + } + } + + if (_field_indices.alphas_scales != -1) { + // vertex i on outgoing particle 2 + i; there is one vertex less than + // there are outgoing particles, the remaining slots hold zero + auto alphas_view = + unweighted_events.at(_field_indices.alphas_scales).view(); + for (std::size_t i = 0; i < w_view.size(); ++i) { + auto scales = alphas_view[i]; + std::size_t vertex_count = scales.size(); + for (std::size_t j = 0; j < _particle_count; ++j) { + auto particle = event_buffer.particle(i, j); + particle.alphas_scale() = + j >= 2 && j - 2 < vertex_count ? scales[j - 2] : 0.; + } + } + } + + if (_field_indices.ladder[0] != -1) { + auto slot_view = [&](std::size_t k) { + return unweighted_events.at(_field_indices.ladder[k]).view(); + }; + auto x_view = slot_view(0), pdg_view = slot_view(1), q_view = slot_view(2), + info_view = slot_view(3), lead_q_view = slot_view(4); + for (std::size_t i = 0; i < w_view.size(); ++i) { + std::size_t slot_count = x_view[i].size(); + for (std::size_t j = 0; j < _particle_count; ++j) { + auto particle = event_buffer.particle(i, j); + bool has_slot = j < slot_count; + particle.ladder_x() = has_slot ? x_view[i][j] : 0.; + particle.ladder_pdg() = has_slot ? pdg_view[i][j] : 0.; + particle.ladder_q() = has_slot ? q_view[i][j] : 0.; + particle.ladder_info() = has_slot ? info_view[i][j] : 0.; + particle.ladder_lead_q() = j < 2 ? lead_q_view[i][j] : 0.; + } + } + } + if (_field_indices.bias != -1) { auto bias_view = unweighted_events.at(_field_indices.bias).view(); for (std::size_t i = 0; i < bias_view.size(); ++i) { diff --git a/madspace/src/driver/event_generator.cpp b/madspace/src/driver/event_generator.cpp index 16be4e8a76..da22f8d241 100644 --- a/madspace/src/driver/event_generator.cpp +++ b/madspace/src/driver/event_generator.cpp @@ -774,10 +774,14 @@ void EventGenerator::combine_to_lhe_npy( copy_event_fields(buffer, i, buffer_out, i, syst_weights); std::size_t j = 0; for (; j < lhe_event.particles.size(); ++j) { - buffer_out.particle(i, j).from_lhe_particle(lhe_event.particles[j]); + buffer_out.particle(i, j).from_lhe_particle( + lhe_event.particles[j], lhe_event.has_cluster_scales + ); } for (; j < lhe_completer.max_particle_count(); ++j) { - buffer_out.particle(i, j).from_lhe_particle(LHEParticle{}); + buffer_out.particle(i, j).from_lhe_particle( + LHEParticle{}, lhe_event.has_cluster_scales + ); } } event_file.write(buffer_out); @@ -912,6 +916,32 @@ void EventGenerator::combine_to_lhe( _event_histograms = nullptr; } +namespace { + +// The MLM alpha_s vertex scales and pdf ladder of any channel: a merged run +// mixes a 2 -> 1 sample, which has no vertex, with the jet samples, which do, +// and channel 0 may be either. +template +int any_alphas_scales(const Channels& channels) { + int flags = 0; + for (auto& channel : channels) { + flags |= channel->particle_layout_extra_flags() & + (ParticleRecord::f_alphas_scales | ParticleRecord::f_pdf_ladder); + } + return flags; +} + +// the pdf ladder fields of one particle, or zeros (an inactive slot) +void copy_ladder(ParticleRecord in, ParticleRecord out, bool from_in) { + out.ladder_x() = from_in ? in.ladder_x().value() : 0.; + out.ladder_pdg() = from_in ? in.ladder_pdg().value() : 0.; + out.ladder_q() = from_in ? in.ladder_q().value() : 0.; + out.ladder_info() = from_in ? in.ladder_info().value() : 0.; + out.ladder_lead_q() = from_in ? in.ladder_lead_q().value() : 0.; +} + +} // namespace + DataLayout EventGenerator::combined_layout() const { return DataLayout( EventRecord::layout( @@ -921,7 +951,7 @@ DataLayout EventGenerator::combined_layout() const { ), ParticleRecord::layout( ParticleRecord::f_particle_data | - _channels.at(0)->particle_layout_extra_flags() + _channels.at(0)->particle_layout_extra_flags() | any_alphas_scales(_channels) ) ); } @@ -942,10 +972,14 @@ EventGenerator::output_layout(int event_flags, std::size_t particle_flags) const extra_flags |= EventRecord::f_syst_weights; } } + // the alpha_s vertex scales and the pdf ladder only serve the systematics, + // which run on the combined buffer: no event file carries them return DataLayout( EventRecord::layout(event_flags | extra_flags, syst_ids), ParticleRecord::layout( - particle_flags | _channels.at(0)->particle_layout_extra_flags() + particle_flags | + (_channels.at(0)->particle_layout_extra_flags() & + ~(ParticleRecord::f_alphas_scales | ParticleRecord::f_pdf_ladder)) ) ); } @@ -1180,6 +1214,11 @@ void EventGenerator::read_and_combine( bool has_beam2 = _channels.at(0)->event_layout_extra_flags() & EventRecord::f_beam2; bool has_partial = _channels.at(0)->event_layout_extra_flags() & EventRecord::f_partial_weights; + bool has_clustering = + _channels.at(0)->particle_layout_extra_flags() & ParticleRecord::f_clustering; + int mlm_flags = any_alphas_scales(_channels); + bool has_alphas_scales = mlm_flags & ParticleRecord::f_alphas_scales; + bool has_ladder = mlm_flags & ParticleRecord::f_pdf_ladder; bool has_subproc_index = _channels.at(0)->event_layout_extra_flags() & EventRecord::f_subproc_index; bool has_bias = _channels.at(0)->event_layout_extra_flags() & EventRecord::f_bias; @@ -1196,6 +1235,10 @@ void EventGenerator::read_and_combine( --chan.cum_count; }); auto& channel = _channels.at(sampled_chan - channel_data.begin()); + bool channel_has_alphas_scales = + channel->particle_layout_extra_flags() & ParticleRecord::f_alphas_scales; + bool channel_has_ladder = + channel->particle_layout_extra_flags() & ParticleRecord::f_pdf_ladder; double weight = 0.; while (true) { @@ -1255,6 +1298,18 @@ void EventGenerator::read_and_combine( particle_out.px() = particle_in.px(); particle_out.py() = particle_in.py(); particle_out.pz() = particle_in.pz(); + if (has_clustering) { + particle_out.cluster_scale() = particle_in.cluster_scale(); + } + if (has_alphas_scales) { + // zero from a channel without vertices (2 -> 1) + particle_out.alphas_scale() = channel_has_alphas_scales + ? particle_in.alphas_scale().value() + : 0.; + } + if (has_ladder) { + copy_ladder(particle_in, particle_out, channel_has_ladder); + } } for (; i < buffer.particle_count(); ++i) { auto particle_out = buffer.particle(event_index, i); @@ -1262,6 +1317,17 @@ void EventGenerator::read_and_combine( particle_out.px() = 0.; particle_out.py() = 0.; particle_out.pz() = 0.; + // the buffer is reused: a padding slot must not keep the scales of + // an earlier, higher-multiplicity event + if (has_clustering) { + particle_out.cluster_scale() = 0.; + } + if (has_alphas_scales) { + particle_out.alphas_scale() = 0.; + } + if (has_ladder) { + copy_ladder(particle_out, particle_out, false); + } } ++sampled_chan->buffer_index; } @@ -1280,6 +1346,17 @@ void EventGenerator::fill_lhe_event( lhe_event.scale = event_in.ren_scale(); lhe_event.alpha_qed = 0; // TODO: populate this lhe_event.alpha_qcd = event_in.alpha_qcd(); + // Both scales, as LHEF3 attributes: SCALUP holds only one number + // and the codes do not agree on which, so write them out explicitly. + int scale_flags = _channels.at(0)->event_layout_extra_flags(); + lhe_event.mu_r = event_in.ren_scale(); + lhe_event.mu_f1 = (scale_flags & EventRecord::f_beam1) + ? double(event_in.fact_scale1()) + : double(event_in.ren_scale()); + lhe_event.mu_f2 = (scale_flags & EventRecord::f_beam2) + ? double(event_in.fact_scale2()) + : lhe_event.mu_f1; + lhe_event.has_scales = true; lhe_event.particles.clear(); for (std::size_t i = 0; i < buffer.particle_count(); ++i) { auto particle_in = buffer.particle(event_index, i); @@ -1295,6 +1372,15 @@ void EventGenerator::fill_lhe_event( } ); } + bool has_clustering = + _channels.at(0)->particle_layout_extra_flags() & ParticleRecord::f_clustering; + lhe_event.has_cluster_scales = has_clustering; + if (has_clustering) { + for (std::size_t i = 0; i < lhe_event.particles.size(); ++i) { + lhe_event.particles.at(i).cluster_scale = + buffer.particle(event_index, i).cluster_scale(); + } + } lhe_completer.complete_event_data( lhe_event, event_in.subprocess_index(), diff --git a/madspace/src/driver/lhe_output.cpp b/madspace/src/driver/lhe_output.cpp index 869fdb227c..6a93d5fcbc 100644 --- a/madspace/src/driver/lhe_output.cpp +++ b/madspace/src/driver/lhe_output.cpp @@ -138,23 +138,73 @@ void LHEEvent::format_to(std::string& buffer) const { particle.spin ); } + if (has_cluster_scales || has_scales) { + std::format_to(insert_iter, "\n"); + } if (lo_info && lo_info->qcd_power >= 0 && lo_info->has_beam1 && lo_info->has_beam2) { + // as madevent's unwgt.f writes it: the alpha_s power left at mu_R and + // the vertex scales, then per beam n, the n pdgs, x and scales + int n_vertices = static_cast(lo_info->asrwt.size()); std::format_to( insert_iter, - "\n {} {:.8e}\n0\n" - " 1 {} {:.8e} {:.8e}\n" - " 1 {} {:.8e} {:.8e}\n" - " 1.0\n\n", - lo_info->qcd_power, + "\n {} {:.8e}\n {}", + lo_info->qcd_power - n_vertices, lo_info->ren_scale, - lo_info->pdg1, - lo_info->x1, - lo_info->fact_scale1, - lo_info->pdg2, - lo_info->x2, - lo_info->fact_scale2 + n_vertices ); + for (double scale : lo_info->asrwt) { + std::format_to(insert_iter, " {:.8e}", scale); + } + buffer += "\n"; + std::array pdg{lo_info->pdg1, lo_info->pdg2}; + std::array x{lo_info->x1, lo_info->x2}; + std::array q{lo_info->fact_scale1, lo_info->fact_scale2}; + for (std::size_t b = 0; b < 2; ++b) { + bool ladder = !lo_info->ladder_pdgs[b].empty(); + std::size_t n = ladder ? lo_info->ladder_pdgs[b].size() : 1; + std::format_to(insert_iter, " {}", b + 1, n); + for (std::size_t e = 0; e < n; ++e) { + std::format_to( + insert_iter, " {}", ladder ? lo_info->ladder_pdgs[b][e] : pdg[b] + ); + } + for (std::size_t e = 0; e < n; ++e) { + std::format_to( + insert_iter, " {:.8e}", ladder ? lo_info->ladder_x[b][e] : x[b] + ); + } + for (std::size_t e = 0; e < n; ++e) { + std::format_to( + insert_iter, " {:.8e}", ladder ? lo_info->ladder_q[b][e] : q[b] + ); + } + buffer += "\n"; + } + buffer += " 1.0\n\n"; } if (!rwgt.empty()) { buffer += "\n"; diff --git a/madspace/src/driver/systematics.cpp b/madspace/src/driver/systematics.cpp index 9fd22e05ef..8e3bf523dc 100644 --- a/madspace/src/driver/systematics.cpp +++ b/madspace/src/driver/systematics.cpp @@ -48,12 +48,22 @@ struct AlphaSKey { std::size_t alpha_s_index; double mur; int dyn; + double alps; bool operator<(const AlphaSKey& other) const { - return std::tie(alpha_s_index, mur, dyn) < - std::tie(other.alpha_s_index, other.mur, other.dyn); + return std::tie(alpha_s_index, mur, dyn, alps) < + std::tie(other.alpha_s_index, other.mur, other.dyn, other.alps); } }; +bool particle_layout_has(const DataLayout& layout, const std::string& name) { + for (auto& field : layout.particle_layout()) { + if (field.name == name) { + return true; + } + } + return false; +} + } // namespace std::string SystematicsCalculator::format_number(double value) { @@ -220,6 +230,15 @@ SystematicsCalculator::SystematicsCalculator( } } } + if (_config.pdf_ladder && nominal_pdf) { + // a ladder step carries whichever parton the beam line had there + for (int pdg : nominal_pdf->pids) { + bool parton = pdg == 21 || (pdg != 0 && std::abs(pdg) <= 5); + if (parton && std::find(pids.begin(), pids.end(), pdg) == pids.end()) { + pids.push_back(pdg); + } + } + } std::sort(pids.begin(), pids.end()); if (_config.has_pdf) { _nominal_pdf = make_pdf_evaluator(nominal_pdf.value(), pids, "nominal", 0); @@ -380,6 +399,14 @@ void SystematicsCalculator::build_variations() { ); } + // emission scale variations of an MLM merged sample (madevent's alpsfact), + // at the nominal mu_R, mu_F and PDF, as systematics.py writes them + for (double alps : _config.alps) { + if (alps != 1.) { + _variations.push_back({id++, 1., 1., -1, -1, alps}); + } + } + // PDF variations: when members of the nominal set are requested, the group // starts with the nominal member itself so that the group is complete bool has_nominal_set_members = std::any_of( @@ -413,11 +440,21 @@ std::vector SystematicsCalculator::scale_variation_indices() const return indices; } +std::vector SystematicsCalculator::emission_variation_indices() const { + std::vector indices; + for (std::size_t k = 0; k < _variations.size(); ++k) { + if (_variations.at(k).is_emission()) { + indices.push_back(k); + } + } + return indices; +} + std::vector SystematicsCalculator::pdf_groups() const { std::map groups; for (std::size_t k = 0; k < _variations.size(); ++k) { auto& var = _variations.at(k); - if (var.is_scale()) { + if (var.is_scale() || var.is_emission()) { continue; } int set_lhaid, member; @@ -643,11 +680,49 @@ SystematicsCalculator::reweight_info(EventBuffer& buffer, std::size_t event_inde .fact_scale1 = has_beam1 ? event.fact_scale1().value() : 0., .fact_scale2 = has_beam2 ? event.fact_scale2().value() : 0., }; + // the MLM alpha_s vertices and pdf ladder, as madevent books them + if (particle_layout_has(layout, "alphas_scale")) { + for (std::size_t j = 2; j < buffer.particle_count(); ++j) { + double scale = buffer.particle(event_index, j).alphas_scale(); + if (scale > 0.) { + info.asrwt.push_back(scale); + } + } + } + if (particle_layout_has(layout, "ladder_x") && has_beam1 && has_beam2) { + std::array lead_q{ + buffer.particle(event_index, 0).ladder_lead_q(), + buffer.particle(event_index, 1).ladder_lead_q() + }; + if (lead_q[0] > 0. || lead_q[1] > 0.) { + std::array pdg{info.pdg1, info.pdg2}; + std::array x{info.x1, info.x2}; + for (std::size_t b = 0; b < 2; ++b) { + info.ladder_pdgs[b].push_back(pdg[b]); + info.ladder_x[b].push_back(x[b]); + info.ladder_q[b].push_back(lead_q[b]); + } + for (std::size_t j = 0; j < buffer.particle_count(); ++j) { + auto particle = buffer.particle(event_index, j); + int step_info = static_cast(particle.ladder_info()); + if (!(step_info & 1)) { + continue; + } + int b = (step_info >> 1) & 1; + info.ladder_pdgs[b].push_back(static_cast(particle.ladder_pdg())); + info.ladder_x[b].push_back(particle.ladder_x()); + info.ladder_q[b].push_back(particle.ladder_q()); + } + } + } // beam 1 moves along +z: leg 1 of a mirrored event belongs to beam 2 if (has_beam1 && has_beam2 && buffer.particle(event_index, 0).pz() < 0.) { std::swap(info.pdg1, info.pdg2); std::swap(info.x1, info.x2); std::swap(info.fact_scale1, info.fact_scale2); + std::swap(info.ladder_pdgs[0], info.ladder_pdgs[1]); + std::swap(info.ladder_x[0], info.ladder_x[1]); + std::swap(info.ladder_q[0], info.ladder_q[1]); } return info; } @@ -668,9 +743,24 @@ void SystematicsCalculator::compute( bool has_subproc = layout_has(layout, "subprocess_index"); bool use_pdf = _config.has_pdf && _nominal_pdf && (has_beam1 || has_beam2); bool has_dyn = !_config.dyn_scales.empty(); + bool has_vertices = particle_layout_has(layout, "alphas_scale"); + bool has_ladder = particle_layout_has(layout, "ladder_x"); // per-event inputs struct EventInput { + // the scales of the MLM clustering vertices whose alpha_s is evaluated + // at their own scale rather than at mu_R (empty if none) + std::vector vertex_scales; + // the MLM pdf reweighting ladder of each beam, as madevent books it + // for its systematics (pdfrwt): the beam parton at x_beam and its + // leading scale, then for every step the line's flavour, its momentum + // fraction before the step and the scale the step takes it to + struct LadderEntry { + double x, q; + me_int_t slot; + }; + std::array, 2> ladder; + bool has_ladder = false; double w0, ren_scale, x1, x2, muf1, muf2, alpha_s, nominal_product; int subproc, qcd_power; me_int_t slot1, slot2; @@ -710,6 +800,40 @@ void SystematicsCalculator::compute( in.nominal_product = event.partial_weight_product(); } } + if (has_vertices) { + // A vertex the clustering did not reweight carries 0, as does an + // unused slot: its alpha_s is one of the mu_R powers. + for (std::size_t j = 2; j < buffer.particle_count(); ++j) { + double scale = buffer.particle(i, j).alphas_scale(); + if (scale > 0.) { + in.vertex_scales.push_back(scale); + } + } + } + if (has_ladder && use_pdf) { + std::array lead_q{ + buffer.particle(i, 0).ladder_lead_q(), buffer.particle(i, 1).ladder_lead_q() + }; + // an event of a channel without the ladder carries zeros + in.has_ladder = lead_q[0] > 0. || lead_q[1] > 0.; + if (in.has_ladder) { + in.ladder[0].push_back({in.x1, lead_q[0], in.slot1}); + in.ladder[1].push_back({in.x2, lead_q[1], in.slot2}); + } + // the steps in clustering order, which is slot order + for (std::size_t j = 0; in.has_ladder && j < buffer.particle_count(); ++j) { + auto particle = buffer.particle(i, j); + int info = static_cast(particle.ladder_info()); + if (!(info & 1)) { + continue; + } + in.ladder[(info >> 1) & 1].push_back({ + particle.ladder_x(), + particle.ladder_q(), + pid_slot(static_cast(particle.ladder_pdg())), + }); + } + } in.dyn_scale.fill(0.); if (has_dyn) { auto momenta = event_momenta(buffer, i); @@ -854,6 +978,91 @@ void SystematicsCalculator::compute( } } + // MLM pdf reweighting ladder, as madevent's systematics.py rebuilds it + // from the pdfrwt entries (q_0..q_N-1, x_0..x_N-1) of each beam: + // f(x_N-1, k S) prod_i ladder_events; + for (std::size_t i = 0; use_pdf && i < count; ++i) { + if (inputs[i].has_ladder) { + ladder_events.push_back(i); + } + } + if (!ladder_events.empty()) { + double low = _config.ladder_min_scale; + double high = _config.ladder_max_scale > 0. ? _config.ladder_max_scale : 1e30; + auto clamp = [&](double q) { return std::min(std::max(q, low), high); }; + std::array has_beam{has_beam1, has_beam2}; + auto ladder_weights = [&](const PdfEvaluator& evaluator, const Variation& var) { + std::vector x, q; + std::vector slots; + for (std::size_t i : ladder_events) { + auto& in = inputs[i]; + for (int b = 0; b < 2; ++b) { + auto& entries = in.ladder[b]; + if (!has_beam[b] || entries.empty()) { + continue; + } + double top = var.muf * + (var.dyn == -1 ? entries.back().q : in.dyn_scale[var.dyn]); + x.push_back(entries.back().x); + q.push_back(clamp(top)); + slots.push_back(entries.back().slot); + for (std::size_t e = 0; e + 1 < entries.size(); ++e) { + double scale = clamp(std::min(var.alps * entries[e].q, top)); + x.push_back(entries[e].x); + q.push_back(scale); + slots.push_back(entries[e].slot); + x.push_back(entries[e + 1].x); + q.push_back(scale); + slots.push_back(entries[e].slot); + } + } + } + auto values = evaluate_pdf(evaluator, x, q, slots); + std::vector result; + std::size_t n = 0; + for (std::size_t i : ladder_events) { + auto& in = inputs[i]; + double w = 1.; + for (int b = 0; b < 2; ++b) { + auto& entries = in.ladder[b]; + if (!has_beam[b] || entries.empty()) { + continue; + } + w *= values[n++]; + for (std::size_t e = 0; e + 1 < entries.size(); ++e) { + double numerator = values[n++]; + double denominator = values[n++]; + w = denominator >= 1e-10 ? w * numerator / denominator : 0.; + } + } + result.push_back(w); + } + return result; + }; + Variation central{0, 1., 1., -1, -1, 1.}; + auto nominal = ladder_weights(_nominal_pdf.value(), central); + for (std::size_t k = 0; k < var_count; ++k) { + auto& var = _variations.at(k); + bool varies_pdf = var.pdf_index >= 0 || var.muf != 1. || var.dyn != -1 || + var.alps != 1.; + if (!varies_pdf) { + continue; + } + auto& evaluator = var.pdf_index >= 0 ? _member_pdfs.at(var.pdf_index) + : _nominal_pdf.value(); + auto varied = ladder_weights(evaluator, var); + for (std::size_t n = 0; n < ladder_events.size(); ++n) { + r_pdf[ladder_events[n]][k] = + nominal[n] == 0. ? 0. : varied[n] / nominal[n]; + } + } + } + // alpha_s part: the distinct (alpha_s grid, mur, dyn) combinations, one // batched alpha_s call each; the matrix element is re-evaluated for the // events of the mixed-order subprocesses @@ -863,10 +1072,10 @@ void SystematicsCalculator::compute( auto& var = _variations.at(k); std::size_t alpha_s_index = var.pdf_index >= 0 ? _member_pdfs.at(var.pdf_index).alpha_s_index : 0; - if (var.mur == 1. && var.dyn == -1 && alpha_s_index == 0) { + if (var.mur == 1. && var.dyn == -1 && alpha_s_index == 0 && var.alps == 1.) { continue; } - AlphaSKey key{alpha_s_index, var.mur, var.dyn}; + AlphaSKey key{alpha_s_index, var.mur, var.dyn, var.alps}; std::size_t pos = 0; for (; pos < keys.size(); ++pos) { if (!(keys[pos] < key) && !(key < keys[pos])) { @@ -887,7 +1096,20 @@ void SystematicsCalculator::compute( me_events[inputs[i].subproc].push_back(i); } } - if (!keys.empty()) { + // alpha_s at the nominal vertex scales, with the nominal set: what the + // events were generated with + std::vector vertex_nominal; + if (has_vertices) { + std::vector q; + for (auto& in : inputs) { + q.insert(q.end(), in.vertex_scales.begin(), in.vertex_scales.end()); + } + vertex_nominal = evaluate_alpha_s(0, q); + } + bool needs_me = std::any_of(keys.begin(), keys.end(), [](auto& key) { + return key.mur != 1. || key.dyn != -1 || key.alpha_s_index != 0; + }); + if (needs_me) { for (auto& [subproc, indices] : me_events) { std::vector alpha_s(indices.size()); for (std::size_t n = 0; n < indices.size(); ++n) { @@ -902,24 +1124,64 @@ void SystematicsCalculator::compute( for (std::size_t i = 0; i < count; ++i) { q[i] = key.mur * scale_of(inputs[i], key.dyn, inputs[i].ren_scale); } - auto varied = evaluate_alpha_s(key.alpha_s_index, q); + // an emission scale variation alone leaves the mu_R powers as they are + bool varies_mur = key.mur != 1. || key.dyn != -1 || key.alpha_s_index != 0; + auto varied = varies_mur ? evaluate_alpha_s(key.alpha_s_index, q) + : std::vector(count, 0.); std::vector ratio(count, 1.); - for (std::size_t i = 0; i < count; ++i) { - auto& in = inputs[i]; - if (in.use_me || in.qcd_power == 0) { - continue; + if (varies_mur) { + for (std::size_t i = 0; i < count; ++i) { + auto& in = inputs[i]; + if (in.use_me || in.qcd_power == 0) { + continue; + } + ratio[i] = in.alpha_s == 0. + ? 0. + : std::pow(varied[i] / in.alpha_s, in.qcd_power); + } + for (auto& [subproc, indices] : me_events) { + std::vector alpha_s(indices.size()); + for (std::size_t n = 0; n < indices.size(); ++n) { + alpha_s[n] = varied[indices[n]]; + } + auto me = matrix_elements(subproc, buffer, indices, alpha_s); + auto& nominal = me_nominal.at(subproc); + for (std::size_t n = 0; n < indices.size(); ++n) { + ratio[indices[n]] = nominal[n] == 0. ? 0. : me[n] / nominal[n]; + } } - ratio[i] = in.alpha_s == 0. ? 0. : std::pow(varied[i] / in.alpha_s, in.qcd_power); } - for (auto& [subproc, indices] : me_events) { - std::vector alpha_s(indices.size()); - for (std::size_t n = 0; n < indices.size(); ++n) { - alpha_s[n] = varied[indices[n]]; + // MLM vertices, as madevent's systematics.py: the weight is + // alpha_s(mu_R)^(n - n_v) prod_v alpha_s(q_v), and a variation takes the + // n - n_v powers at k mu_R but every vertex at alpsfact q_v. The ratio + // above moved all n powers to k mu_R, so the vertices are taken back + // out of it and put in at their own scales: + // prod_v alpha'(alps q_v) / alpha(q_v) * (alpha(mu_R) / alpha'(k mu_R))^n_v + // which holds for the matrix element re-evaluation too, since that + // ratio is the same (alpha'(k mu_R) / alpha(mu_R))^n. + if (has_vertices) { + std::vector vq; + for (auto& in : inputs) { + for (double scale : in.vertex_scales) { + vq.push_back(key.alps * scale); + } } - auto me = matrix_elements(subproc, buffer, indices, alpha_s); - auto& nominal = me_nominal.at(subproc); - for (std::size_t n = 0; n < indices.size(); ++n) { - ratio[indices[n]] = nominal[n] == 0. ? 0. : me[n] / nominal[n]; + auto vertex_varied = evaluate_alpha_s(key.alpha_s_index, vq); + for (std::size_t i = 0, n = 0; i < count; ++i) { + auto& in = inputs[i]; + if (in.vertex_scales.empty()) { + continue; + } + double factor = 1.; + for (std::size_t v = 0; v < in.vertex_scales.size(); ++v, ++n) { + factor *= vertex_nominal[n] == 0. + ? 0. + : vertex_varied[n] / vertex_nominal[n]; + if (varies_mur) { + factor *= varied[i] == 0. ? 0. : in.alpha_s / varied[i]; + } + } + ratio[i] *= factor; } } for (std::size_t k : key_vars[p]) { @@ -960,9 +1222,11 @@ void SystematicsCalculator::accumulate( std::string SystematicsCalculator::initrwgt() const { std::string text; bool in_scale = false; + bool in_emission = false; int in_pdf = -1; for (auto& var : _variations) { bool is_scale = var.is_scale(); + bool is_emission = var.is_emission(); if (is_scale) { if (!in_scale) { text += "\n"; + in_emission = true; + } + } else if (in_emission) { + text += " # ALPS\n"; + in_emission = false; + } std::string tag, info; int pdf_lhaid; - if (!is_scale) { + if (is_emission) { + pdf_lhaid = _config.nominal_lhaid; + info = std::format("ALPSFACT={} ", format_number(var.alps)); + } else if (!is_scale) { // PDF group: the nominal member (pdf_index -1) belongs to the // nominal set's group int set_lhaid; @@ -1031,10 +1309,13 @@ std::string SystematicsCalculator::initrwgt() const { if (var.dyn != -1) { tag += std::format("DYN_SCALE=\"{}\" ", var.dyn); } + if (var.alps != 1.) { + tag += std::format("ALPSFACT=\"{}\" ", format_number(var.alps)); + } tag += std::format("PDF=\"{}\" ", pdf_lhaid); text += std::format(" {} \n", var.id, tag, info); } - if (in_scale || in_pdf != -1) { + if (in_scale || in_emission || in_pdf != -1) { text += "\n"; } return text; @@ -1099,6 +1380,23 @@ json SystematicsCalculator::summary() const { }; } + // emission scale envelope (MLM alpsfact) + auto emission_indices = emission_variation_indices(); + if (!emission_indices.empty()) { + json ids = json::array(); + double low = nominal_xsec, high = nominal_xsec; + for (std::size_t k : emission_indices) { + ids.push_back(_variations.at(k).id); + low = std::min(low, variation_xsecs.at(k)); + high = std::max(high, variation_xsecs.at(k)); + } + result["emission"] = { + {"weight_ids", ids}, + {"min", low}, + {"max", high}, + }; + } + // PDF uncertainty per set json pdf_sets = json::array(); for (auto& group : pdf_groups()) { @@ -1162,6 +1460,10 @@ void madspace::to_json(json& j, const SystematicsConfig& config) { j = json{ {"mur", config.mur}, {"muf", config.muf}, + {"alps", config.alps}, + {"pdf_ladder", config.pdf_ladder}, + {"ladder_min_scale", config.ladder_min_scale}, + {"ladder_max_scale", config.ladder_max_scale}, {"together", config.together}, {"dyn_scales", config.dyn_scales}, {"scale_factor", config.scale_factor}, @@ -1179,6 +1481,10 @@ void madspace::to_json(json& j, const SystematicsConfig& config) { void madspace::from_json(const json& j, SystematicsConfig& config) { config.mur = j.at("mur").get>(); config.muf = j.at("muf").get>(); + config.alps = j.value("alps", std::vector{1.}); + config.pdf_ladder = j.value("pdf_ladder", false); + config.ladder_min_scale = j.value("ladder_min_scale", 0.); + config.ladder_max_scale = j.value("ladder_max_scale", 0.); config.together = j.value("together", true); config.dyn_scales = j.value("dyn_scales", std::vector{}); config.scale_factor = j.value("scale_factor", 1.); @@ -1207,6 +1513,7 @@ void madspace::to_json(json& j, const Variation& variation) { {"mur", variation.mur}, {"muf", variation.muf}, {"dyn", variation.dyn}, + {"alps", variation.alps}, {"pdf_index", variation.pdf_index}, }; } diff --git a/madspace/src/kernels/kernels.hpp b/madspace/src/kernels/kernels.hpp index c436367728..0da4fa2f3f 100644 --- a/madspace/src/kernels/kernels.hpp +++ b/madspace/src/kernels/kernels.hpp @@ -6,6 +6,7 @@ #include "invariants.hpp" #include "kinematics.hpp" #include "math.hpp" +#include "mlm.hpp" #include "multichannel.hpp" #include "nn.hpp" #include "observables.hpp" diff --git a/madspace/src/kernels/kinematics.hpp b/madspace/src/kernels/kinematics.hpp index e2e8098a57..36ce7b5458 100644 --- a/madspace/src/kernels/kinematics.hpp +++ b/madspace/src/kernels/kinematics.hpp @@ -12,7 +12,7 @@ constexpr double INV_GEV2_TO_PB = 0.38937937217186e9; template struct FourMom { KERNELSPEC FVal& operator[](int i) { return p[i]; } - KERNELSPEC FVal& operator[](int i) const { return p[i]; } + KERNELSPEC const FVal& operator[](int i) const { return p[i]; } FVal p[4]; }; @@ -212,6 +212,16 @@ KERNELSPEC FVal esquare(FourMom p) { // between an azimuth set by atan2(q_y, q_x) and a fixed one on the exact test // q_t == 0, and turned the azimuth by an arbitrary angle, often pi/2, whenever // the two sides rounded q_t differently.) +template +KERNELSPEC FVal ldot(FourMom p1, FourMom p2) { + return p1[0] * p2[0] - p1[1] * p2[1] - p1[2] * p2[2] - p1[3] * p2[3]; +} + +template +KERNELSPEC FVal edot(FourMom p1, FourMom p2) { + return p1[1] * p2[1] + p1[2] * p2[2] + p1[3] * p2[3]; +} + template KERNELSPEC FourMom rotate(FourMom p, FourMom q) { auto qq = sqrt(max(esquare(q), FVal(1e-300))); diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp new file mode 100644 index 0000000000..9e50a6c31f --- /dev/null +++ b/madspace/src/kernels/mlm.hpp @@ -0,0 +1,1953 @@ +#pragma once + +#include "definitions.hpp" +#include "kinematics.hpp" + +namespace madspace { +namespace kernels { + +constexpr int N_EXT_MAX = 12; +constexpr double ONE_PLUS_TINY = 1.000001; +// Stand-in for a clustering measure that came out inf or NaN; still far +// above any physical scale, and small enough that SCALE_MAX * 10 is finite. +constexpr double SCALE_MAX = 1e307; +// Words per clustering transition in the compiled state machine: +// (data, next_offset, trace_data). Must match state_machine_item_size in +// madspace/phasespace/mlm_clustering.hpp. +constexpr int STATE_ITEM_SIZE = 3; +// trace_data values, mirroring TraceMode in mlm_clustering.cpp: which daughter +// of a clustering the mother's parton line continues into. +constexpr int TRACE_FIRST = 0; +constexpr int TRACE_SECOND = 1; +constexpr int TRACE_HARDER = 2; +constexpr int TRACE_BOTH = 3; +// JetScaleScheme in mlm_clustering.hpp. +constexpr int SCHEME_EMISSION = 0; +constexpr int SCHEME_PRODUCTION = 1; +// ScaleScheme in mlm_clustering.hpp. +constexpr int SCALES_CLUSTERING_MEAN = 0; +constexpr int SCALES_MADEVENT = 1; +// How the beam parton line is decided to carry on past a vertex: from each +// daughter's own flavour, or from the propagated goodjet flag of reweight.f. +constexpr int LINE_FLAVOR = 0; +constexpr int LINE_GOODJET = 1; +// ClusteringMeasure in mlm_clustering.hpp: which definition scores a +// non-resonant final-state clustering. +constexpr int MEASURE_FXFX = 0; +constexpr int MEASURE_MADEVENT = 1; +// How alpha_s is evaluated for a merged event. +constexpr int ALPHAS_NONE = 0; // one coupling at mu_R, as before +constexpr int ALPHAS_PER_VERTEX = 1; // alphas(pt_i) at each vertex, as madevent +constexpr int ALPHAS_GEOMETRIC = 2; // alphas at the geometric mean of the pt_i +// trace_data bits above the 2-bit trace mode: the flavour of the mother. +constexpr int TRACE_IS_JET_IN = 1 << 2; +constexpr int TRACE_IS_COLORED_IN = 1 << 3; +// Bits 4..11 hold the mother's pdf flavour class plus one, so that zero means +// the line is not a parton and the pdf reweighting chain stops there. The +// classes themselves are assigned in mlm_clustering.cpp; the kernel only +// carries them, since which density they stand for is not known until the +// flavour has been sampled. +constexpr int TRACE_FLAVOR_SHIFT = 4; +constexpr int TRACE_FLAVOR_MASK = 0xFF; +// More of the mother's flavour, for isjetvx and the iqjets demotion. +constexpr int TRACE_IS_OCTET_IN = 1 << 12; +constexpr int TRACE_MOTHER_IS_DAU1 = 1 << 13; +constexpr int TRACE_MOTHER_IS_DAU2 = 1 << 14; +constexpr int TRACE_ALL_COLORLESS = 1 << 15; +// The clustering joins two bare external fermions through a flavour-diagonal +// boson (g, a, Z, h), so it only exists when the two carry the same flavour. +// One state machine serves every flavour of a merged subprocess, and the union +// of its diagrams has such a vertex for some flavours only: for q q~ > q' q~' +// the q-q' t-channel gluon of q q~ > q q~. The flavour is a per-event input, +// see flavor_index and leg_flavors. +constexpr int TRACE_SAME_FLAVOR = 1 << 16; +// jet_leg_mask carries is_jet per leg in the low half and is_octet in the high +// half; n_ext_max is 12, so one word holds both. +constexpr int LEG_OCTET_SHIFT = 16; + +// mT^2 = E^2 - pz^2 (hadronic) or E^2 (lepton collider). +// based on djb_clus from Template/NLO/SubProcesses/cluster.f +template +KERNELSPEC FVal djb_clus(const FourMom& p, bool hadronic) { + auto r = hadronic ? (p[0] - p[3]) * (p[0] + p[3]) : p[0] * p[0]; + return max(r, 0.0); +} + +// kt/Durham clustering measure for two partons. +// mass1/mass2 are the tracked clustering masses kept in a separate array and updated by +// update_momenta - NOT the Lorentz-invariant masses computed from the 4-momentum. +// jet_radius is the jet-radius parameter (Fortran common /to_dj/D). +// based on dj_clus from Template/NLO/SubProcesses/cluster.f +template +KERNELSPEC FVal dj_clus( + const FourMom& p1, + const FourMom& p2, + FVal mass1, + FVal mass2, + bool hadronic, + FVal jet_radius +) { + if (!hadronic) { + // Durham e+e- measure: 2*min(E1^2, E2^2)*(1 - cos_theta), evaluated as + // 4*min(E1^2, E2^2)*sin^2(theta/2) with theta = atan2(|p1 x p2|, + // p1.p2). For a nearly collinear pair 1 - cos_theta is the difference + // of two numbers close to one and loses most of its digits. + auto p1a = sqrt(esquare(p1)); + auto p2a = sqrt(esquare(p2)); + if (p1a * p2a == 0.0) { + return 0.0; + } + auto cross_x = p1[2] * p2[3] - p1[3] * p2[2]; + auto cross_y = p1[3] * p2[1] - p1[1] * p2[3]; + auto cross_z = p1[1] * p2[2] - p1[2] * p2[1]; + auto theta = atan2( + sqrt(cross_x * cross_x + cross_y * cross_y + cross_z * cross_z), + edot(p1, p2) + ); + auto sin_half = sin(0.5 * theta); + return 4.0 * min(p1[0] * p1[0], p2[0] * p2[0]) * sin_half * sin_half; + } + + // hadronic: massless+massive pair clusters to the lighter parton's mT^2 + bool massive1 = (mass1 > 0.0); + bool massive2 = (mass2 > 0.0); + if (!massive1 && massive2) { + return djb_clus(p1, true) * ONE_PLUS_TINY; + } + if (massive1 && !massive2) { + return djb_clus(p2, true) * ONE_PLUS_TINY; + } + + // both massless or both massive: generalised kt measure in (eta, phi), + // min(pt1^2, pt2^2) * 2 * (cosh(deta) - cos(dphi)) / D^2 as in cluster.f, + // evaluated as 4 * (sinh^2(deta/2) + sin^2(dphi/2)), with dphi from + // atan2(cross, dot) and eta = asinh(pz/pt). For a nearly collinear pair + // cosh(deta) and cos(dphi) are both close to one, and their difference + // lost up to eight digits, so that scalar and SIMD math (and the GPU) gave + // different scales; log((|p| + pz)/(|p| - pz)) also cancels for a forward + // parton. + auto pt1_sq = p1[1] * p1[1] + p1[2] * p1[2]; + auto pt2_sq = p2[1] * p2[1] + p2[2] * p2[2]; + if (pt1_sq == 0.0 || pt2_sq == 0.0) { + return 0.0; + } + auto deta = asinh(p1[3] / sqrt(pt1_sq)) - asinh(p2[3] / sqrt(pt2_sq)); + auto dphi = + atan2(p1[1] * p2[2] - p1[2] * p2[1], p1[1] * p2[1] + p1[2] * p2[2]); + auto sinh_half = sinh(0.5 * deta); + auto sin_half = sin(0.5 * dphi); + auto m_max_sq = max(mass1 * mass1, mass2 * mass2); + auto result = m_max_sq + + min(pt1_sq, pt2_sq) * 4.0 * + (sinh_half * sinh_half + sin_half * sin_half) / + (jet_radius * jet_radius); + // The Fortran zeroes both negative and NaN results here ("prevent + // numerical inaccuracies"); max() alone would let a NaN from extreme + // inputs through. + if (!(result > 0.0)) { + return 0.0; + } + return result; +} + +// Clustering scale for the pair (momentum1=pi, momentum2=pj). +// mass1/mass2 are the tracked clustering masses for momentum1/momentum2. +// +// Parameters replacing Fortran globals / derived quantities: +// is_initial : momentum2 is a beam particle (Fortran: j<=2 in cluster_one_step) +// hadronic : hadronic collider (Fortran: lpp[] from run.inc) +// jet_radius : jet-radius parameter (Fortran: common /to_dj/D) +// massive_in/out1/out2 replace the Fortran get_clustering_type cl[0:2] bit-array +// resonant : replaces the iBWlist lookup in Fortran cluster_scale +// mass_in/width_in: carried for future use; not consumed in the scale formula +// +// based on cluster_scale from Template/NLO/SubProcesses/cluster.f +template +KERNELSPEC FVal compute_scale( + const FourMom& momentum1, + const FourMom& momentum2, + const FourMom& momentum_sum, + FVal mass1, + FVal mass2, + bool resonant, + bool is_initial, + bool massive_in, + bool massive_out1, + bool massive_out2, + bool hadronic, + FVal jet_radius, + int measure +) { + if (is_initial) { + // scale = mT of the final-state parton + // small penalty when it goes against the beam + if (measure == MEASURE_MADEVENT) { + // LO cluster.f multiplies pt2ij, the squared measure, by 1 + 1e-6, + // which is exactly the factor DJ gives a massless-massive + // final-state pair: the two tie, and madevent keeps the + // initial-state candidate it evaluated first. Taking the square + // root of the same product keeps the tie bit-for-bit here. + auto djb = djb_clus(momentum2, hadronic); + if ((momentum1[3] < 0.0) != (momentum2[3] < 0.0)) { + djb = djb * ONE_PLUS_TINY; + } + return sqrt(djb); + } + auto scale = sqrt(djb_clus(momentum2, hadronic)); + if ((momentum1[3] < 0.0) != (momentum2[3] < 0.0)) { + scale = scale * ONE_PLUS_TINY; + } + return scale; + } + if (resonant) { + return sqrt(max(lsquare(momentum_sum), 0.0)); + } + if (measure == MEASURE_MADEVENT) { + // DJ of Template/LO/Source/kin_functions.f, which madevent's LO + // cluster.f takes for every final-state pair: dj_clus already scores a + // massless-massive pair by the massless one's transverse mass (times + // 1 + 1e-6) and anything else by the kt measure with the larger mass + // squared added. The branches below are the NLO cluster_scale's, which + // score q* > q W and g* > g h by sqrt(|p_j.(p_i+p_j)|)/2 instead - often + // below the jet's pt, so the jet pairs with the boson where madevent + // takes it into a beam. + return sqrt(dj_clus(momentum1, momentum2, mass1, mass2, hadronic, jet_radius)); + } + if (!massive_in && massive_out1 && !massive_out2) { + return sqrt(fabs(ldot(momentum2, momentum_sum))) / 2.0; + } + if (!massive_in && !massive_out1 && massive_out2) { + return sqrt(fabs(ldot(momentum1, momentum_sum))) / 2.0; + } + if (massive_in && !massive_out1 && !massive_out2) { + return sqrt(max(lsquare(momentum_sum), 0.0)); + } + return sqrt(dj_clus(momentum1, momentum2, mass1, mass2, hadronic, jet_radius)); +} + +// Two candidate clusterings whose measures agree to this relative precision are +// taken to be equal, and settled by order rather than by rounding. +constexpr double TIE_TOLERANCE = 1e-12; + +// Whether a candidate clustering takes over from the best one found so far. +// +// A resonant candidate beats a non-resonant one, then the smaller measure wins. +// Measures that are equal in exact arithmetic are common: with two massless +// partons left in the final state they balance in transverse momentum, so +// clustering either one into the beam it travels along scores the same mT. +// Comparing the rounded values then hands the step to whichever came out an +// ulp lower, which a rotation about the beam axis can change, and which beam +// takes the parton decides where that beam's line ends and so mu_F. +// +// Settle such ties the way madevent's cluster.f does in exact arithmetic: it +// keeps the first of two equal candidates, looping over the later leg on the +// outside and its partner, beams first, on the inside. cluster.f keeps the +// surviving legs in their original order with the mother in the earlier +// slot, as the slots here are kept, so its loop order is the order of +// (particle2, particle1). That also covers the tie DJ makes between a beam +// clustering and a massless-massive final-state pair on the same parton: +// the beam comes first. +template +KERNELSPEC bool candidate_wins( + FVal scale, + int data, + bool resonant, + int best_next_state, + FVal best_scale, + int best_data, + bool best_resonant +) { + if (best_next_state == -1) { + return true; + } + if (resonant != best_resonant) { + return resonant; + } + if (scale < best_scale * (1.0 - TIE_TOLERANCE)) { + return true; + } + if (scale > best_scale * (1.0 + TIE_TOLERANCE)) { + return false; + } + int particle1 = data & 0xFF, particle2 = (data >> 8) & 0xFF; + int best_particle1 = best_data & 0xFF, best_particle2 = (best_data >> 8) & 0xFF; + if (particle2 != best_particle2) { + return particle2 < best_particle2; + } + // The same pair reached through another graph scores the same; the first + // one listed stands. + return particle1 < best_particle1; +} + +// Update momenta and the separately-tracked masses after one clustering step. +// Based on update_momenta from Template/NLO/SubProcesses/cluster.f +template +KERNELSPEC void update_momenta( + int n_part, + FourMom* momenta, + FVal* masses, + int& alive, + int i_remove, + int i_keep, + bool resonant, + bool allow_boost +) { + alive &= ~(1 << i_remove); + + if (i_keep < 2) { // initial-state clustering + int j_other = 1 - i_keep; + for (int k = 0; k < 4; ++k) { + momenta[i_keep][k] -= momenta[i_remove][k]; + } + + masses[i_keep] = (masses[i_keep] > 0.0) != (masses[i_remove] > 0.0) + ? max(masses[i_keep], masses[i_remove]) + : 0.0; + + FourMom com_boost_vector = { + momenta[i_keep][0] + momenta[j_other][0], + -(momenta[i_keep][1] + momenta[j_other][1]), + -(momenta[i_keep][2] + momenta[j_other][2]), + -(momenta[i_keep][3] + momenta[j_other][3]), + }; + // cluster.f skips the boost at the last clustering ("Don't boost if boost + // vector too lightlike or last vertex", nleft > 4), so the frame the + // 2 -> 1 root is measured in is the one left by the previous step. + if (allow_boost && lsquare(com_boost_vector) > 100.0) { + // boost j_keep to COM frame to define the rotation axis, then apply to all + // alive particles + auto jkeep_cm = boost(momenta[i_keep], com_boost_vector, 1.0); + for (int j = 0; j < n_part; ++j) { + if (alive & (1 << j)) { + momenta[j] = rotate_inverse( + boost(momenta[j], com_boost_vector, 1.0), jkeep_cm + ); + } + } + } + } else { // final-state clustering + for (int k = 0; k < 4; ++k) { + momenta[i_keep][k] += momenta[i_remove][k]; + } + + if (resonant) { + masses[i_keep] = sqrt(max(lsquare(momenta[i_keep]), 0.0)); + } else { + masses[i_keep] = max(masses[i_keep], masses[i_remove]); + } + } +} + +// One clustering history, walked from start_state: 0 is the root of the +// state machine over every diagram, anything else the root of the one compiled +// for a single diagram. Writes every output and returns the number of outgoing +// legs the history calls merging jets. +template +KERNELSPEC int mlm_clustering_walk( + FIn momenta, + FIn random, + IIn state_machine, + FIn external_masses, + FIn bw_masses, + FIn bw_widths, + FIn bw_cutoff, + FIn jet_radius, + FIn cm_energy, + IIn jet_scale_scheme, + FIn xqcut, + IIn scale_scheme, + IIn beam_flags, + IIn jet_leg_mask, + IIn parton_line_scheme, + IIn alphas_scheme, + IIn pdf_reweighting, + IIn clustering_measure, + IIn flavor_index, + IIn leg_flavors, + FOut ren_scale, + FOut fact_scale1, + FOut fact_scale2, + FOut outgoing_scales, + IOut diagram_index, + FOut xqcut_weight, + FOut alphas_scales, + FOut alphas_weight, + FOut pdf_scale1, + FOut pdf_scale2, + IOut pdf_rw_flavor, + FOut pdf_rw_x, + FOut pdf_rw_q_num, + FOut pdf_rw_q_den, + FOut pdf_rw_active, + FOut pdf_rw_beam, + FOut pdf_rw_x_prev, + int start_state, + bool hadronic +) { + // we do not support SIMD for now, so we can assume simple types + static_assert(std::is_same_v, int>); + static_assert(std::is_same_v, double>); + + int state = start_state, cluster_count = 0; + int cluster_max = momenta.size() - 3; + int n_part = momenta.size(); + FourMom momenta_tmp[N_EXT_MAX]; + // The same clustering tracked in the lab frame, without the boosts and + // rotations update_momenta applies. Only the 2 -> 1 root needs it, and only + // when the last clustering was a final-state one: cluster.f then boosts and + // rotates the remaining object back before taking the root's transverse + // mass. It undoes only the most recent boost; this copy undoes all of them, + // which is the same thing whenever at most one boosting initial-state + // clustering came before. After an initial-state last clustering cluster.f + // takes the root in the current frame instead, see last_is_initial. + FourMom momenta_lab[N_EXT_MAX]; + // Whether the last recorded clustering was an initial-state one. + bool last_is_initial = false; + FVal masses_tmp[N_EXT_MAX]; + int alive = (1 << n_part) - 1; + int cluster_history[N_EXT_MAX - 3]; + int cluster_trace[N_EXT_MAX - 3]; + FVal cluster_scales[N_EXT_MAX - 3]; + // mT of the final-state daughter of each initial-state clustering, + // zero for a final-state one. This is mt2ij in cluster.f, kept + // unsquared like every other scale here. + FVal cluster_mt[N_EXT_MAX - 3]; + // The Pythia ISR momentum fraction of each initial-state clustering, + // zcl in cluster.f, zero for a final-state one. The pdf reweighting walks + // the beam's momentum fraction down the ladder with it. + FVal cluster_z[N_EXT_MAX - 3]; + // sqrt(mT(d1) mT(d2)) of the last clustering if that one is a final-state + // clustering, zero otherwise. mt2last in cluster.f, kept unsquared like + // every other scale here: "If last clustering is FS, store also average + // transverse mass of the particles combined (for use if QCD vertex, e.g. + // tt~ or qq~)". + FVal mt_last = 0.0; + + for (int i = 0; i < n_part; ++i) { + for (int j = 0; j < 4; ++j) { + momenta_tmp[i][j] = momenta[i][j]; + momenta_lab[i][j] = momenta[i][j]; + } + masses_tmp[i] = external_masses[i]; + } + + // abs pdg id of each external leg for this event, zero where it is not + // known, which leaves every clustering touching that leg allowed. + int leg_flavor[N_EXT_MAX]; + for (int i = 0; i < n_part; ++i) { + int entry = flavor_index * n_part + i; + leg_flavor[i] = flavor_index >= 0 && entry < leg_flavors.size() + ? leg_flavors[entry] + : 0; + } + + int win_next_state = -1, win_data = 0, win_trace = TRACE_FIRST; + bool win_resonant = false; + FVal win_scale = SCALE_MAX * 10.; + // The best of the candidates the flavours of the event forbid, taken only + // when a state offers nothing else, so that the walk can always go on. + int alt_next_state = -1, alt_data = 0, alt_trace = TRACE_FIRST; + bool alt_resonant = false; + FVal alt_scale = SCALE_MAX * 10.; + while (cluster_count < cluster_max) { + int data = state_machine[state]; + int next_state = state_machine[state + 1]; + int trace_data = state_machine[state + 2]; + int particle1 = data & 0xFF; + int particle2 = (data >> 8) & 0xFF; + int mass_index = (data >> 16) & 0xFF; + bool massive_in = (data >> 24) & 1; + bool massive_out1 = (data >> 25) & 1; + bool massive_out2 = (data >> 26) & 1; + bool is_last = (data >> 30) & 1; + bool is_initial = (particle1 < 2); + + FourMom momentum_sum{ + momenta_tmp[particle1][0] + momenta_tmp[particle2][0], + momenta_tmp[particle1][1] + momenta_tmp[particle2][1], + momenta_tmp[particle1][2] + momenta_tmp[particle2][2], + momenta_tmp[particle1][3] + momenta_tmp[particle2][3], + }; + + bool resonant = false; + if (mass_index != 0) { + FVal prop_m2 = lsquare(momentum_sum); + FVal mass = bw_masses[mass_index - 1]; + FVal width = bw_widths[mass_index - 1]; + FVal half_window = FVal(bw_cutoff) * width; + FVal m_min = max(mass - half_window, 0.0); + FVal m_max = mass + half_window; + resonant = (prop_m2 >= m_min * m_min) && (prop_m2 <= m_max * m_max); + } + + FVal scale = compute_scale( + momenta_tmp[particle1], + momenta_tmp[particle2], + momentum_sum, + masses_tmp[particle1], + masses_tmp[particle2], + resonant, + is_initial, + massive_in, + massive_out1, + massive_out2, + hadronic, + jet_radius, + clustering_measure + ); + + // An exactly collinear pair - which the boost and rotation applied after + // an initial-state clustering can produce - makes the measure inf or + // NaN. Map those onto a large finite value: they then lose to any + // well-defined clustering, but a clustering is still always chosen, so + // the walk cannot fall off the end of the state machine. + if (!(scale < SCALE_MAX)) { + scale = SCALE_MAX; + } + + // The MG5 fortran code extracted the resonance structure from the integration + // channel. This is not always possible in MG7, so prefer resonant configs + // over non-resonant ones. Ties go the way cluster.f's loop order takes + // them whatever order the state machine lists the candidates in, see + // candidate_wins. + bool forbidden = (trace_data & TRACE_SAME_FLAVOR) && + leg_flavor[particle1] != 0 && leg_flavor[particle2] != 0 && + leg_flavor[particle1] != leg_flavor[particle2]; + if (forbidden) { + if (candidate_wins( + scale, data, resonant, alt_next_state, alt_scale, alt_data, + alt_resonant + )) { + alt_next_state = next_state; + alt_scale = scale; + alt_data = data; + alt_trace = trace_data; + alt_resonant = resonant; + } + } else { + if (candidate_wins( + scale, data, resonant, win_next_state, win_scale, win_data, + win_resonant + )) { + win_next_state = next_state; + win_scale = scale; + win_data = data; + win_trace = trace_data; + win_resonant = resonant; + } + } + if (is_last && win_next_state == -1) { + win_next_state = alt_next_state; + win_scale = alt_scale; + win_data = alt_data; + win_trace = alt_trace; + win_resonant = alt_resonant; + } + if (is_last) { + int p1_win = win_data & 0xFF; + int p2_win = (win_data >> 8) & 0xFF; + // Read the daughter before update_momenta boosts it away. Only an + // initial-state clustering has one, matching the iwin < 3 guard in + // cluster.f. + cluster_mt[cluster_count] = p1_win < 2 + ? sqrt(djb_clus(momenta_tmp[p2_win], hadronic)) + : FVal(0.0); + // cluster.f sets mt2last when the clustering leaves only the two + // beams and one object (nleft <= 3) and was a final-state one + // (iwin > 2), from pcl as it stands then: after any earlier + // initial-state boosts, before this clustering merges the pair. + if (cluster_count == cluster_max - 1 && p1_win >= 2) { + mt_last = sqrt(sqrt( + djb_clus(momenta_tmp[p1_win], hadronic) * + djb_clus(momenta_tmp[p2_win], hadronic) + )); + } + // zclus() of Template/LO/Source/kin_functions.f: the ratio of the + // partonic invariants before and after the emission is taken back + // out of the beam, both measured against the other beam. Taken + // here, before update_momenta, because that is where cluster.f + // takes it; the boosts it applies preserve the dot products + // anyway. + cluster_z[cluster_count] = 0.0; + if (p1_win < 2) { + int other_beam = 1 - p1_win; + FourMom sum_prev, sum_red; + for (int k = 0; k < 4; ++k) { + sum_prev[k] = + momenta_tmp[p1_win][k] + momenta_tmp[other_beam][k]; + sum_red[k] = momenta_tmp[p1_win][k] - + momenta_tmp[p2_win][k] + momenta_tmp[other_beam][k]; + } + FVal s_prev = lsquare(sum_prev); + FVal s_red = lsquare(sum_red); + // The Fortran gives up below 1 GeV^2 rather than dividing; + // a z of zero then fails the 0 < z < 1 test downstream and the + // momentum fraction is left alone. + if (s_red >= 1.0 && s_prev > 0.0) { + cluster_z[cluster_count] = s_red / s_prev; + } + } + // The mother keeps slot p1_win. A final-state clustering merges the + // two daughters; an initial-state one takes the emission back out + // of the beam, as pcl(imo) = pcl(ida1) - pcl(ida2) does in + // cluster.f. + for (int k = 0; k < 4; ++k) { + momenta_lab[p1_win][k] = p1_win < 2 + ? momenta_lab[p1_win][k] - momenta_lab[p2_win][k] + : momenta_lab[p1_win][k] + momenta_lab[p2_win][k]; + } + if (cluster_count == cluster_max - 1) { + last_is_initial = p1_win < 2; + } + update_momenta( + n_part, + momenta_tmp, + masses_tmp, + alive, + p2_win, + p1_win, + win_resonant, + cluster_count < cluster_max - 1 + ); + state = win_next_state; + cluster_history[cluster_count] = win_data; + cluster_trace[cluster_count] = win_trace; + cluster_scales[cluster_count] = win_scale; + ++cluster_count; + // Reset the whole selection, not just the scale: leaving + // win_resonant set would stop any non-resonant candidate from ever + // winning the next step, and leaving win_next_state set would send + // the walk to a stale state if that happened. + win_next_state = -1; + win_data = 0; + win_trace = TRACE_FIRST; + win_resonant = false; + win_scale = SCALE_MAX * 10.; + alt_next_state = -1; + alt_data = 0; + alt_trace = TRACE_FIRST; + alt_resonant = false; + alt_scale = SCALE_MAX * 10.; + } else { + state += STATE_ITEM_SIZE; + } + } + + // Renormalization scale: geometric mean of QCD clustering scales + // (non-QCD entries replaced by the maximum scale). + // Factorization scale: smallest QCD clustering scale. + FVal fac_scale = 1e308, max_scale = 0.0; + for (int i = 0; i < cluster_max; ++i) { + FVal scale = cluster_scales[i]; + bool is_qcd = (cluster_history[i] >> 27) & 1; + if (is_qcd && scale < fac_scale) { + fac_scale = scale; + } + if (scale > max_scale) { + max_scale = scale; + } + } + + for (int i = 0; i < n_part - 2; ++i) { + outgoing_scales[i] = 0.0; + } + + // Which external legs the parton line of each surviving slot ends at. + // madevent calls this ipart; a slot stands for two legs after a + // g -> q qbar splitting, and for none once its line has been absorbed. + int rep1[N_EXT_MAX], rep2[N_EXT_MAX]; + bool slot_is_jet[N_EXT_MAX]; + // Colour of the line currently in each slot, tracked the same way. Only + // the two beam slots are ever read back, at the 2 -> 1 root. + bool slot_is_colored[N_EXT_MAX]; + // and whether it is an octet, which decides whether an emission counts as + // a jet unconditionally: a gluon has a soft singularity the shower has to + // be left free to fill. + bool slot_is_octet[N_EXT_MAX]; + for (int i = 0; i < n_part; ++i) { + rep1[i] = i; + rep2[i] = -1; + slot_is_jet[i] = (jet_leg_mask >> i) & 1; + slot_is_colored[i] = i < 2 ? (((beam_flags >> (2 * i)) & 1) != 0) : true; + slot_is_octet[i] = (jet_leg_mask >> (i + LEG_OCTET_SHIFT)) & 1; + } + // What the iqjets bookkeeping below needs from each step but cannot + // reconstruct afterwards: which slots were still bare external legs, and + // where the mother's line ended up. Recorded here rather than walked a + // second time. + int step_bare[N_EXT_MAX - 3]; + int step_mother_leg1[N_EXT_MAX - 3], step_mother_leg2[N_EXT_MAX - 3]; + // The external legs each daughter's line ends at, as the step found them: + // (daughter 1 first, daughter 2 second, -1 for none). madevent's ptclus + // loop reads ipart of the daughters, which the slots no longer hold once + // the mother has taken them over. + int step_daughter_legs[N_EXT_MAX - 3][4]; + + bool by_production = jet_scale_scheme == SCHEME_PRODUCTION; + FVal ren_scale_val = 1.0; + // The generation-level merging cut. A clustering that emitted a jet has to + // be at or above xqcut, otherwise the matrix element is describing + // radiation the parton shower is meant to produce and the event is + // dropped. This mirrors the "Check xqcut for vertices with jet daughters + // only" block of Template/LO/SubProcesses/reweight.f: the test runs per + // clustering step, on each daughter that is still a bare final-state jet, + // and it does not ask whether the vertex was a QCD one. + // + // is_last_cluster is exactly the "still a bare external leg" bookkeeping + // the emission scheme already needs, so the two share it. + int is_last_cluster = 0b11111111'11111111'11111100; + for (int i = 0; i < cluster_max; ++i) { + FVal scale = cluster_scales[i]; + int data = cluster_history[i]; + int particle1 = data & 0xFF; + int particle2 = (data >> 8) & 0xFF; + bool is_qcd = (data >> 27) & 1; + bool is_jet1 = (data >> 28) & 1; + bool is_jet2 = (data >> 29) & 1; + step_bare[i] = is_last_cluster; + if (is_qcd) { + if (by_production) { + // Book this vertex onto every external leg the two daughters' + // lines end at, keeping the hardest vertex each leg takes part + // in. That is the scale at which the leg's line was produced. + for (int k = 0; k < 2; ++k) { + if (!(k == 0 ? is_jet1 : is_jet2)) { + continue; + } + int daughter = k == 0 ? particle1 : particle2; + for (int m = 0; m < 2; ++m) { + int leg = m == 0 ? rep1[daughter] : rep2[daughter]; + if (leg >= 2 && scale > outgoing_scales[leg - 2]) { + outgoing_scales[leg - 2] = scale; + } + } + } + } else { + // Book the vertex at which the leg itself was emitted, i.e. + // the first (softest) clustering it takes part in. + if (is_jet1 && (is_last_cluster & (1 << particle1))) { + outgoing_scales[particle1 - 2] = scale; + } + if (is_jet2 && (is_last_cluster & (1 << particle2))) { + outgoing_scales[particle2 - 2] = scale; + } + } + ren_scale_val *= scale; + } else { + ren_scale_val *= max_scale; + } + is_last_cluster &= ~((1 << particle1) | (1 << particle2)); + + step_daughter_legs[i][0] = rep1[particle1]; + step_daughter_legs[i][1] = rep2[particle1]; + step_daughter_legs[i][2] = rep1[particle2]; + step_daughter_legs[i][3] = rep2[particle2]; + + // Carry the parton line into the mother, which occupies slot + // particle1. Only needed for the production scheme, but keeping it + // unconditional costs nothing and keeps the two branches comparable. + int trace = cluster_trace[i] & 0x3; + int a1 = rep1[particle1], a2 = rep2[particle1]; + int b1 = rep1[particle2], b2 = rep2[particle2]; + if (trace == TRACE_HARDER || trace == TRACE_BOTH) { + // madevent compares the transverse momenta of the representative + // legs in the original event, not in the clustered one + bool second_harder; + if (a1 < 0) { + second_harder = true; + } else if (b1 < 0) { + second_harder = false; + } else { + FVal pt_a = momenta[a1][1] * momenta[a1][1] + + momenta[a1][2] * momenta[a1][2]; + FVal pt_b = momenta[b1][1] * momenta[b1][1] + + momenta[b1][2] * momenta[b1][2]; + second_harder = pt_b > pt_a; + } + if (trace == TRACE_HARDER) { + if (second_harder) { + rep1[particle1] = b1; + rep2[particle1] = b2; + } + } else { + // both daughters carry the line on, hardest first + rep1[particle1] = second_harder ? b1 : a1; + rep2[particle1] = second_harder ? a1 : b1; + } + } else if (trace == TRACE_SECOND) { + rep1[particle1] = b1; + rep2[particle1] = b2; + } + // TRACE_FIRST: slot particle1 already holds daughter 1's line + + // the merged line takes the mother's flavour + slot_is_jet[particle1] = (cluster_trace[i] & TRACE_IS_JET_IN) != 0; + slot_is_colored[particle1] = (cluster_trace[i] & TRACE_IS_COLORED_IN) != 0; + slot_is_octet[particle1] = (cluster_trace[i] & TRACE_IS_OCTET_IN) != 0; + step_mother_leg1[i] = rep1[particle1]; + step_mother_leg2[i] = rep2[particle1]; + } + + // Any outgoing leg that no QCD clustering booked a scale onto keeps + // sqrt(s) rather than zero, mirroring the "ptclus = etot" fallback in + // Template/LO/SubProcesses/reweight.f. This covers the legs that are not + // jets at all, and jets whose winning clustering history happened to + // contain no QCD splitting. pt_clust is what an MLM veto compares against + // qcut, so a leg reported at zero would trip a veto that a leg with no + // clustering scale must never trip. + for (int i = 0; i < n_part - 2; ++i) { + if (outgoing_scales[i] == 0.0) { + outgoing_scales[i] = cm_energy; + } + } + + if (cluster_max == 0) { + // A 2 -> 1 process has no clustering at all. cluster.f's special + // 2 -> 1 case takes pt2ijcl = pcl(4, imocl), the invariant mass squared + // of the outgoing line, and setclscales sets every scale from it. + FourMom p_out{momenta[2][0], momenta[2][1], momenta[2][2], momenta[2][3]}; + ren_scale_val = sqrt(max(lsquare(p_out), 0.0)); + } else { + ren_scale_val = pow(ren_scale_val, 1.0 / cluster_max); + } + if (fac_scale > ren_scale_val) { + fac_scale = ren_scale_val; + } + FVal fac_scale1 = fac_scale, fac_scale2 = fac_scale; + + // One pdf reweighting slot per clustering that can sit on a beam line, + // plus one per beam for the 2 -> 1 root, which sits on both. A slot that + // no step claims stays inert: its two scales are equal, so its ratio is + // one, and its weight is one whatever the density comes out as. + // Which outgoing legs the merging cut applies to. Filled by madevent's + // iqjets walk below where that scale scheme is in use; without it the only + // thing available is the leg's own flavour and the vertex's. + // The scale each clustering is reweighted at. rewgt reads pt2ijcl *after* + // setclscales has overwritten it - at the central vertex with the emitted + // object's transverse mass, and at the last one to keep it above the first + // - so the coupling does not see the raw clustering measure. Defaults to + // that measure for the scheme that has no such table. + FVal alphas_step[N_EXT_MAX - 3]; + for (int i = 0; i < cluster_max; ++i) { + alphas_step[i] = cluster_scales[i]; + } + + bool merging_jet[N_EXT_MAX]; + bool have_merging_jets = false; + for (int i = 0; i < n_part; ++i) { + merging_jet[i] = false; + } + + int pdf_slot_count = n_part - 1; + int rw_flavor[N_EXT_MAX - 1]; + FVal rw_x[N_EXT_MAX - 1]; + FVal rw_q_num[N_EXT_MAX - 1]; + FVal rw_q_den[N_EXT_MAX - 1]; + FVal rw_active[N_EXT_MAX - 1]; + // Which beam a slot sits on. Only the two root slots know that statically; + // for the rest it depends on which clustering won, so it has to travel + // with the slot. + FVal rw_beam[N_EXT_MAX - 1]; + // The beam line's momentum fraction before the step's own z, which is + // what madevent books for its systematics (s_xpdf = xnow / zcl): they + // rebuild the ladder as f(x_i, Q_i) / f(x_i+1, Q_i) from those. + FVal rw_x_prev[N_EXT_MAX - 1]; + for (int i = 0; i < pdf_slot_count; ++i) { + rw_flavor[i] = 0; + rw_x[i] = 1.0; + rw_q_num[i] = 1.0; + rw_q_den[i] = 1.0; + rw_active[i] = 0.0; + rw_beam[i] = 0.0; + rw_x_prev[i] = 1.0; + } + FVal pdf_scale_val[2] = {-1.0, -1.0}; + + // pdf_reweighting == 2 under clustering_mean: walk the beam lines as + // madevent does below, but keep only the pdf half of it - the lowered + // leading scale, the ladder and the per-beam scale it ends at, which is + // also the factorisation scale then reported. Without it clustering_mean + // evaluates both beam densities at the softest clustering scale, with no + // ladder. + bool mean_with_ladder = scale_scheme != SCALES_MADEVENT && pdf_reweighting == 2; + + if (scale_scheme == SCALES_MADEVENT || mean_with_ladder) { + // Everything the walk writes besides the densities and mu_F - mu_R, + // the alpha_s vertex scales, the jet scales and the merging-jet gating + // - is madevent's only, and stays clustering_mean's under + // mean_with_ladder: every such write below sits under this flag. + bool madevent_scales = scale_scheme == SCALES_MADEVENT; + // madevent clusters one step further than this kernel does: its loop + // runs all the way to the 2 -> 1 configuration, so it has one more + // scale than cluster_max. That last step absorbs whatever is left of + // the final state into a beam, and its scale is the transverse mass of + // that object - for Drell-Yan, the boson's mT. It is also the step + // jcentral usually lands on, which is why madevent's factorisation + // scale comes out as mT of the lepton pair rather than a jet scale. + // + // Rather than extending the state machine, take that step here: with + // the walk finished exactly one final-state slot is still alive, and + // the clustering is fully determined by which beam it goes to. + int leftover = -1; + for (int i = 2; i < n_part; ++i) { + if (alive & (1 << i)) { + leftover = i; + break; + } + } + // cluster.f's pt2ijcl(n+1) = djb(pcl(imap(3,2))): the lab frame after a + // final-state last clustering (it boosts back first), the current frame + // after an initial-state one. In t t~ + jet with the jet taken into a + // beam first, that current frame is the one where t and t~ balance, so + // the root and the top's vertex carry the same scale and mu_R == mu_F. + FVal extra_scale = leftover < 0 + ? FVal(0.0) + : sqrt(djb_clus( + last_is_initial ? momenta_tmp[leftover] : momenta_lab[leftover], + hadronic + )); + // Follow each beam's parton line through the clustering, exactly as + // setclscales does in Template/LO/SubProcesses/reweight.f. + // + // jfirst the first initial-state clustering on this beam + // jlast the last one while the line is still a jet + // jcentral the last one while the line is still coloured, which + // carries on past jlast through e.g. a W emission + // + // Beam j's line always occupies slot j here, because a clustering + // keeps the lower of the two indices, so the slot index is madevent's + // ibeam(j) without any extra bookkeeping. + int jfirst[2] = {-1, -1}, jlast[2] = {-1, -1}, jcentral[2] = {-1, -1}; + bool partonline[2], qcdline[2]; + for (int j = 0; j < 2; ++j) { + qcdline[j] = (beam_flags >> (2 * j)) & 1; + partonline[j] = qcdline[j]; + } + // goodjet(i) of reweight.f: "every clustering this line has been + // through so far was a jet one". It starts as the flavour of each + // external - QCD for a beam, jet for an outgoing leg - and can only + // ever be cleared, so a line that once passed through a non-jet vertex + // never counts as a parton line again. Reading each daughter's flavour + // instead, which is what LINE_FLAVOR does, keeps beam lines alive past + // vertices where madevent would have stopped them. + bool goodjet[N_EXT_MAX]; + for (int i = 0; i < n_part; ++i) { + goodjet[i] = i < 2 ? ((beam_flags >> (2 * i)) & 1) != 0 + : ((jet_leg_mask >> i) & 1) != 0; + } + bool use_goodjet = parton_line_scheme == LINE_GOODJET; + + // iqjets of reweight.f: which outgoing legs count as merging jets. + // This is the gate on the xqcut check, and the reason a jet emitted + // where no radiation happened is exempt from it. + // + // 1 certainly a jet: emitted while the beam line was still a + // parton line, or a gluon, which has a soft singularity the + // shower must be left free to fill + // jcode possibly a jet, resolved at the end + // 0 not a merging jet + // + // jcode counts how many times the walk has crossed between QCD and + // non-QCD vertices, so that an emission can be placed relative to those + // crossings; increasecode makes the first jet vertex after a run of + // non-jet ones count as another crossing. + int iqjets[N_EXT_MAX]; + for (int i = 0; i < n_part; ++i) { + iqjets[i] = 0; + } + int jcode = 1; + bool increasecode = false; + // isjet of the beam line itself as it stands, which is ipdgcl(ida(i)) + // in the jcode test - the daughter on the beam side, before the vertex + // turns it into the mother. + bool line_is_jet[2]; + for (int j = 0; j < 2; ++j) { + line_is_jet[j] = ((beam_flags >> (2 * j + 1)) & 1) != 0; + } + + // One step of the beam-line bookkeeping, for beam j at step i. + // emitted_leg is ipart(1, ida(3-i)), the first external leg of the + // emitted line, or -1 where there is none; is_jet_in and is_colored_in + // are the mother's flavour. + auto walk_line = [&](int j, + int i, + bool is_emitted_jet, + bool emitted_is_octet, + int emitted_leg, + bool is_jet_in, + bool is_colored_in) { + if (partonline[j]) { + if (jfirst[j] < 0) { + jfirst[j] = i; + } + jlast[j] = i; + partonline[j] = is_emitted_jet && is_jet_in; + } else { + if (jfirst[j] < 0) { + jfirst[j] = i; + } + // the beam line has stopped being a parton line, so whatever + // it becomes is not a good jet either + goodjet[j] = false; + } + if (!is_emitted_jet || !line_is_jet[j] || !is_jet_in) { + ++jcode; + increasecode = true; + } else if (increasecode) { + ++jcode; + increasecode = false; + } + if (is_emitted_jet && emitted_leg >= 2) { + iqjets[emitted_leg] = + (partonline[j] || emitted_is_octet) ? 1 : jcode; + } + line_is_jet[j] = is_jet_in; + if (qcdline[j]) { + jcentral[j] = i; + qcdline[j] = is_colored_in; + } + }; + + int steps = cluster_max + (leftover >= 0 ? 1 : 0); + // goodjet of the two daughters as each step meets them, before the + // step turns the first slot into the mother. The ptclus loop needs it. + bool step_daughter_goodjet[N_EXT_MAX - 3][2]; + for (int i = 0; i < cluster_max; ++i) { + int data = cluster_history[i]; + int particle1 = data & 0xFF; + int particle2 = (data >> 8) & 0xFF; + int trace = cluster_trace[i]; + step_daughter_goodjet[i][0] = goodjet[particle1]; + step_daughter_goodjet[i][1] = goodjet[particle2]; + bool is_jet_in = (trace & TRACE_IS_JET_IN) != 0; + if (particle1 >= 2) { + // Final-state clustering. isjetvx() of reweight.f: a QCD + // vertex, and one where a jet actually came out - which it did + // if a jet daughter sits opposite either a jet mother or a + // mother repeating the other daughter's flavour, i.e. an + // emission off a line rather than a splitting into something + // else. + bool is_qcd_vertex = (data >> 27) & 1; + bool is_jet1 = (data >> 28) & 1; + bool is_jet2 = (data >> 29) & 1; + bool mother_is_dau1 = (trace & TRACE_MOTHER_IS_DAU1) != 0; + bool mother_is_dau2 = (trace & TRACE_MOTHER_IS_DAU2) != 0; + bool is_octet_in = (trace & TRACE_IS_OCTET_IN) != 0; + bool is_jet_vertex = is_qcd_vertex && + ((is_jet1 && (is_jet_in || mother_is_dau2)) || + (is_jet2 && (is_jet_in || mother_is_dau1))); + int mother_leg1 = step_mother_leg1[i]; + int mother_leg2 = step_mother_leg2[i]; + if (!is_jet_vertex) { + // "Remove non-gluon jets that lead up to non-jet + // vertices": a quark that was counted as a jet earlier but + // whose line runs into a vertex that made no jet was not a + // merging emission after all. A vertex whose three lines + // all carry colour, or none of them do, is left alone. + if (!is_qcd_vertex && mother_leg1 >= 2) { + bool leg1_octet = + ((jet_leg_mask >> (mother_leg1 + LEG_OCTET_SHIFT)) & 1) != 0; + if (!leg1_octet) { + if ((trace & TRACE_ALL_COLORLESS) != 0) { + // a W W Z or h h h vertex: nothing to demote + } else if (mother_leg2 < 0) { + iqjets[mother_leg1] = 0; + } else if (iqjets[mother_leg1] > 0 && + mother_leg2 >= 2 && + iqjets[mother_leg2] > 0) { + // both halves of an octet's line are tagged, + // so one of them can go + iqjets[mother_leg1] = 0; + } + } else if (is_octet_in) { + iqjets[mother_leg1] = 0; + } + } + if (mother_leg2 >= 2 && !is_octet_in) { + bool leg2_octet = + ((jet_leg_mask >> (mother_leg2 + LEG_OCTET_SHIFT)) & 1) != 0; + if (!leg2_octet) { + iqjets[mother_leg2] = 0; + } + } + goodjet[particle1] = false; + continue; + } + // a jet vertex: every daughter that is still a bare external + // jet was emitted here + if (is_jet1 && particle1 >= 2 && + (step_bare[i] & (1 << particle1))) { + iqjets[particle1] = 1; + } + if (is_jet2 && particle2 >= 2 && + (step_bare[i] & (1 << particle2))) { + iqjets[particle2] = 1; + } + goodjet[particle1] = is_jet_in && goodjet[particle1] && + goodjet[particle2]; + continue; + } + bool emitted = use_goodjet ? goodjet[particle2] + : ((data >> 29) & 1) != 0; + walk_line( + particle1, + i, + emitted, + slot_is_octet[particle2], + rep1[particle2], + is_jet_in, + (trace & TRACE_IS_COLORED_IN) != 0 + ); + } + if (leftover >= 0) { + // The 2 -> 1 root clusters both beams into what is left of the + // final state, so it lies on both beam lines and reweight.f walks + // it twice, once per beam. Seen from beam j the emitted object is + // that leftover and the mother is the *other* beam's parton, which + // is what decides whether either line carries on past the root. + bool leftover_jet = + use_goodjet ? goodjet[leftover] : slot_is_jet[leftover]; + for (int j = 0; j < 2; ++j) { + // Seen from beam j the mother is the other beam's line as it + // stands at the root, not the external parton it started as - + // reweight.f reads ipdgcl(imo) with imo = idacl(n,3-i). + walk_line( + j, + cluster_max, + leftover_jet, + slot_is_octet[leftover], + rep1[leftover], + slot_is_jet[1 - j], + slot_is_colored[1 - j] + ); + } + } + // "Emissions with code 1 are always jets; now take care of possible + // jets". Once a beam line has stopped being a parton line, everything + // tagged only provisionally and before the last crossing was not a + // merging emission. + if (!partonline[0] || !partonline[1]) { + if (partonline[0] || partonline[1]) { + --jcode; + } + for (int leg = 2; leg < n_part; ++leg) { + if (iqjets[leg] > 1 && iqjets[leg] <= jcode) { + iqjets[leg] = 0; + } + } + } + if (madevent_scales) { + for (int leg = 0; leg < n_part; ++leg) { + merging_jet[leg] = leg >= 2 && iqjets[leg] > 0; + } + have_merging_jets = true; + } + for (int j = 0; j < 2; ++j) { + if (jfirst[j] < 0) { + jfirst[j] = jlast[j]; + } + } + + // The scale of each step, across the recorded history plus the extra + // 2 -> 1 one, as a mutable table because the central vertices are + // overwritten in place below just as pt2ijcl is in reweight.f. + FVal pt_step[N_EXT_MAX - 2]; + for (int i = 0; i < steps; ++i) { + pt_step[i] = i == cluster_max ? extra_scale : cluster_scales[i]; + } + + // "If last clustering is s-channel QCD (e.g. ttbar) use mt2last + // instead" (setclscales in reweight.f): when the last recorded + // clustering joined two coloured final-state lines into a coloured + // one, and both beam lines are still parton lines at the root, the + // root's transverse mass becomes that average mT of the pair. Without + // it the root keeps the mT of the whole final state, which for + // q q~ > t t~ is sqrt(shat) - on average 2.3 times mT(t). reweight.f + // also writes it onto mt2ij of the last recorded clustering, but that + // one is final-state and jcentral only ever lands on an initial-state + // clustering or the root, so the root is the only place it is read. + FVal root_mt = 0.0; + if (mt_last > 2.0 && cluster_max >= 1 && jlast[0] == cluster_max && + jlast[1] == cluster_max && + ((cluster_history[cluster_max - 1] >> 27) & 1) != 0) { + root_mt = mt_last; + } + + // "Set central scale to mT2": at an initial-state clustering the + // relevant scale is not the clustering measure but the transverse mass + // of what was emitted there. For Drell-Yan the central vertex is where + // the lepton pair attaches, which is why madevent's factorisation + // scale comes out as mT(ll) and not a jet scale. + for (int j = 0; j < 2; ++j) { + if (jcentral[j] < 0) { + continue; + } + // At the root cluster.f takes mt2ij from daughter 2, which there + // is the second beam's parton: mT of a beam parton is zero, so + // the root is only overridden through mt2last above. + FVal mt = jcentral[j] == cluster_max ? root_mt + : cluster_mt[jcentral[j]]; + if (mt > 0.0) { + pt_step[jcentral[j]] = mt; + } + } + + // "Ensure that last scales are at least as big as first scales". + // reweight.f raises the entry in pt2ijcl itself, so when jlast and + // jcentral are the same vertex - which is the usual case, both beam + // lines running QCD all the way to the root - the central scale is + // raised with it and mu_F comes out as that one scale rather than a + // geometric mean of two. Writing into pt_step keeps that. + for (int j = 0; j < 2; ++j) { + if (jlast[j] >= 0 && jfirst[j] >= 0 && + pt_step[jfirst[j]] > pt_step[jlast[j]]) { + pt_step[jlast[j]] = pt_step[jfirst[j]]; + } + } + if (madevent_scales) { + for (int i = 0; i < cluster_max; ++i) { + alphas_step[i] = pt_step[i]; + } + } + + // The jet scales madevent writes, the "Store jet info for matching" + // loop at the end of setclscales. A leg only takes a scale from a jet + // vertex its line is still a good jet at, and a line stops being one + // at the first leg that iqjets does not call a merging jet. Everything + // else is written at sqrt(s), which is what tells the shower's MLM + // matching to leave that parton alone: without this a VBF tagging + // quark that radiated a gluon kept the scale of that emission, and was + // matched and vetoed as if it were a merging jet. + // + // Only the production scheme is madevent's; the emission scheme keeps + // the booking above. + if (by_production && madevent_scales) { + FVal ptclus[N_EXT_MAX]; + for (int leg = 0; leg < n_part; ++leg) { + ptclus[leg] = 0.0; + } + auto book = [&](int leg, bool jet_vertex, bool& good, FVal scale) { + if (leg < 2) { + return; + } + if (good && iqjets[leg] == 0) { + good = false; + } + if (jet_vertex && good) { + ptclus[leg] = max(ptclus[leg], scale); + } else if (ptclus[leg] == 0.0) { + ptclus[leg] = FVal(cm_energy); + } + }; + for (int i = 0; i < cluster_max; ++i) { + int data = cluster_history[i]; + int trace = cluster_trace[i]; + bool is_qcd_vertex = (data >> 27) & 1; + bool is_jet1 = (data >> 28) & 1; + bool is_jet2 = (data >> 29) & 1; + bool is_jet_in = (trace & TRACE_IS_JET_IN) != 0; + // isjetvx: an initial-state vertex is a jet vertex when the + // emitted daughter is a jet, a final-state one as in the + // iqjets walk above. + bool jet_vertex = is_qcd_vertex && + ((data & 0xFF) < 2 + ? is_jet2 + : ((is_jet1 && + (is_jet_in || (trace & TRACE_MOTHER_IS_DAU2) != 0)) || + (is_jet2 && + (is_jet_in || (trace & TRACE_MOTHER_IS_DAU1) != 0)))); + for (int k = 0; k < 2; ++k) { + bool good = step_daughter_goodjet[i][k]; + for (int m = 0; m < 2; ++m) { + book(step_daughter_legs[i][2 * k + m], jet_vertex, good, pt_step[i]); + } + } + } + if (leftover >= 0) { + // the root is never a jet vertex (islast in isjetvx) + bool good = goodjet[leftover]; + book(rep1[leftover], false, good, 0.0); + book(rep2[leftover], false, good, 0.0); + } + for (int leg = 2; leg < n_part; ++leg) { + outgoing_scales[leg - 2] = + ptclus[leg] > 0.0 ? ptclus[leg] : FVal(cm_energy); + } + } + + FVal s_last[2], s_central[2]; + for (int j = 0; j < 2; ++j) { + s_last[j] = jlast[j] < 0 ? 0.0 : pt_step[jlast[j]]; + s_central[j] = jcentral[j] < 0 ? 0.0 : pt_step[jcentral[j]]; + } + + // mu_R: the geometric mean of the four scales. madevent writes it as + // (pt2 pt2 pt2 pt2)^(1/8) over squared scales, which is the same thing. + FVal last_scale = pt_step[steps - 1]; + // madevent's mu_R, which mu_F falls back on; it only becomes the + // event's mu_R under madevent_scales + FVal walk_ren_scale; + // Take the roots before multiplying. A degenerate clustering leaves a + // scale at SCALE_MAX, and the product of four of those overflows to + // infinity, which then reaches alpha_s as a NaN. + if (jlast[0] >= 0 && jlast[1] >= 0) { + walk_ren_scale = pow(s_last[0], 0.25) * pow(s_central[0], 0.25) * + pow(s_last[1], 0.25) * pow(s_central[1], 0.25); + } else if (jlast[0] >= 0) { + walk_ren_scale = sqrt(s_last[0]) * sqrt(s_central[0]); + } else if (jlast[1] >= 0) { + walk_ren_scale = sqrt(s_last[1]) * sqrt(s_central[1]); + } else if (jcentral[0] >= 0 && jcentral[1] >= 0) { + walk_ren_scale = sqrt(s_central[0]) * sqrt(s_central[1]); + } else if (jcentral[0] >= 0) { + walk_ren_scale = s_central[0]; + } else if (jcentral[1] >= 0) { + walk_ren_scale = s_central[1]; + } else { + walk_ren_scale = last_scale; + } + + // mu_F, one per beam. madevent stores q2fact = mu_F^2 as + // sqrt(pt2[jlast] pt2[jcentral]), i.e. mu_F is the geometric mean of + // the two scales. + fac_scale1 = jlast[0] >= 0 ? sqrt(s_last[0]) * sqrt(s_central[0]) : 0.0; + fac_scale2 = jlast[1] >= 0 ? sqrt(s_last[1]) * sqrt(s_central[1]) : 0.0; + // "We have a qcd line going through the whole event, use single scale" + if (jcentral[0] >= 0 && jcentral[0] == jcentral[1]) { + fac_scale1 = max(fac_scale1, fac_scale2); + fac_scale2 = fac_scale1; + } + + // "Take care of case when jcentral are zero". + // + // The branch that ends that chain in reweight.f, capping each beam at + // min(pt2ijcl(jfirst), q2fact) when pdfwgt is on, is deliberately not + // ported. It exists so madevent can evaluate the PDF low on the + // clustering ladder and reweight back up; the central scale is kept in + // q2bck and restored, which is why turning pdfwgt off moves madevent's + // cross section (1113 -> 1093 pb for Z+0,1,2,3j) but leaves SCALUP + // unchanged. madspace has no such ladder reweighting, so the central + // scale is what both the PDF and the LHE should see. + if (jcentral[0] < 0 && jcentral[1] < 0) { + if (!(fac_scale1 > 0.0) && !(fac_scale2 > 0.0)) { + fac_scale1 = pt_step[steps - 1]; + fac_scale2 = fac_scale1; + } + } else if (jcentral[0] < 0) { + if (jfirst[0] >= 0) { + fac_scale1 = pt_step[jfirst[0]]; + } + } else if (jcentral[1] < 0) { + if (jfirst[1] >= 0) { + fac_scale2 = pt_step[jfirst[1]]; + } + } + if (!(fac_scale1 > 0.0)) { + fac_scale1 = walk_ren_scale; + } + if (!(fac_scale2 > 0.0)) { + fac_scale2 = walk_ren_scale; + } + // and keep everything inside the range the rest of the kernel uses + if (!(walk_ren_scale > 0.0) || !(walk_ren_scale < SCALE_MAX)) { + walk_ren_scale = last_scale; + } + if (!(fac_scale1 > 0.0) || !(fac_scale1 < SCALE_MAX)) { + fac_scale1 = walk_ren_scale; + } + if (!(fac_scale2 > 0.0) || !(fac_scale2 < SCALE_MAX)) { + fac_scale2 = walk_ren_scale; + } + if (madevent_scales) { + ren_scale_val = walk_ren_scale; + } + + // The pdf half of rewgt in Template/LO/SubProcesses/reweight.f, the + // other thing madevent does to a merged event that a single density at + // the factorisation scale does not. + // + // A merged event's beam density belongs at the scale of the emission + // that took the parton out of the beam, not at the scale of the hard + // process. madevent gets there in two moves: it evaluates the density + // low on the clustering ladder, at min(pt(jfirst), mu_F), and then + // walks each beam line back up, multiplying by f(x z, Q_i) / f(x z, + // Q_i-1) at every further clustering the line takes part in, with the + // momentum fraction rescaled by that clustering's z as it goes. The + // last step lands on mu_F itself, so the chain ends where the density + // would have been evaluated in the first place - except when the line + // has only one clustering on it, where the lowered scale stands with + // nothing to correct it. That asymmetry is madevent's, and it is a + // good part of why turning pdfwgt off moves the cross section at all. + // + // Only the scales, the momentum fractions and the flavour classes are + // decided here. Which density a class stands for is not knowable yet: + // the flavour is sampled from the very densities this is correcting. + bool reweight_pdf = + pdf_reweighting != 0 && jcentral[0] >= 0 && jcentral[1] >= 0; + pdf_scale_val[0] = fac_scale1; + pdf_scale_val[1] = fac_scale2; + if (reweight_pdf) { + FVal q_central[2] = {fac_scale1, fac_scale2}; + for (int j = 0; j < 2; ++j) { + if (jlast[j] >= 0 && jfirst[j] >= 0 && jfirst[j] <= jlast[j]) { + pdf_scale_val[j] = min(pt_step[jfirst[j]], q_central[j]); + } + } + // Where each beam's line has got to: its flavour class, the + // product of the z it has picked up, and the scale its density + // was last evaluated at. reweight.f keeps these as ibeam(j), + // xnow(j) and pt2pdf(ibeam(j)). + int line_class[2]; + FVal x_frac[2] = {1.0, 1.0}; + FVal pt_pdf[2] = {0.0, 0.0}; + for (int j = 0; j < 2; ++j) { + line_class[j] = + ((beam_flags >> (8 * (j + 1))) & TRACE_FLAVOR_MASK) - 1; + } + for (int i = 0; i < steps; ++i) { + for (int j = 0; j < 2; ++j) { + // A recorded clustering sits on the beam whose slot is its + // first daughter, and on no beam at all if that daughter is + // a final-state one. The root sits on both. + bool at_root = i >= cluster_max; + if (!at_root && (cluster_history[i] & 0xFF) != j) { + continue; + } + // Once a line stops being a parton reweight.f stops + // advancing ibeam(j), so no later clustering can match it + // again and the chain is over for good. + if (line_class[j] < 0) { + continue; + } + int slot = at_root ? cluster_max + j : i; + // zcl is 1 at the root, so the momentum fraction is only + // ever rescaled by the recorded clusterings. + FVal z = at_root ? FVal(1.0) : cluster_z[i]; + FVal x_prev = x_frac[j]; + if (z > 0.0 && z < 1.0) { + x_frac[j] *= z; + } + FVal q_now = i == jlast[j] ? q_central[j] + : min(pt_step[i], q_central[j]); + if (!(pt_pdf[j] > 0.0)) { + // the first clustering on the line only records where + // the density it already has was evaluated + pt_pdf[j] = q_now; + } else if (pt_pdf[j] < q_now && i <= jlast[j]) { + rw_flavor[slot] = line_class[j]; + rw_x[slot] = x_frac[j]; + rw_q_num[slot] = q_now; + rw_q_den[slot] = pt_pdf[j]; + rw_active[slot] = 1.0; + rw_beam[slot] = j; + rw_x_prev[slot] = x_prev; + pt_pdf[j] = q_now; + } + // and the line becomes the mother; at the root that is the + // s-channel object, which ends the chain. + line_class[j] = at_root + ? -1 + : ((cluster_trace[i] >> TRACE_FLAVOR_SHIFT) & + TRACE_FLAVOR_MASK) - + 1; + } + } + } + } + + // The merging cut. A matrix-element jet below xqcut is radiation the parton + // shower is meant to produce, so the event is dropped - but only for a leg + // that is a merging jet in the first place. madevent decides that with + // iqjets, which is only ever set for a leg emitted at a jet vertex; a leg + // it leaves at zero is exempt however soft the vertex it sits on. Without + // that gate a jet is rejected at vertices that produced no radiation at + // all, most visibly a quark pairing into a W, whose measure is a + // lepton-side kt with nothing to do with the jet's own transverse + // momentum. + bool passes_xqcut = true; + if (FVal(xqcut) > 0.0) { + for (int i = 0; i < cluster_max; ++i) { + if (!(cluster_scales[i] < FVal(xqcut))) { + continue; + } + int data = cluster_history[i]; + int particle1 = data & 0xFF; + int particle2 = (data >> 8) & 0xFF; + bool is_qcd = (data >> 27) & 1; + for (int k = 0; k < 2; ++k) { + int slot = k == 0 ? particle1 : particle2; + if (slot < 2 || !(step_bare[i] & (1 << slot))) { + continue; + } + bool counts = have_merging_jets + ? merging_jet[slot] + : (is_qcd && ((data >> (28 + k)) & 1) != 0); + if (counts) { + passes_xqcut = false; + } + } + } + } + + ren_scale = ren_scale_val; + fact_scale1 = fac_scale1; + fact_scale2 = fac_scale2; + // A weight rather than a flag, so that it can simply multiply the event + // weight the way every other cut in madspace does. + // Scale of each clustering vertex for the alpha_s reweighting, following + // the loop in Template/LO/SubProcesses/reweight.f: every clustering except + // the last one is reweighted by alphas(pt_clust) / alphas(mu_R), and only + // where a parton is produced. A vertex that is not reweighted is handed + // mu_R itself, so its ratio is exactly one and the consumer needs no mask. + // + // madevent gates this on goodjet / ispartonvx; the QCD flag of the vertex + // is the same statement for every case that arises here - all three lines + // coloured - and is what the rest of this kernel already uses. + // + // reweight.f drops an event outright when a reweighted vertex sits at or + // below 2 GeV, where the coupling is not to be trusted; alphas_weight is + // that veto. + // + // ALPHAS_GEOMETRIC hands every reweighted vertex the same scale, the + // geometric mean of the individual ones, so the product below turns into + // alphas()^n. That is the cheaper approximation of the same idea: one + // coupling for the whole ladder rather than one per rung. + bool alphas_ok = true; + FVal log_sum = 0.0; + int reweighted = 0; + for (int i = 0; i < cluster_max; ++i) { + if ((cluster_history[i] >> 27) & 1) { + FVal scale = alphas_step[i]; + if (!(scale * scale > 4.0)) { + alphas_ok = false; + } else { + log_sum += log(scale); + ++reweighted; + } + } + } + FVal mean_scale = + reweighted > 0 ? exp(log_sum / reweighted) : ren_scale_val; + // A vertex left at mu_R is handed 0 rather than mu_R itself: a reweighted + // vertex can sit exactly at mu_R (one clustering under clustering_mean), and + // the systematics have to tell the two apart, since madevent varies a + // reweighted vertex with alpsfact and the others with mu_R. + for (int i = 0; i < cluster_max; ++i) { + bool is_qcd_vertex = (cluster_history[i] >> 27) & 1; + if (alphas_scheme == ALPHAS_NONE || !is_qcd_vertex) { + alphas_scales[i] = 0.0; + } else if (alphas_scheme == ALPHAS_GEOMETRIC) { + alphas_scales[i] = mean_scale; + } else { + alphas_scales[i] = alphas_step[i]; + } + } + + // Kept apart from xqcut_weight: that one is the merging cut and nothing + // else, and only the reweighting cares where the coupling stops being + // usable. + alphas_weight = (alphas_scheme == ALPHAS_NONE || alphas_ok) ? 1.0 : 0.0; + xqcut_weight = passes_xqcut ? 1.0 : 0.0; + + // Any scale scheme other than madevent's has no clustering ladder to walk, + // so the density stays where the factorisation scale put it. + if (!(pdf_scale_val[0] > 0.0)) { + pdf_scale_val[0] = fac_scale1; + } + if (!(pdf_scale_val[1] > 0.0)) { + pdf_scale_val[1] = fac_scale2; + } + pdf_scale1 = pdf_scale_val[0]; + pdf_scale2 = pdf_scale_val[1]; + for (int i = 0; i < pdf_slot_count; ++i) { + if (rw_active[i] == 0.0) { + // Somewhere the density is defined, so that an inert slot cannot + // trip the low-density veto the consumer applies. + rw_q_num[i] = pdf_scale_val[0]; + rw_q_den[i] = pdf_scale_val[0]; + } + pdf_rw_flavor[i] = rw_flavor[i]; + pdf_rw_x[i] = rw_x[i]; + pdf_rw_q_num[i] = rw_q_num[i]; + pdf_rw_q_den[i] = rw_q_den[i]; + pdf_rw_active[i] = rw_active[i]; + pdf_rw_beam[i] = rw_beam[i]; + pdf_rw_x_prev[i] = rw_x_prev[i]; + } + + int diag_count = state_machine[state]; + int rand_index = static_cast(FVal(random) * diag_count); + if (rand_index >= diag_count) { + rand_index = diag_count - 1; + } + diagram_index = state_machine[state + rand_index + 1]; + + // How many outgoing legs this history calls merging jets: iqjets where the + // madevent scale scheme has filled it, a jet emitted at a QCD vertex + // otherwise - the same test the merging cut above falls back to. This is + // njets of setclscales, which HISTORY_MADEVENT compares between histories. + int merging_jet_count = 0; + if (have_merging_jets) { + for (int leg = 2; leg < n_part; ++leg) { + if (merging_jet[leg]) { + ++merging_jet_count; + } + } + } else { + int emitted = 0; + for (int i = 0; i < cluster_max; ++i) { + int data = cluster_history[i]; + if (!((data >> 27) & 1)) { + continue; + } + for (int k = 0; k < 2; ++k) { + int slot = k == 0 ? (data & 0xFF) : ((data >> 8) & 0xFF); + if (slot >= 2 && (step_bare[i] & (1 << slot)) && + ((data >> (28 + k)) & 1) != 0) { + emitted |= 1 << slot; + } + } + } + for (int leg = 2; leg < n_part; ++leg) { + if (emitted & (1 << leg)) { + ++merging_jet_count; + } + } + } + return merging_jet_count; +} + +// ClusteringHistory in mlm_clustering.hpp. +constexpr int HISTORY_ALL_DIAGRAMS = 0; +constexpr int HISTORY_DIAGRAM = 1; +constexpr int HISTORY_MADEVENT = 2; + +template +KERNELSPEC void mlm_clustering( + FIn momenta, + FIn random, + IIn start_state, + IIn state_machine, + FIn external_masses, + FIn bw_masses, + FIn bw_widths, + FIn bw_cutoff, + FIn jet_radius, + FIn cm_energy, + IIn jet_scale_scheme, + FIn xqcut, + IIn scale_scheme, + IIn beam_flags, + IIn jet_leg_mask, + IIn parton_line_scheme, + IIn alphas_scheme, + IIn pdf_reweighting, + IIn clustering_measure, + IIn history_mode, + IIn flavor_index, + IIn leg_flavors, + FOut ren_scale, + FOut fact_scale1, + FOut fact_scale2, + FOut outgoing_scales, + IOut diagram_index, + FOut xqcut_weight, + FOut alphas_scales, + FOut alphas_weight, + FOut pdf_scale1, + FOut pdf_scale2, + IOut pdf_rw_flavor, + FOut pdf_rw_x, + FOut pdf_rw_q_num, + FOut pdf_rw_q_den, + FOut pdf_rw_active, + FOut pdf_rw_beam, + FOut pdf_rw_x_prev, + bool hadronic +) { + auto walk = [&](int start) { + return mlm_clustering_walk( + momenta, + random, + state_machine, + external_masses, + bw_masses, + bw_widths, + bw_cutoff, + jet_radius, + cm_energy, + jet_scale_scheme, + xqcut, + scale_scheme, + beam_flags, + jet_leg_mask, + parton_line_scheme, + alphas_scheme, + pdf_reweighting, + clustering_measure, + flavor_index, + leg_flavors, + ren_scale, + fact_scale1, + fact_scale2, + outgoing_scales, + diagram_index, + xqcut_weight, + alphas_scales, + alphas_weight, + pdf_scale1, + pdf_scale2, + pdf_rw_flavor, + pdf_rw_x, + pdf_rw_q_num, + pdf_rw_q_den, + pdf_rw_active, + pdf_rw_beam, + pdf_rw_x_prev, + start, + hadronic + ); + }; + int start = start_state; + if (history_mode == HISTORY_MADEVENT && start != 0) { + // setclscales clusters over every graph first and keeps that history + // unless its jet count differs from the one stored for the channel, + // which was itself clustered along the channel's graph. Every walk + // writes all outputs, so the one that ran last is the one that stands: + // the channel's walk is kept aside and put back when it wins, rather + // than walked a second time. + int diagram_jets = walk(start); + int n_part = momenta.size(); + FVal kept_ren = ren_scale, kept_fact1 = fact_scale1, + kept_fact2 = fact_scale2, kept_xqcut = xqcut_weight, + kept_alphas = alphas_weight, kept_pdf1 = pdf_scale1, + kept_pdf2 = pdf_scale2; + IVal kept_diagram = diagram_index; + FVal kept_outgoing[N_EXT_MAX], kept_alphas_scales[N_EXT_MAX]; + IVal kept_rw_flavor[N_EXT_MAX]; + FVal kept_rw_x[N_EXT_MAX], kept_rw_q_num[N_EXT_MAX], + kept_rw_q_den[N_EXT_MAX], kept_rw_active[N_EXT_MAX], + kept_rw_beam[N_EXT_MAX], kept_rw_x_prev[N_EXT_MAX]; + for (int i = 0; i < n_part - 2; ++i) { + kept_outgoing[i] = outgoing_scales[i]; + } + for (int i = 0; i < n_part - 3; ++i) { + kept_alphas_scales[i] = alphas_scales[i]; + } + for (int i = 0; i < n_part - 1; ++i) { + kept_rw_flavor[i] = pdf_rw_flavor[i]; + kept_rw_x[i] = pdf_rw_x[i]; + kept_rw_q_num[i] = pdf_rw_q_num[i]; + kept_rw_q_den[i] = pdf_rw_q_den[i]; + kept_rw_active[i] = pdf_rw_active[i]; + kept_rw_beam[i] = pdf_rw_beam[i]; + } + for (int i = 0; i < n_part - 1; ++i) { + kept_rw_x_prev[i] = pdf_rw_x_prev[i]; + } + if (walk(0) != diagram_jets) { + ren_scale = kept_ren; + fact_scale1 = kept_fact1; + fact_scale2 = kept_fact2; + xqcut_weight = kept_xqcut; + alphas_weight = kept_alphas; + pdf_scale1 = kept_pdf1; + pdf_scale2 = kept_pdf2; + diagram_index = kept_diagram; + for (int i = 0; i < n_part - 2; ++i) { + outgoing_scales[i] = kept_outgoing[i]; + } + for (int i = 0; i < n_part - 3; ++i) { + alphas_scales[i] = kept_alphas_scales[i]; + } + for (int i = 0; i < n_part - 1; ++i) { + pdf_rw_flavor[i] = kept_rw_flavor[i]; + pdf_rw_x[i] = kept_rw_x[i]; + pdf_rw_q_num[i] = kept_rw_q_num[i]; + pdf_rw_q_den[i] = kept_rw_q_den[i]; + pdf_rw_active[i] = kept_rw_active[i]; + pdf_rw_beam[i] = kept_rw_beam[i]; + } + for (int i = 0; i < n_part - 1; ++i) { + pdf_rw_x_prev[i] = kept_rw_x_prev[i]; + } + } + } else { + walk(start); + } +} + +template +KERNELSPEC void kernel_mlm_clustering_hadronic( + FIn momenta, + FIn random, + IIn start_state, + IIn state_machine, + FIn external_masses, + FIn bw_masses, + FIn bw_widths, + FIn bw_cutoff, + FIn jet_radius, + FIn cm_energy, + IIn jet_scale_scheme, + FIn xqcut, + IIn scale_scheme, + IIn beam_flags, + IIn jet_leg_mask, + IIn parton_line_scheme, + IIn alphas_scheme, + IIn pdf_reweighting, + IIn clustering_measure, + IIn history_mode, + IIn flavor_index, + IIn leg_flavors, + FOut ren_scale, + FOut fact_scale1, + FOut fact_scale2, + FOut outgoing_scales, + IOut diagram_index, + FOut xqcut_weight, + FOut alphas_scales, + FOut alphas_weight, + FOut pdf_scale1, + FOut pdf_scale2, + IOut pdf_rw_flavor, + FOut pdf_rw_x, + FOut pdf_rw_q_num, + FOut pdf_rw_q_den, + FOut pdf_rw_active, + FOut pdf_rw_beam, + FOut pdf_rw_x_prev +) { + mlm_clustering( + momenta, + random, + start_state, + state_machine, + external_masses, + bw_masses, + bw_widths, + bw_cutoff, + jet_radius, + cm_energy, + jet_scale_scheme, + xqcut, + scale_scheme, + beam_flags, + jet_leg_mask, + parton_line_scheme, + alphas_scheme, + pdf_reweighting, + clustering_measure, + history_mode, + flavor_index, + leg_flavors, + ren_scale, + fact_scale1, + fact_scale2, + outgoing_scales, + diagram_index, + xqcut_weight, + alphas_scales, + alphas_weight, + pdf_scale1, + pdf_scale2, + pdf_rw_flavor, + pdf_rw_x, + pdf_rw_q_num, + pdf_rw_q_den, + pdf_rw_active, + pdf_rw_beam, + pdf_rw_x_prev, + true + ); +} + +template +KERNELSPEC void kernel_mlm_clustering_leptonic( + FIn momenta, + FIn random, + IIn start_state, + IIn state_machine, + FIn external_masses, + FIn bw_masses, + FIn bw_widths, + FIn bw_cutoff, + FIn jet_radius, + FIn cm_energy, + IIn jet_scale_scheme, + FIn xqcut, + IIn scale_scheme, + IIn beam_flags, + IIn jet_leg_mask, + IIn parton_line_scheme, + IIn alphas_scheme, + IIn pdf_reweighting, + IIn clustering_measure, + IIn history_mode, + IIn flavor_index, + IIn leg_flavors, + FOut ren_scale, + FOut fact_scale1, + FOut fact_scale2, + FOut outgoing_scales, + IOut diagram_index, + FOut xqcut_weight, + FOut alphas_scales, + FOut alphas_weight, + FOut pdf_scale1, + FOut pdf_scale2, + IOut pdf_rw_flavor, + FOut pdf_rw_x, + FOut pdf_rw_q_num, + FOut pdf_rw_q_den, + FOut pdf_rw_active, + FOut pdf_rw_beam, + FOut pdf_rw_x_prev +) { + mlm_clustering( + momenta, + random, + start_state, + state_machine, + external_masses, + bw_masses, + bw_widths, + bw_cutoff, + jet_radius, + cm_energy, + jet_scale_scheme, + xqcut, + scale_scheme, + beam_flags, + jet_leg_mask, + parton_line_scheme, + alphas_scheme, + pdf_reweighting, + clustering_measure, + history_mode, + flavor_index, + leg_flavors, + ren_scale, + fact_scale1, + fact_scale2, + outgoing_scales, + diagram_index, + xqcut_weight, + alphas_scales, + alphas_weight, + pdf_scale1, + pdf_scale2, + pdf_rw_flavor, + pdf_rw_x, + pdf_rw_q_num, + pdf_rw_q_den, + pdf_rw_active, + pdf_rw_beam, + pdf_rw_x_prev, + false + ); +} + +} // namespace kernels +} // namespace madspace diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index 2fb684ff34..5533fc48f1 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -31,6 +31,51 @@ std::size_t final_channel_count( } } +// Whether the event carries the product of its beam densities for the +// systematics to divide the PDF variations by. Not under MLM pdf reweighting: +// there the densities are taken low on the clustering ladder (pdf_scale) and +// walked back up to the factorisation scale by the ladder ratios, so the +// variations, which are evaluated at (k times) the factorisation scale, would +// be divided by densities at another scale, and even the central member would +// differ from one. Without it the systematics take the nominal densities at +// the factorisation scale themselves, and a variation becomes the ratio of +// the leading densities there, the ladder ratios left at their nominal value. +// Whether the event carries the scales of its MLM alpha_s vertices, for the +// systematics to vary them the way madevent's do: the same condition under +// which mlm_weights applies them. A 2 -> 1 process has no vertex at all. +bool emits_alphas_scales( + const std::optional& energy_scale, + const std::optional& running_coupling, + std::size_t particle_count +) { + return energy_scale && energy_scale->mlm_alphas_reweighting() && + running_coupling && particle_count > 3; +} + +// Whether the event carries the MLM pdf reweighting ladder, for the +// systematics to vary it as madevent's do: every clustering scale with +// alpsfact, mu_F with the factorisation scale, and the whole ladder with each +// PDF member. +bool emits_pdf_ladder( + const std::optional& energy_scale, bool has_pdf, bool has_pid_options +) { + return energy_scale && energy_scale->mlm_pdf_reweighting() && has_pdf && + has_pid_options; +} + +// the per-slot ladder outputs (one entry per slot that can carry a ratio, +// slot 0 never does) and the two per-beam ones +const std::array ladder_slot_names{ + "ladder_x", "ladder_pdg", "ladder_q", "ladder_info" +}; +const std::array ladder_beam_names{"ladder_lead_q"}; + +bool emits_partial_weights( + bool partial_weights, const std::optional& energy_scale +) { + return partial_weights && !(energy_scale && energy_scale->mlm_pdf_reweighting()); +} + } // namespace static const BatchSize acc_batch_size("acc_batch_size"); @@ -65,7 +110,8 @@ Integrand::Integrand( const std::vector& flavor_per_subproc_remap, std::size_t compressed_channel_weight_count, const std::optional& custom_bias, - const std::optional& pdf_grid2 + const std::optional& pdf_grid2, + int mlm_history_qcd_power ) : FunctionGenerator( "Integrand", @@ -139,17 +185,49 @@ Integrand::Integrand( } ret_types.push_back("ren_scale", batch_float); ret_types.push_back("alpha_qcd", batch_float); - if (partial_weights) { - if (diff_xs_first.has_pdf(0)) { - ret_types.push_back("x1", batch_float); - ret_types.push_back("fact_scale1", batch_float); - } - if (diff_xs_first.has_pdf(1)) { - ret_types.push_back("x2", batch_float); - ret_types.push_back("fact_scale2", batch_float); + // The per-beam factorisation scales ride along with x1/x2 in + // the event record. They are worth keeping for any hadronic + // run, not just a reweighted one: a dynamical scale choice can + // give the two beams different scales, and without them the + // LHE writer has nothing to report but mu_R. + if (diff_xs_first.has_pdf(0)) { + ret_types.push_back("x1", batch_float); + ret_types.push_back("fact_scale1", batch_float); + } + if (diff_xs_first.has_pdf(1)) { + ret_types.push_back("x2", batch_float); + ret_types.push_back("fact_scale2", batch_float); + } + if (emits_partial_weights(partial_weights, energy_scale) && + (diff_xs_first.has_pdf(0) || diff_xs_first.has_pdf(1))) { + ret_types.push_back("partial_weight_product", batch_float); + } + if (energy_scale && energy_scale->is_mlm()) { + ret_types.push_back( + "cluster_scales", + batch_float_array(mapping.particle_count() - 2) + ); + } + if (emits_alphas_scales( + energy_scale, running_coupling, mapping.particle_count() + )) { + ret_types.push_back( + "alphas_scales", + batch_float_array(mapping.particle_count() - 3) + ); + } + if (emits_pdf_ladder( + energy_scale, + !std::holds_alternative(pdf_grid), + flav_count > 0 + )) { + for (auto name : ladder_slot_names) { + ret_types.push_back( + name, batch_float_array(mapping.particle_count() - 2) + ); } - if (diff_xs_first.has_pdf(0) || diff_xs_first.has_pdf(1)) { - ret_types.push_back("partial_weight_product", batch_float); + for (auto name : ladder_beam_names) { + ret_types.push_back(name, batch_float_array(2)); } } // before "random": the outputs after it feed the discrete histograms @@ -187,7 +265,7 @@ Integrand::Integrand( _compressed_channel_weight_count(compressed_channel_weight_count), _madnis_training(madnis_training), _drop_cuts_and_rescale(drop_cuts_and_rescale), - _partial_weights(partial_weights), + _partial_weights(emits_partial_weights(partial_weights, energy_scale)), _channel_indices(channel_indices.begin(), channel_indices.end()), _random_dim( mapping.random_dim() + // phasespace @@ -208,6 +286,20 @@ Integrand::Integrand( flavor_per_subproc_remap.begin(), flavor_per_subproc_remap.end() ), _custom_bias(custom_bias) { + _mlm_history_qcd_power = mlm_history_qcd_power; + if (energy_scale && energy_scale->mlm_flavor_dependent() && + pid_options.size() > 1) { + for (auto& option : pid_options) { + if (option.size() != pid_options.at(0).size()) { + throw std::invalid_argument( + "every flavour option needs the same number of external legs" + ); + } + for (me_int_t pid : option) { + _mlm_leg_flavors.push_back(std::abs(pid)); + } + } + } if (!std::holds_alternative(pdf_grid)) { for (std::size_t i = 0; i < 2; ++i) { std::set pids; @@ -251,6 +343,54 @@ Integrand::Integrand( } } } + if (energy_scale && energy_scale->mlm_pdf_reweighting() && + !std::holds_alternative(pdf_grid)) { + // One density serves both beams: the ladder's ratios are taken with + // the same grid whichever beam the leg comes from. + auto grid = std::get_if(&pdf_grid); + if (!grid || pdf_grid2) { + throw std::invalid_argument( + "MLM pdf reweighting needs the same PDF grid for both beams" + ); + } + // Everything the reweighting can ask for: the gluon, whatever the + // beams can be, and the flavours the clustering pinned down along + // the way. The kernel names these by class, not by pdg, because + // one clustering serves every flavour channel; the table turns + // (class, sampled option) into an index into this one density. + auto& absolute = energy_scale->mlm_pdf_absolute_pdgs(); + std::vector rw_pids{21}; + auto index_of = [&](int pid) { + auto found = std::find(rw_pids.begin(), rw_pids.end(), pid); + if (found == rw_pids.end()) { + rw_pids.push_back(pid); + return static_cast(rw_pids.size() - 1); + } + return static_cast(found - rw_pids.begin()); + }; + std::size_t class_count = 3 + absolute.size(); + std::size_t option_count = std::max(pid_options.size(), 1); + _pdf_rw_table.resize(class_count * option_count); + for (std::size_t f = 0; f < option_count; ++f) { + _pdf_rw_table.at(0 * option_count + f) = index_of(21); + for (std::size_t beam = 0; beam < 2; ++beam) { + _pdf_rw_table.at((1 + beam) * option_count + f) = index_of( + pid_options.size() > 0 ? pid_options.at(f).at(beam) : 21 + ); + } + for (std::size_t c = 0; c < absolute.size(); ++c) { + _pdf_rw_table.at((3 + c) * option_count + f) = + index_of(absolute.at(c)); + } + } + for (std::size_t c = 0; c < class_count; ++c) { + _pdf_rw_class_offsets.push_back( + static_cast(c * option_count) + ); + } + _pdf_rw = PartonDensity(*grid, rw_pids, true); + _pdf_rw_pdgs.assign(rw_pids.begin(), rw_pids.end()); + } if (custom_bias) { if (custom_bias->type() != CustomFunction::bias) { throw std::invalid_argument("custom bias must be of type bias"); @@ -262,6 +402,26 @@ Integrand::Integrand( } } + if (energy_scale && energy_scale->mlm_history_per_diagram()) { + // The diagram is picked from the matrix element's own diagram weights, + // and those only line up with the clustering's diagram numbering for + // a single matrix element. + if (diff_xs.size() != 1) { + throw std::invalid_argument( + "an MLM clustering history per diagram needs a single matrix element" + ); + } + auto& starts = energy_scale->mlm_diagram_start_states(); + std::size_t diagram_count = diff_xs.at(0).matrix_element().diagram_count(); + std::vector mask(diagram_count, 0.); + _mlm_start_states.assign(diagram_count, 0); + for (std::size_t i = 0; i < std::min(diagram_count, starts.size()); ++i) { + mask.at(i) = starts.at(i) != 0 ? 1. : 0.; + _mlm_start_states.at(i) = starts.at(i); + } + _mlm_diagram_mask.push_back(mask); + } + if (active_flavors.size() > 0) { if (active_flavors.size() != mapping.channel_count()) { throw std::invalid_argument( @@ -295,6 +455,15 @@ Integrand::Integrand( "mirrored flavors need a PhaseSpaceMapping built with mirror_beams = true" ); } + // evaluate_pdfs, which takes the densities again once the history is + // picked per diagram, knows nothing of the event's orientation, so it + // cannot pick a mirrored event's density from the other beam. + if (_has_mirror && (_pdfs_swapped.at(0) || _pdfs_swapped.at(1)) && energy_scale && + energy_scale->mlm_history_per_diagram()) { + throw std::invalid_argument( + "an MLM clustering history per diagram needs the same PDF for both beams" + ); + } _channel_part_ret_types = compute_channel_part_ret_types(); } @@ -376,9 +545,31 @@ NamedVector Integrand::compute_channel_part_ret_types() const { } } + if (_energy_scale && _energy_scale->is_mlm()) { + ret.push_back("cluster_scales_acc", acc_float_array(particle_count - 2)); + } + if (emits_alphas_scales(_energy_scale, _running_coupling, particle_count)) { + ret.push_back("alphas_scales_acc", acc_float_array(particle_count - 3)); + } + if (_pdf_rw && _pid_options.size() > 0) { + for (auto name : ladder_slot_names) { + ret.push_back(std::format("{}_acc", name), acc_float_array(particle_count - 2)); + } + for (auto name : ladder_beam_names) { + ret.push_back(std::format("{}_acc", name), acc_float_array(2)); + } + } + return ret; } +bool Integrand::has_channel_weight_after_cuts() const { + // the three groups of pushes into weights_after_cuts in build_channel_part + bool merging = _energy_scale && !_energy_scale->mlm_history_per_diagram() && + (_energy_scale->is_mlm() || _energy_scale->has_scale_veto()); + return _pid_options.size() > 1 || _has_mirror || merging; +} + NamedVector Integrand::build_channel_part( FunctionBuilder& fb, const NamedVector& args ) const { @@ -521,27 +712,42 @@ NamedVector Integrand::build_channel_part( auto scales = _energy_scale.value().build_function(fb, {momenta_acc}); std::array pdf_results; bool has_pdf_prior = false; + // The densities of beam i, for every flavour option, at the scale the + // event is generated with. Under MLM pdf reweighting the density is asked + // for low on the clustering ladder rather than at the factorisation scale, + // and walked back up by the ratios mlm_weights applies. This is madevent + // setting q2fact below q2bck before calling DSIG, and it is what the + // flavour prior should see too, since that is the density the event is + // actually generated with. + auto beam_pdf = [&](std::size_t i) { + auto& pdf_scale = _energy_scale->mlm_pdf_reweighting() + ? scales.at(std::format("pdf_scale{}", i + 1)) + : scales.at(i + 1); + auto pdf = + _pdfs.at(i).value().build_function(fb, {x_acc.at(i), pdf_scale}).at(0); + if (_has_mirror && _pdfs_swapped.at(i)) { + // leg i of a mirrored event comes from the other beam + auto pdf_swapped = _pdfs_swapped.at(i) + .value() + .build_function(fb, {x_acc.at(i), pdf_scale}) + .at(0); + pdf = fb.gather_vector( + fb.batch_gather(indices_acc, mirror_index), fb.stack({pdf, pdf_swapped}) + ); + } + return pdf; + }; if ((_pdfs.at(0) || _pdfs.at(1)) && _energy_scale) { ValueVec pdf_priors; for (std::size_t i = 0; i < 2; ++i) { if (_diff_xs.at(0).has_pdf(i)) { - auto pdf = - _pdfs.at(i) - .value() - .build_function(fb, {x_acc.at(i), scales.at(i + 1)}) - .at(0); - if (_has_mirror && _pdfs_swapped.at(i)) { - // leg i of a mirrored event comes from the other beam - auto pdf_swapped = - _pdfs_swapped.at(i) - .value() - .build_function(fb, {x_acc.at(i), scales.at(i + 1)}) - .at(0); - pdf = fb.gather_vector( - fb.batch_gather(indices_acc, mirror_index), - fb.stack({pdf, pdf_swapped}) - ); - } + // Under pdf reweighting the density is asked for low on the + // clustering ladder rather than at the factorisation scale, + // and walked back up by the ratios applied further down. This + // is madevent setting q2fact below q2bck before calling DSIG, + // and it is what the flavour prior should see too, since that + // is the density the event is actually generated with. + auto pdf = beam_pdf(i); pdf_results.at(i) = pdf; pdf_priors.push_back(fb.select(pdf, _pdf_indices.at(i))); } @@ -636,6 +842,54 @@ NamedVector Integrand::build_channel_part( } } + // With a clustering history per diagram these scales only serve the pdf + // prior above: the history, and every weight read off it, is settled after + // the matrix element has given the diagram weights, in the common part. + bool mlm_history_per_diagram = + _energy_scale && _energy_scale->mlm_history_per_diagram(); + + // The history over every diagram above was clustered before the flavour + // was known, as the flavour prior needs its scales. Some of its + // clusterings only exist for some flavours of a merged subprocess (the + // q-q' t-channel gluon of q q~ > q q~ for a q q~ > q' q~' event), so the + // event is clustered again with its own flavours, and the densities are + // taken again at the scales that gives. The prior keeps the first ones: + // it only steers the sampling. + if (_energy_scale && _energy_scale->is_mlm() && !mlm_history_per_diagram && + !_mlm_leg_flavors.empty()) { + scales = _energy_scale->build_mlm_with_flavors( + fb, momenta_acc, flavor_id, _mlm_leg_flavors + ); + for (std::size_t i = 0; i < 2; ++i) { + if (!pdf_results.at(i)) { + continue; + } + auto pdf = beam_pdf(i); + // _mlm_leg_flavors is only filled with more than one flavour + pdf_results.at(i) = + fb.gather(fb.gather_int(flavor_id, _pdf_indices.at(i)), pdf); + } + } + + NamedVector mlm_ladder; + if (_energy_scale && _energy_scale->is_mlm() && !mlm_history_per_diagram) { + for (auto& weight : mlm_weights(fb, scales, x_acc, flavor_id, &mlm_ladder)) { + weights_after_cuts.push_back(weight); + } + } + if (_energy_scale && _energy_scale->has_scale_veto() && !mlm_history_per_diagram) { + // Same for the floor on the scales themselves, which applies to every + // dynamical scale choice rather than only to the merging one. + weights_after_cuts.push_back(scales.at("scale_weight")); + } + + if (!weights_after_cuts.empty() && !has_channel_weight_after_cuts()) { + // the common part would drop these weights without a word + throw std::logic_error( + "Integrand: weight_after_cuts carries a weight that " + "has_channel_weight_after_cuts() does not announce" + ); + } Value weight_after_cuts = weights_after_cuts.empty() ? fb.full({1., batch_size_acc}) : fb.product(weights_after_cuts); @@ -686,9 +940,218 @@ NamedVector Integrand::build_channel_part( out.push_back("fact_scale2", scales.at(2)); } + if (_energy_scale && _energy_scale->is_mlm()) { + out.push_back("cluster_scales_acc", scales.at("outgoing_scales")); + } + if (emits_alphas_scales( + _energy_scale, _running_coupling, _mapping.particle_count() + )) { + out.push_back("alphas_scales_acc", scales.at("alphas_scales")); + } + if (_pdf_rw && _pid_options.size() > 0) { + // with the history picked per diagram the ladder is only known in + // the common part: zeros here, never read + auto batch_size = fb.batch_size({momenta_acc}); + auto slot_count = static_cast(_mapping.particle_count() - 2); + for (auto name : ladder_slot_names) { + out.push_back( + std::format("{}_acc", name), + mlm_ladder.size() > 0 ? mlm_ladder.at(name) + : fb.full({0., batch_size, slot_count}) + ); + } + for (auto name : ladder_beam_names) { + out.push_back( + std::format("{}_acc", name), + mlm_ladder.size() > 0 + ? mlm_ladder.at(name) + : fb.full({0., batch_size, static_cast(2)}) + ); + } + } + return out; } +ValueVec Integrand::mlm_weights( + FunctionBuilder& fb, + const NamedVector& scales, + const std::array& x, + Value flavor_id, + NamedVector* ladder +) const { + ValueVec weights; + auto batch_size_acc = fb.batch_size({flavor_id}); + // The merging cut comes out of the clustering, which only runs for + // events that already passed the phase-space cuts, so it enters as a + // factor on the weight rather than as one of the cuts themselves. An + // event below xqcut ends up with weight zero and is never unweighted. + weights.push_back(scales.at("xqcut_weight")); + + // alpha_s reweighting, the CKKW-style factor madevent applies in + // Template/LO/SubProcesses/reweight.f: every clustering vertex that + // produced a parton is evaluated at its own scale rather than at the + // event's, so the weight carries prod_i alphas(pt_i) instead of + // alphas(mu_R)^n. Without it a merged sample is short by one factor + // per emission, compounding with multiplicity. + // + // The kernel hands back 0 for any vertex it does not reweight; that + // vertex is evaluated at mu_R, so its ratio is exactly one. + if (_energy_scale->mlm_alphas_reweighting() && _running_coupling) { + auto vertex_scales = scales.at("alphas_scales"); + std::size_t vertex_count = vertex_scales.type.shape.at(0); + if (vertex_count > 0) { + auto ren_scale = scales.at("ren_scale"); + auto reference = + _running_coupling.value().build_function(fb, {ren_scale}).at(0); + Value factor; + for (std::size_t i = 0; i < vertex_count; ++i) { + auto [rest, vertex_scale] = fb.pop(vertex_scales); + vertex_scales = rest; + // 0 (not reweighted) -> mu_R; a reweighted scale is > 2 GeV + auto at_mur = fb.cut_one(vertex_scale, -1., 0.); + auto one_scale = fb.add(vertex_scale, fb.mul(at_mur, ren_scale)); + auto alpha = + _running_coupling.value().build_function(fb, {one_scale}).at(0); + auto ratio = fb.div(alpha, reference); + factor = factor ? fb.mul(factor, ratio) : ratio; + } + weights.push_back(factor); + weights.push_back(scales.at("alphas_weight")); + } + } + + // pdf reweighting: the beam density, evaluated above at the bottom of + // the clustering ladder, walked back up one clustering at a time. Each + // slot the kernel marked active contributes f(x, Q_i) / f(x, Q_i-1) + // for the flavour its beam line carried at that point. + // + // The flavour is only knowable here, after the sampling: the kernel + // hands out a class - the gluon, one of the two beams' own flavours, or + // a flavour the diagram pinned down - and the table turns that plus the + // sampled option into an index into the reweighting density. + if (_pdf_rw && _pid_options.size() > 0) { + auto flavor_classes = scales.at("pdf_rw_flavor"); + std::size_t slot_count = flavor_classes.type.shape.at(0); + double low = _energy_scale->min_scale(); + double high = _energy_scale->max_scale() > 0. + ? _energy_scale->max_scale() + : 1e30; + auto ones = fb.full({1., batch_size_acc}); + Value factor, veto; + ValueVec ladder_x, ladder_pdg, ladder_q, ladder_info; + // Slot 0 is the first clustering of the history, which can only ever + // record where a beam line's density already sits, so it never + // carries a ratio. + for (std::size_t i = 1; i < slot_count; ++i) { + std::vector column{static_cast(i)}; + auto pick = [&](const char* name) { + return fb.squeeze(fb.select(scales.at(name), column)); + }; + auto active = pick("pdf_rw_active"); + auto beam = pick("pdf_rw_beam"); + // x of the beam line: the beam's own momentum fraction times + // everything the clustering z has taken off it since. + auto x_beam = fb.add( + x.at(0), + fb.mul(beam, fb.sub(x.at(1), x.at(0))) + ); + auto x = fb.mul(x_beam, pick("pdf_rw_x")); + auto scale_of = [&](const char* name) { + auto q = pick(name); + if (low > 0.) { + q = fb.max(q, fb.full({low, batch_size_acc})); + } + return fb.min(q, fb.full({high, batch_size_acc})); + }; + auto index = fb.gather_int( + fb.add_int( + fb.gather_int( + fb.squeeze(fb.select_int(flavor_classes, column)), + _pdf_rw_class_offsets + ), + flavor_id + ), + _pdf_rw_table + ); + auto density = [&](const char* name) { + return _pdf_rw.value() + .build_function(fb, {x, scale_of(name), index}) + .at(0); + }; + auto numerator = density("pdf_rw_q_num"); + auto denominator = density("pdf_rw_q_den"); + if (ladder) { + // what madevent books for its systematics at a step: the line's + // flavour, its momentum fraction before the step's z, and the + // scale the step takes the density to; info is active + 2 beam + ladder_x.push_back(fb.mul(x_beam, pick("pdf_rw_x_prev"))); + ladder_pdg.push_back(fb.gather(index, _pdf_rw_pdgs)); + ladder_q.push_back(scale_of("pdf_rw_q_num")); + ladder_info.push_back(fb.add(active, fb.mul(beam, 2.))); + } + // madevent drops the event outright when the density it is + // dividing by falls under 1e-10, where the grid is no longer + // saying anything (reweight.f, rewgt=0 for pdfj2 < 1e-10). Same + // threshold, as a hard step: a ramp in the denominator would let + // 0 < den < 1e-10 through at num*den*1e20 instead of zero. The + // floor under the division keeps the term finite so that a + // vetoed slot multiplies to zero and not to a NaN. + auto floor = fb.full({1e-10, batch_size_acc}); + // max(den, 0) is 0 for a NaN density (the comparison max is built + // on picks the second argument), which cut_one alone would pass + auto usable = fb.cut_one( + fb.max(denominator, fb.full({0., batch_size_acc})), 1e-10, 1e300 + ); + auto ratio = fb.div(numerator, fb.max(denominator, floor)); + // An inert slot contributes exactly one, whatever its density + // came out as. + auto term = fb.add(ones, fb.mul(active, fb.sub(ratio, ones))); + auto pass = fb.add(ones, fb.mul(active, fb.sub(usable, ones))); + factor = factor ? fb.mul(factor, term) : term; + veto = veto ? fb.mul(veto, pass) : pass; + } + if (factor) { + weights.push_back(factor); + weights.push_back(veto); + } + if (ladder && !ladder_x.empty()) { + ladder->push_back("ladder_x", fb.stack(ladder_x)); + ladder->push_back("ladder_pdg", fb.stack(ladder_pdg)); + ladder->push_back("ladder_q", fb.stack(ladder_q)); + ladder->push_back("ladder_info", fb.stack(ladder_info)); + // each beam's leading density, at the scale it was evaluated at + // (clamped by apply_scale_range) + ladder->push_back( + "ladder_lead_q", fb.stack({scales.at("pdf_scale1"), scales.at("pdf_scale2")}) + ); + } + } + return weights; +} + +std::array Integrand::evaluate_pdfs( + FunctionBuilder& fb, + const NamedVector& scales, + const std::array& x, + Value flavor_id +) const { + std::array pdfs; + for (std::size_t i = 0; i < 2; ++i) { + if (!_diff_xs.at(0).has_pdf(i)) { + continue; + } + auto& pdf_scale = _energy_scale->mlm_pdf_reweighting() + ? scales.at(std::format("pdf_scale{}", i + 1)) + : scales.at(std::format("fact_scale{}", i + 1)); + auto pdf = _pdfs.at(i).value().build_function(fb, {x.at(i), pdf_scale}).at(0); + pdfs.at(i) = _pid_options.size() > 1 + ? fb.gather(fb.gather_int(flavor_id, _pdf_indices.at(i)), pdf) + : fb.squeeze(pdf); + } + return pdfs; +} + NamedVector Integrand::build_common_part( FunctionBuilder& fb, const NamedVector& args ) const { @@ -762,21 +1225,55 @@ NamedVector Integrand::build_common_part( Value alpha_qcd_acc = _running_coupling.value().build_function(fb, {args.at("ren_scale")}).at(0); - // Evaluate differential cross section - ValueVec xs_args{ - momenta_acc, - _flavor_remap.size() > 0 ? flavor_table_lookup(_flavor_remap) : flavor_id, - }; - xs_args.push_back(x1_acc); - xs_args.push_back(x2_acc); - xs_args.push_back(flavor_id); - if (_diff_xs.at(0).has_pdf(0)) { - xs_args.push_back(args.at("pdf1")); + // The scales and densities the event is evaluated with. They come from the + // channel part, unless the MLM history is picked per diagram further down. + Value ren_scale_acc = args.at("ren_scale"); + std::array fact_scales_acc, pdfs_acc; + for (std::size_t i = 0; i < 2; ++i) { + if (_diff_xs.at(0).has_pdf(i)) { + fact_scales_acc.at(i) = args.at(std::format("fact_scale{}", i + 1)); + pdfs_acc.at(i) = args.at(std::format("pdf{}", i + 1)); + } } - if (_diff_xs.at(0).has_pdf(1)) { - xs_args.push_back(args.at("pdf2")); + Value cluster_scales_acc; + if (_energy_scale && _energy_scale->is_mlm()) { + cluster_scales_acc = args.at("cluster_scales_acc"); + } + bool has_alphas_scales = + emits_alphas_scales(_energy_scale, _running_coupling, _mapping.particle_count()); + Value alphas_scales_acc; + if (has_alphas_scales) { + alphas_scales_acc = args.at("alphas_scales_acc"); } - xs_args.push_back(alpha_qcd_acc); + bool has_ladder = _pdf_rw && _pid_options.size() > 0; + NamedVector ladder_acc; + if (has_ladder) { + for (auto name : ladder_slot_names) { + ladder_acc.push_back(name, args.at(std::format("{}_acc", name))); + } + for (auto name : ladder_beam_names) { + ladder_acc.push_back(name, args.at(std::format("{}_acc", name))); + } + } + + // Evaluate differential cross section + auto make_xs_args = [&](std::array& pdfs, Value alpha) { + ValueVec xs_args{ + momenta_acc, + _flavor_remap.size() > 0 ? flavor_table_lookup(_flavor_remap) : flavor_id, + }; + xs_args.push_back(x1_acc); + xs_args.push_back(x2_acc); + xs_args.push_back(flavor_id); + for (std::size_t i = 0; i < 2; ++i) { + if (_diff_xs.at(0).has_pdf(i)) { + xs_args.push_back(pdfs.at(i)); + } + } + xs_args.push_back(alpha); + return xs_args; + }; + ValueVec xs_args = make_xs_args(pdfs_acc, alpha_qcd_acc); ValueVec dxs_vec; Value ps_flavor_id; Value subproc_id; @@ -838,19 +1335,113 @@ NamedVector Integrand::build_common_part( chan_weights_acc = fb.batch_merge_by_index(split_channel_weights); } } + + // A clustering history per diagram. The evaluation above, at the scales of + // the history over every diagram, has supplied the diagram weights + // |A_i|^2 / sum_j |A_j|^2 (and the channel weights, which stay as they + // are). One diagram is picked from them - a choice between histories, not + // an importance sample, so it puts no factor on the weight - the event is + // clustered along it, and the cross section is taken at the scales that + // history gives: by rescaling the first evaluation when every diagram has + // the same power of alpha_s (mlm_history_qcd_power), by evaluating the + // matrix element again when coupling orders mix, as they do for gluon + // fusion and VBF in one process. + ValueVec mlm_history_weights; + if (_energy_scale && _energy_scale->mlm_history_per_diagram()) { + auto batch_size_acc = fb.batch_size({momenta_acc}); + auto diagram_mask = fb.gather_vector( + fb.full({static_cast(0), batch_size_acc}), _mlm_diagram_mask + ); + auto [diagram, diagram_det] = fb.sample_discrete_probs( + fb.squeeze(fb.random(batch_size_acc, 1)), + fb.mul(dxs_vec.at(1), diagram_mask) + ); + auto scales = _energy_scale->build_mlm_from_start_state( + fb, + momenta_acc, + fb.gather_int(diagram, _mlm_start_states), + flavor_id, + _mlm_leg_flavors + ); + std::array x_acc{x1_acc, x2_acc}; + NamedVector history_ladder; + mlm_history_weights = + mlm_weights(fb, scales, x_acc, flavor_id, &history_ladder); + for (auto& name : history_ladder.keys()) { + ladder_acc.at(name) = history_ladder.at(name); + } + if (_energy_scale->has_scale_veto()) { + mlm_history_weights.push_back(scales.at("scale_weight")); + } + ren_scale_acc = scales.at("ren_scale"); + for (std::size_t i = 0; i < 2; ++i) { + if (_diff_xs.at(0).has_pdf(i)) { + fact_scales_acc.at(i) = scales.at(std::format("fact_scale{}", i + 1)); + } + } + auto pdfs_first = pdfs_acc; + auto alpha_first = alpha_qcd_acc; + pdfs_acc = evaluate_pdfs(fb, scales, x_acc, flavor_id); + cluster_scales_acc = scales.at("outgoing_scales"); + if (has_alphas_scales) { + alphas_scales_acc = scales.at("alphas_scales"); + } + alpha_qcd_acc = + _running_coupling.value().build_function(fb, {ren_scale_acc}).at(0); + if (_mlm_history_qcd_power >= 0) { + // Only the scales changed, and the matrix element sees them only + // through alpha_s: with one power n of alpha_s for every diagram + // |M|^2 = C alpha_s^n, and the cross section is linear in each + // beam density. So the first evaluation, rescaled by + // (alpha_s' / alpha_s)^n f1' f2' / (f1 f2), is the second one to + // rounding - and exactly it where the history did not change, as + // for most events under clustering_history = madevent. The colour, + // helicity and diagram weights of the first evaluation stay valid: + // their probabilities are ratios of terms of the same power. + auto batch_size_acc = fb.batch_size({momenta_acc}); + auto ones = fb.full({1., batch_size_acc}); + Value factor = ones; + auto alpha_ratio = fb.div(alpha_qcd_acc, alpha_first); + for (int k = 0; k < _mlm_history_qcd_power; ++k) { + factor = fb.mul(factor, alpha_ratio); + } + for (std::size_t i = 0; i < 2; ++i) { + if (_diff_xs.at(0).has_pdf(i)) { + // a first density of exactly zero gave a zero weight, which + // stays zero (the guard only keeps 0 * x / 0 from a NaN) + auto denominator = fb.add( + pdfs_first.at(i), fb.cut_one(pdfs_first.at(i), 0., 0.) + ); + factor = + fb.mul(factor, fb.div(pdfs_acc.at(i), denominator)); + } + } + dxs_vec.at(0) = fb.mul(dxs_vec.at(0), factor); + } else { + // several powers of alpha_s mix (gluon fusion and VBF in one + // process, say): the matrix element has to be evaluated again + dxs_vec = _diff_xs.at(0) + .build_function(fb, make_xs_args(pdfs_acc, alpha_qcd_acc)) + .values(); + } + } + auto diff_xs_acc = dxs_vec.at(0); if (_flavor_factors.size() > 0) { diff_xs_acc = has_multi_flavor ? fb.mul(diff_xs_acc, fb.gather(flavor_id, _flavor_factors)) : fb.mul(diff_xs_acc, _flavor_factors.at(0)); } - // weight_after_cuts is one by construction unless flavor or mirror sampling - // contribute to it (see build_channel_part) + // weight_after_cuts is one by construction unless the channel part put a + // weight into it ValueVec weights_after_cuts; - if (has_multi_flavor || _has_mirror) { + if (has_channel_weight_after_cuts()) { weights_after_cuts.push_back(args.at("weight_after_cuts")); } weights_after_cuts.push_back(diff_xs_acc); + for (auto& weight : mlm_history_weights) { + weights_after_cuts.push_back(weight); + } // the bias enters the weight seen by the adaptation and the unweighting, // the event generator divides it out again Value bias_acc; @@ -1015,36 +1606,60 @@ NamedVector Integrand::build_common_part( } outputs.push_back( - "ren_scale", scatter_or_drop(zeros_float, args.at("ren_scale")) + "ren_scale", scatter_or_drop(zeros_float, ren_scale_acc) ); outputs.push_back("alpha_qcd", scatter_or_drop(zeros_float, alpha_qcd_acc)); - if (_partial_weights) { - ValueVec pdf_vals; - if (_diff_xs.at(0).has_pdf(0)) { - outputs.push_back( - "x1", scatter_or_drop(zeros_float, args.at("x1_acc")) - ); - outputs.push_back( - "fact_scale1", scatter_or_drop(zeros_float, args.at("fact_scale1")) - ); - pdf_vals.push_back(args.at("pdf1")); - } - if (_diff_xs.at(0).has_pdf(1)) { - outputs.push_back( - "x2", scatter_or_drop(zeros_float, args.at("x2_acc")) - ); - outputs.push_back( - "fact_scale2", scatter_or_drop(zeros_float, args.at("fact_scale2")) - ); - pdf_vals.push_back(args.at("pdf2")); - } - if (_diff_xs.at(0).has_pdf(0) || _diff_xs.at(0).has_pdf(1)) { + ValueVec pdf_vals; + if (_diff_xs.at(0).has_pdf(0)) { + outputs.push_back("x1", scatter_or_drop(zeros_float, args.at("x1_acc"))); + outputs.push_back( + "fact_scale1", scatter_or_drop(zeros_float, fact_scales_acc.at(0)) + ); + pdf_vals.push_back(pdfs_acc.at(0)); + } + if (_diff_xs.at(0).has_pdf(1)) { + outputs.push_back("x2", scatter_or_drop(zeros_float, args.at("x2_acc"))); + outputs.push_back( + "fact_scale2", scatter_or_drop(zeros_float, fact_scales_acc.at(1)) + ); + pdf_vals.push_back(pdfs_acc.at(1)); + } + if (_partial_weights && + (_diff_xs.at(0).has_pdf(0) || _diff_xs.at(0).has_pdf(1))) { + outputs.push_back( + "partial_weight_product", + scatter_or_drop(zeros_float, fb.product(pdf_vals)) + ); + } + if (_energy_scale && _energy_scale->is_mlm()) { + auto outgoing_count = static_cast(_mapping.particle_count() - 2); + outputs.push_back( + "cluster_scales", + scatter_or_drop( + fb.full({0., batch_size_val, outgoing_count}), + cluster_scales_acc + ) + ); + } + if (has_ladder) { + for (auto& name : ladder_acc.keys()) { + auto& value = ladder_acc.at(name); + auto width = static_cast(value.type.shape.at(0)); outputs.push_back( - "partial_weight_product", - scatter_or_drop(zeros_float, fb.product(pdf_vals)) + name, + scatter_or_drop(fb.full({0., batch_size_val, width}), value) ); } } + if (has_alphas_scales) { + auto vertex_count = static_cast(_mapping.particle_count() - 3); + outputs.push_back( + "alphas_scales", + scatter_or_drop( + fb.full({0., batch_size_val, vertex_count}), alphas_scales_acc + ) + ); + } if (bias_acc) { outputs.push_back( "bias", scatter_or_drop(fb.full({1., batch_size_val}), bias_acc) diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp new file mode 100644 index 0000000000..edd907815d --- /dev/null +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -0,0 +1,1181 @@ +#include "madspace/phasespace/mlm_clustering.hpp" + +#include +#include +#include +#include +#include +#include + +using namespace madspace; + +namespace { + +// Largest number of external particles the kernel can handle (mlm.hpp +// N_EXT_MAX), and the number of bits available for a clustering mask. +constexpr std::size_t n_ext_max = 12; + +// Properties of the line a clustering mask stands for: the external particle +// itself for a single-bit mask, the propagator recombining them otherwise. +struct LineMeta { + double mass = 0.; + double width = 0.; + int pdg_id = 0; + bool known = false; +}; + +// Mirrors isjet() in Template/LO/SubProcesses/reweight.f: a final state +// particle a parton shower would turn into a jet, and hence one that gets its +// own clustering scale in the LHE output. 81 is the merged-flavor placeholder +// used by flavor grouping. +bool is_jet_pdg(int pdg_id, int max_jet_flavor) { + int a = std::abs(pdg_id); + return (a >= 1 && a <= max_jet_flavor) || a == 21 || a == 81; +} + +// Mirrors is_octet() in Template/LO/SubProcesses/reweight.f: a colour octet, +// which in the QCD sector means the gluon. The iqjets bookkeeping treats an +// emitted gluon as a jet unconditionally - it has a soft singularity the shower +// must be allowed to fill - and uses octet-ness again to decide which leg of a +// non-jet vertex to demote. +bool is_octet_pdg(int pdg_id, const std::unordered_map& pdg_color_types); + +// Mirrors isparton() in Template/LO/SubProcesses/reweight.f: a line that can +// be a beam parton, i.e. one the pdf reweighting can follow back into a beam. +// Wider than is_jet_pdg: madevent tests against max(asrwgtflavor, maxjetflavor) +// and asrwgtflavor defaults to 5, so the b is a parton even when it is not a +// jet. +bool is_parton_pdg(int pdg_id) { + int a = std::abs(pdg_id); + return (a >= 1 && a <= 5) || a == 21 || a == 81; +} + +// Which density the pdf reweighting should ask for on a given line. +// +// The flavour cannot simply be baked in. One MLMClustering serves every flavour +// channel of a subprocess, and external_pdg_ids is a single representative +// assignment with the signs stripped (clean_pids in launch.py), so "the beam is +// a u" is only true of one channel out of many. What is stable across the +// channels is the *structure*: whether a line is a gluon, and whether it still +// carries the flavour the beam came in with. Those two are baked here as +// classes; the integrand resolves them per event once the flavour has been +// sampled. +// +// Anything else - a quark line whose flavour changed along the way, which for +// W+jets means past the W vertex - falls back to an absolute pdg taken from the +// representative assignment. That is a real approximation, but it is confined +// to lines whose flavour the representative was already guessing at, and a pdf +// ratio between two nearby scales is far more sensitive to quark-versus-gluon +// than to which quark. +constexpr int flavor_class_none = -1; +constexpr int flavor_class_gluon = 0; +constexpr int flavor_class_beam1 = 1; +constexpr int flavor_class_beam2 = 2; +constexpr int flavor_class_first_absolute = 3; +// _MERGED_PID_REPRESENTATIVE in launch.py, which is what external_pdg_ids has +// already been mapped through by the time it gets here. +constexpr int merged_quark_representative = 1; + +// Color representation of a line, as the signed color of the model (1 for a +// singlet, +-3 for a (anti)triplet, 8 for an octet, +-6 for a sextet). The +// merged-flavor placeholders that survive in propagator pdg ids stand for the +// particles clean_pids() maps them to in launch.py: 81 is a quark, 82 an +// electron and 83 a neutrino. +int color_rep(int pdg_id, const std::unordered_map& pdg_color_types) { + if (auto search = pdg_color_types.find(pdg_id); search != pdg_color_types.end()) { + return std::abs(search->second); + } + int a = std::abs(pdg_id); + if ((a >= 1 && a <= 6) || a == 81) { + return 3; + } + if (a == 21) { + return 8; + } + return 1; +} + +// Whether a line carries color. Used to decide whether a *vertex* is a QCD +// splitting, which is what selects the clustering scales entering mu_R/mu_F. +bool is_colored_pdg(int pdg_id, const std::unordered_map& pdg_color_types) { + return color_rep(pdg_id, pdg_color_types) != 1; +} + +bool is_octet_pdg(int pdg_id, const std::unordered_map& pdg_color_types) { + return color_rep(pdg_id, pdg_color_types) == 8; +} + +// How the parton line of the mother of a clustering continues into its two +// daughters, so that a clustering scale can be booked onto the external legs +// the line ends at. Mirrors ipartupdate() in +// Template/LO/SubProcesses/reweight.f, which decides the same thing from the +// color representations (its leading pdg-equality tests give the same answer +// as the color ones for every case they cover). +enum TraceMode { + trace_first = 0, // the line continues into daughter 1 + trace_second = 1, // ... into daughter 2 + trace_harder = 2, // ... into whichever daughter is harder (decided per event) + trace_both = 3, // the mother stands for both daughters' lines +}; + +TraceMode trace_mode_for(int color_in, int color_1, int color_2) { + if (color_in == 8) { + // g -> g g, or an octet splitting to two octets: follow the harder one + if (color_1 == 8 && color_2 == 8) { + return trace_harder; + } + // g -> q qbar: both daughters carry the line on + if (color_1 == 3 && color_2 == 3) { + return trace_both; + } + // g -> g H and friends: follow the colored daughter + if (color_1 == 8) { + return trace_first; + } + if (color_2 == 8) { + return trace_second; + } + } else if (color_in == 3) { + // an epsilon^ijk vertex, 3 -> 3 3, is treated like a photon emission + // and follows the first daughter + if (color_1 == 3 && color_2 == 3) { + return trace_first; + } + // q -> q g, q -> q Z/H/W, and the exotic q -> q' S variants + if (color_1 == 3) { + return trace_first; + } + if (color_2 == 3) { + return trace_second; + } + } else if (color_in == 6) { + if (color_1 == 3 && color_2 == 3) { + return trace_both; + } + } else if (color_in == 1) { + return trace_both; + } + // Nothing matched. The Fortran stops the run here; keeping the line on the + // first daughter is wrong but local, and only costs the clustering scale of + // one leg rather than the whole event. + return trace_first; +} + +struct CompileContext { + const nested_vector2& valid_diags; + const std::vector& mask_meta; + std::vector& bw_masses; + std::vector& bw_widths; + std::map, int>& bw_indices; + const std::unordered_map& pdg_color_types; + bool have_pdg_ids; + int max_jet_flavor; + // |pdg| of each beam, to recognise a line that still carries the flavour + // the beam came in with. + int beam_pdg[2]; + // The absolute flavours needed beyond gluon and the two beams, in the + // order the classes above index them. + std::vector& absolute_pdgs; + // The propagator pdg ids each diagram puts on a line, keyed by (diagram, + // mask): what the union of mask_meta keeps only one of. + const std::map, std::vector>& line_pdgs; + // Set once any clustering is marked same_flavor. + bool& has_same_flavor; +}; + +// Whether a line is a fermion whose flavour a g, a, Z or h vertex preserves. +bool is_flavored_fermion_pdg(int pdg_id) { + int a = std::abs(pdg_id); + return (a >= 1 && a <= 6) || (a >= 11 && a <= 16); +} + +// Whether every diagram among diags that has the line mask puts a g, a, Z or h +// on it, so that the two bare external legs it joins must share their flavour. +// madevent's cluster.f checks the same per graph, see flavor_filter there. +bool joins_same_flavor( + const CompileContext& ctx, int mask, int mask_1, int mask_2, + const std::vector& diags +) { + if (!ctx.have_pdg_ids || std::popcount(static_cast(mask_1)) != 1 || + std::popcount(static_cast(mask_2)) != 1 || + !is_flavored_fermion_pdg(ctx.mask_meta.at(mask_1).pdg_id) || + !is_flavored_fermion_pdg(ctx.mask_meta.at(mask_2).pdg_id)) { + return false; + } + bool any = false; + for (int diag : diags) { + auto search = ctx.line_pdgs.find({diag, mask}); + if (search == ctx.line_pdgs.end()) { + return false; + } + for (int pdg_id : search->second) { + int a = std::abs(pdg_id); + if (a != 21 && a != 22 && a != 23 && a != 25) { + return false; + } + any = true; + } + } + return any; +} + +// The flavour class of one line, allocating a new absolute class if it needs +// one. flavor_class_none for anything the reweighting cannot follow. +// +// on_beam is the beam a line sits on, or -1 for one that sits on neither. It +// only matters for the flavour-merged placeholder 81, which says "a light +// quark" without saying which: on a quark beam that is the beam's own flavour +// and resolves exactly, and anywhere else it falls back to the representative +// quark that clean_pids in launch.py would have picked. +int flavor_class(CompileContext& ctx, int pdg_id, int on_beam = -1) { + if (!ctx.have_pdg_ids || !is_parton_pdg(pdg_id)) { + return flavor_class_none; + } + int a = std::abs(pdg_id); + if (a == 21) { + return flavor_class_gluon; + } + if (a == 81) { + if (on_beam >= 0 && std::abs(ctx.beam_pdg[on_beam]) != 21 && + is_parton_pdg(ctx.beam_pdg[on_beam])) { + return flavor_class_beam1 + on_beam; + } + a = merged_quark_representative; + } + for (int beam = 0; beam < 2; ++beam) { + if (a == std::abs(ctx.beam_pdg[beam])) { + return flavor_class_beam1 + beam; + } + } + auto found = std::find(ctx.absolute_pdgs.begin(), ctx.absolute_pdgs.end(), a); + if (found == ctx.absolute_pdgs.end()) { + ctx.absolute_pdgs.push_back(a); + return flavor_class_first_absolute + + static_cast(ctx.absolute_pdgs.size()) - 1; + } + return flavor_class_first_absolute + + static_cast(found - ctx.absolute_pdgs.begin()); +} + +using StateKey = std::pair, std::vector>; +struct StateItem { + int next_state; + int particle1; + int particle2; + int mass_index; + bool massive_in; + bool massive_out1; + bool massive_out2; + bool is_qcd; + bool is_jet1; + bool is_jet2; + TraceMode trace_mode; + // Flavour of the *mother*. madevent's scale definition needs to know where + // a beam's parton line stops being a jet and where it stops being coloured. + bool is_jet_in; + bool is_colored_in; + // isjetvx() needs to know whether the mother repeats one daughter's + // flavour - a gluon emission off a quark line, say - and the iqjets + // demotion needs to know whether the mother is an octet. + bool is_octet_in; + bool mother_is_daughter1; + bool mother_is_daughter2; + // None of the three lines carries colour - a W W Z or h h h vertex. The + // demotion leaves those alone rather than treating them as a QCD vertex + // that failed the jet test. + bool all_colorless; + // Flavour class of the mother, i.e. of the beam line as it stands after + // this clustering. flavor_class_none ends the pdf reweighting chain, + // which is also what stops madevent following ibeam(j) any further. + int flavor_class_in; + // The two daughters are bare external fermions joined by a g, a, Z or h in + // every diagram left, so the clustering only exists for an event in which + // they carry the same flavour (TRACE_SAME_FLAVOR in kernels/mlm.hpp). + bool same_flavor; +}; + +// 1-based index into the Breit-Wigner tables handed to the kernel, or 0 for +// "never treat this clustering as resonant". +int breit_wigner_index(CompileContext& ctx, const LineMeta& meta) { + if (!meta.known || meta.width <= 0. || meta.mass <= 0.) { + return 0; + } + auto key = std::pair{meta.mass, meta.width}; + if (auto search = ctx.bw_indices.find(key); search != ctx.bw_indices.end()) { + return search->second; + } + int index = static_cast(ctx.bw_masses.size()) + 1; + ctx.bw_masses.push_back(meta.mass); + ctx.bw_widths.push_back(meta.width); + ctx.bw_indices[key] = index; + return index; +} + +StateItem make_state_item( + CompileContext& ctx, + int next_state, + int mask_in, + int mask_1, + int mask_2, + const std::vector& diags +) { + int particle1 = std::countr_zero(static_cast(mask_1)); + int particle2 = std::countr_zero(static_cast(mask_2)); + auto& meta_in = ctx.mask_meta.at(mask_in); + auto& meta_1 = ctx.mask_meta.at(mask_1); + auto& meta_2 = ctx.mask_meta.at(mask_2); + // The state machine always names the merged pseudo-particle by the lowest + // bit of the combined mask, so particle1 < particle2 and an initial-state + // (t-channel) clustering is exactly the case particle1 < 2. + bool is_initial = particle1 < 2; + + // A t-channel propagator is spacelike and can never go on shell, so the + // resonance test is only meaningful for a final-state clustering. + int mass_index = is_initial ? 0 : breit_wigner_index(ctx, meta_in); + + int color_1 = color_rep(meta_1.pdg_id, ctx.pdg_color_types); + int color_2 = color_rep(meta_2.pdg_id, ctx.pdg_color_types); + int color_in = color_rep(meta_in.pdg_id, ctx.pdg_color_types); + if (meta_in.pdg_id == 0) { + // The propagator's flavor was not supplied (the topology fixtures used + // by the tests carry masses only), so color_rep would call it a + // singlet. Infer it from the daughters instead, which is unambiguous + // for a QCD vertex. + if (color_1 == 3 && color_2 == 3) { + color_in = 8; + } else if (color_1 == 8 && color_2 == 8) { + color_in = 8; + } else if (color_1 == 3 || color_2 == 3) { + color_in = 3; + } + } + // An initial-state clustering carries the beam line on, which is daughter 1 + // by construction, so the color-based table does not apply to it. + TraceMode trace_mode = + is_initial ? trace_first : trace_mode_for(color_in, color_1, color_2); + + bool is_qcd, is_jet1, is_jet2; + if (!ctx.have_pdg_ids) { + // No flavor information supplied: fall back to assuming every + // clustering is a massless QCD splitting between jets. + is_qcd = is_jet1 = is_jet2 = true; + } else { + // A vertex is a QCD splitting when all three of its lines carry color. + // Checking the parent matters: q qbar -> Z has two colored children and + // a colorless parent, and is not a QCD vertex. + // + // A pdg id of 0 means the propagator's flavor was not supplied (the + // topology fixtures used by the tests carry masses only). Fall back to + // the two children then: a splitting of two colored lines is QCD unless + // the parent says otherwise, which is also the only test available for + // t-channel lines whose flavor changes along the chain. + is_qcd = color_1 != 1 && color_2 != 1 && + (meta_in.pdg_id == 0 || color_in != 1); + // Only final state particles get a clustering scale in the LHE output, + // so a beam leg is never a jet here. + is_jet1 = !is_initial && is_jet_pdg(meta_1.pdg_id, ctx.max_jet_flavor); + is_jet2 = is_jet_pdg(meta_2.pdg_id, ctx.max_jet_flavor); + } + + bool same_flavor = joins_same_flavor(ctx, mask_in, mask_1, mask_2, diags); + if (same_flavor) { + ctx.has_same_flavor = true; + } + + return { + .next_state = next_state, + .particle1 = particle1, + .particle2 = particle2, + .mass_index = mass_index, + .massive_in = meta_in.mass > 0., + .massive_out1 = meta_1.mass > 0., + .massive_out2 = meta_2.mass > 0., + .is_qcd = is_qcd, + .is_jet1 = is_jet1, + .is_jet2 = is_jet2, + .trace_mode = trace_mode, + .is_jet_in = !ctx.have_pdg_ids || + is_jet_pdg(meta_in.pdg_id, ctx.max_jet_flavor), + .is_colored_in = !ctx.have_pdg_ids || color_in != 1, + .is_octet_in = !ctx.have_pdg_ids || + is_octet_pdg(meta_in.pdg_id, ctx.pdg_color_types), + .mother_is_daughter1 = + meta_in.pdg_id != 0 && meta_in.pdg_id == meta_1.pdg_id, + .mother_is_daughter2 = + meta_in.pdg_id != 0 && meta_in.pdg_id == meta_2.pdg_id, + .all_colorless = ctx.have_pdg_ids && color_in == 1 && color_1 == 1 && + color_2 == 1, + .flavor_class_in = + flavor_class(ctx, meta_in.pdg_id, is_initial ? particle1 : -1), + .same_flavor = same_flavor, + }; +} + +// A terminal state: nothing left to cluster, only the diagrams the history +// can have come from, one of which the walk picks at random. +void append_terminal_items( + std::vector& state, const std::vector& diags +) { + for (int diag_index : diags) { + state.push_back({ + .next_state = diag_index, + .particle1 = 0, + .particle2 = 0, + .mass_index = 0, + .massive_in = false, + .massive_out1 = false, + .massive_out2 = false, + .is_qcd = false, + .is_jet1 = false, + .is_jet2 = false, + .trace_mode = trace_first, + .is_jet_in = false, + .is_colored_in = false, + .is_octet_in = false, + .mother_is_daughter1 = false, + .mother_is_daughter2 = false, + .all_colorless = false, + .flavor_class_in = flavor_class_none, + .same_flavor = false, + }); + } +} + +void find_clusterings( + CompileContext& ctx, + const std::vector& particle_masks, + const std::vector& diagrams, + nested_vector2& states, + std::map& state_map, + std::set& dead_states, + int prev_index +) { + int n_masks = particle_masks.size(); + for (int i = 0; i < n_masks - 1; ++i) { + for (int j = i + 1; j < n_masks; ++j) { + int mask_i = particle_masks.at(i), mask_j = particle_masks.at(j); + int mask = mask_i | mask_j; + auto& valid = ctx.valid_diags.at(mask); + if (valid.size() == 0) { + continue; + } + std::vector new_masks, new_diags; + new_masks.insert( + new_masks.end(), particle_masks.begin(), particle_masks.begin() + i + ); + new_masks.push_back(mask); + new_masks.insert( + new_masks.end(), + particle_masks.begin() + i + 1, + particle_masks.begin() + j + ); + new_masks.insert( + new_masks.end(), particle_masks.begin() + j + 1, particle_masks.end() + ); + for (int index : diagrams) { + if (std::find(valid.begin(), valid.end(), index) != valid.end()) { + new_diags.push_back(index); + } + } + // valid_diags says some diagram allows this clustering, not that + // any of the diagrams still in play does. With none left there is + // no clustering history to continue, and following it anyway + // builds a state the walk can never leave. + if (new_diags.size() == 0) { + continue; + } + + bool is_terminal = new_masks.size() == 3; + StateKey key; + if (is_terminal) { + key = {{}, new_diags}; + } else { + key = {new_masks, new_diags}; + } + int index; + bool is_new_state; + if (auto search = state_map.find(key); search != state_map.end()) { + index = search->second; + is_new_state = false; + // A state already known to be a dead end stays one. + if (dead_states.contains(index)) { + continue; + } + } else { + index = states.size(); + state_map[key] = index; + states.push_back({}); + is_new_state = true; + } + + // Only expand a state the first time it is reached. Expanding it + // again would append a second copy of the same transitions, once + // per path leading to it. + if (is_new_state) { + if (is_terminal) { + append_terminal_items(states.at(index), new_diags); + } else { + find_clusterings( + ctx, new_masks, new_diags, states, state_map, + dead_states, index + ); + } + // The expansion may have found nothing: every clustering left + // is unsupported by the remaining diagrams. Drop the + // transition rather than pointing it at a state the walk + // cannot leave. + if (states.at(index).size() == 0) { + dead_states.insert(index); + continue; + } + } + + states.at(prev_index) + .push_back(make_state_item(ctx, index, mask, mask_i, mask_j, new_diags)); + } + } +} + +// Record the properties of the line a mask stands for. Different diagrams can +// put different propagators on the same partition of external legs (a photon +// and a Z, say); keep the one with a width, since that is the one the +// resonance test is about. +void set_mask_meta(std::vector& mask_meta, int mask, LineMeta meta) { + auto& current = mask_meta.at(mask); + if (!current.known || (current.width <= 0. && meta.width > 0.)) { + meta.known = true; + current = meta; + } +} + +// Append compiled states to the flat state machine. A clustering state is a +// run of (data, next_offset, trace_data) triples ending at the triple whose data +// has the is_last bit set; a terminal state is a count followed by that many +// diagram indices. Returns the offset each state landed at. +std::vector append_state_layout( + std::vector& machine, const nested_vector2& states +) { + std::vector first_indices; + first_indices.reserve(states.size()); + for (int offset = static_cast(machine.size()); auto& state : states) { + first_indices.push_back(offset); + if (state.size() == 0) { + // a dropped dead end: nothing points at it, so it takes no space + } else if (state.at(0).particle1 == 0 && state.at(0).particle2 == 0) { + offset += 1 + state.size(); + } else { + offset += state_machine_item_size * state.size(); + } + } + for (auto& state : states) { + if (state.size() == 0) { + continue; + } + if (state.at(0).particle1 == 0 && state.at(0).particle2 == 0) { + machine.push_back(state.size()); + for (auto& item : state) { + machine.push_back(item.next_state); + } + } else { + for (auto& item : state) { + machine.push_back( + (item.particle1 << 0) + (item.particle2 << 8) + + (item.mass_index << 16) + (item.massive_in << 24) + + (item.massive_out1 << 25) + (item.massive_out2 << 26) + + (item.is_qcd << 27) + (item.is_jet1 << 28) + (item.is_jet2 << 29) + + ((&item == &state.back()) << 30) + ); + machine.push_back(first_indices.at(item.next_state)); + machine.push_back( + static_cast(item.trace_mode) + (item.is_jet_in << 2) + + (item.is_colored_in << 3) + + ((item.flavor_class_in + 1) << 4) + + (item.is_octet_in << 12) + (item.mother_is_daughter1 << 13) + + (item.mother_is_daughter2 << 14) + (item.all_colorless << 15) + + (item.same_flavor << 16) + ); + } + } + } + return first_indices; +} + +} // namespace + +MLMClustering::MLMClustering( + std::vector topologies, + nested_vector3 permutations, + nested_vector2 diagram_indices, + double cm_energy, + JetScaleScheme jet_scale_scheme, + ScaleScheme scale_scheme, + std::unordered_map pdg_color_types, + double xqcut, + double bw_cutoff, + double jet_radius, + bool hadronic, + std::vector external_pdg_ids, + int max_jet_flavor, + PartonLineScheme parton_line_scheme, + AlphasScheme alphas_scheme, + bool pdf_reweighting, + ClusteringMeasure clustering_measure, + ClusteringHistory clustering_history, + bool clustering_mean_pdf_ladder +) : + FunctionGenerator( + "MLMClustering", + {{"momenta", + batch_four_vec_array(topologies.at(0).outgoing_masses().size() + 2)}}, + {{"ren_scale", batch_float}, + {"fact_scale1", batch_float}, + {"fact_scale2", batch_float}, + {"outgoing_scales", + batch_float_array(topologies.at(0).outgoing_masses().size())}, + {"diagram_index", batch_int}, + {"xqcut_weight", batch_float}, + {"alphas_scales", + batch_float_array(topologies.at(0).outgoing_masses().size() - 1)}, + {"alphas_weight", batch_float}, + // The scale the beam density itself is evaluated at. Not the same as + // fact_scale under pdf reweighting: madevent drops the density low on + // the clustering ladder and walks it back up, so the reported + // factorisation scale and the one the pdf is asked for differ. + {"pdf_scale1", batch_float}, + {"pdf_scale2", batch_float}, + // One slot per clustering that can put a pdf ratio on a beam line, + // plus one per beam for the 2 -> 1 root, which lies on both. + {"pdf_rw_flavor", + batch_int_array(topologies.at(0).outgoing_masses().size() + 1)}, + {"pdf_rw_x", + batch_float_array(topologies.at(0).outgoing_masses().size() + 1)}, + {"pdf_rw_q_num", + batch_float_array(topologies.at(0).outgoing_masses().size() + 1)}, + {"pdf_rw_q_den", + batch_float_array(topologies.at(0).outgoing_masses().size() + 1)}, + {"pdf_rw_active", + batch_float_array(topologies.at(0).outgoing_masses().size() + 1)}, + {"pdf_rw_beam", + batch_float_array(topologies.at(0).outgoing_masses().size() + 1)}, + // the beam line's momentum fraction before each step's z, which + // madevent books for its systematics + {"pdf_rw_x_prev", + batch_float_array(topologies.at(0).outgoing_masses().size() + 1)}} + ), + _cm_energy(cm_energy), + _jet_scale_scheme(jet_scale_scheme), + _scale_scheme(scale_scheme), + _parton_line_scheme(parton_line_scheme), + _alphas_scheme(alphas_scheme), + _pdf_reweighting(pdf_reweighting), + _clustering_mean_pdf_ladder(clustering_mean_pdf_ladder), + _clustering_measure(clustering_measure), + _clustering_history(clustering_history), + _beam_flags(0), + _jet_leg_mask(0), + _xqcut(xqcut), + _bw_cutoff(bw_cutoff), + _jet_radius(jet_radius), + _hadronic(hadronic) { + std::size_t n_ext = topologies.at(0).outgoing_masses().size() + 2; + if (n_ext > n_ext_max) { + throw std::invalid_argument(std::format( + "MLM clustering supports at most {} external particles, got {}", + n_ext_max, + n_ext + )); + } + bool have_pdg_ids = external_pdg_ids.size() > 0; + if (have_pdg_ids && external_pdg_ids.size() != n_ext) { + throw std::invalid_argument(std::format( + "expected {} external pdg ids, got {}", n_ext, external_pdg_ids.size() + )); + } + + // madevent seeds its walk with the flavour of each beam: qcdline from + // isqcd(beam) and partonline from the same. Bake those two bits per beam + // in, since they are a property of the subprocess. + for (int beam = 0; beam < 2; ++beam) { + int pdg = have_pdg_ids ? external_pdg_ids.at(beam) : 21; + if (color_rep(pdg, pdg_color_types) != 1) { + _beam_flags |= 1 << (2 * beam); + } + if (is_jet_pdg(pdg, max_jet_flavor)) { + _beam_flags |= 1 << (2 * beam + 1); + } + // The flavour class the pdf reweighting starts each beam line at, + // stored as class + 1 so that 0 means "not a parton" and the chain + // never starts. A beam that is a parton is by construction either the + // gluon or its own class, so this needs no lookup. + int beam_class = !have_pdg_ids || !is_parton_pdg(pdg) ? flavor_class_none + : std::abs(pdg) == 21 ? flavor_class_gluon + : flavor_class_beam1 + beam; + _beam_flags |= (beam_class + 1) << (8 * (beam + 1)); + } + + for (std::size_t leg = 0; leg < n_ext; ++leg) { + int pdg = have_pdg_ids ? external_pdg_ids.at(leg) : 21; + if (is_jet_pdg(pdg, max_jet_flavor)) { + _jet_leg_mask |= 1 << leg; + } + // The high half carries octet-ness for the same leg. The kernel needs + // both and n_ext_max is 12, so one word holds them without another + // kernel argument. + if (is_octet_pdg(pdg, pdg_color_types)) { + _jet_leg_mask |= 1 << (leg + 16); + } + } + + nested_vector2 valid_diags(1 << n_ext); + std::vector mask_meta(1 << n_ext); + std::vector particle_masks; + std::vector all_diags; + // The lines of each diagram on its own, as (mask, properties): what the + // union above merges away. Only needed for the per-diagram histories. + std::vector>>> + diagram_lines; + auto add_line = [&](std::size_t diag_index, int mask, const LineMeta& meta) { + valid_diags.at(mask).push_back(diag_index); + diagram_lines.back().second.push_back({mask, meta}); + }; + + // The kernel indexes momenta and external_masses by external leg, while a + // topology's masses are indexed by topology slot, so the permutation has to + // be applied. Start from a sentinel so the first diagram fills the table and + // every later one is checked against it. + _external_masses.assign(n_ext, -1.); + + // create a list of all diagram indices that are possible for a given clustering, + // where a binary encoding of the clustering is used + for (auto [topo, permutations, diag_indices] : + zip(topologies, permutations, diagram_indices)) { + auto& incoming_masses = topo.incoming_masses(); + auto& outgoing_masses = topo.outgoing_masses(); + for (auto [permutation, diag_index] : zip(permutations, diag_indices)) { + all_diags.push_back(diag_index); + diagram_lines.push_back({diag_index, {}}); + particle_masks.assign(topo.decays().size(), 0); + for (std::size_t i = 2; i < permutation.size(); ++i) { + particle_masks.at(topo.outgoing_indices().at(permutation.at(i) - 2)) = 1 + << i; + } + + // permutation maps external leg -> topology slot, so the mass of leg + // i is the mass of the slot it is mapped to. + for (std::size_t leg = 0; leg < n_ext; ++leg) { + double mass = leg < 2 + ? incoming_masses.at(leg) + : outgoing_masses.at(permutation.at(leg) - 2); + double& stored = _external_masses.at(leg); + if (stored < 0.) { + stored = mass; + } else if (stored != mass) { + // The kernel keeps a single external mass per leg, so a + // permutation that moves a mass between legs would silently + // give the wrong clustering measure. + throw std::invalid_argument(std::format( + "MLM clustering needs the mass of external leg {} to be the " + "same for every diagram and permutation, got {} and {}", + leg, + stored, + mass + )); + } + set_mask_meta( + mask_meta, + 1 << leg, + {.mass = mass, + .width = 0., + .pdg_id = have_pdg_ids ? external_pdg_ids.at(leg) : 0} + ); + } + + bool has_t_channel = topo.t_integration_order().size() > 0; + for (auto& decay : std::views::reverse(topo.decays())) { + if (decay.child_indices.size() == 0) { + continue; + } + if (decay.index == 0 && has_t_channel) { + continue; + } + if (decay.child_indices.size() > 2) { + throw std::logic_error("does not support 1->n decays with n > 2"); + } + int mask = + (particle_masks.at(decay.child_indices.at(0)) | + particle_masks.at(decay.child_indices.at(1))); + particle_masks.at(decay.index) = mask; + LineMeta meta{ + .mass = decay.mass, .width = decay.width, .pdg_id = decay.pdg_id + }; + add_line(diag_index, mask, {meta.mass, meta.width, meta.pdg_id, true}); + set_mask_meta(mask_meta, mask, meta); + } + + if (!has_t_channel) { + continue; + } + + // for the t-channel part, one of the initial state particles has to be + // involved in the clustering. find_t_vertices collects the t-channel + // propagators on the way back from beam 1 (index 0 of the incoming + // legs), so t_propagator_masses/pdg_ids start at the propagator next + // to that beam. The k-th mask accumulated from beam 1 (mask 1) is + // therefore propagator k, and the k-th from beam 2 (mask 2), walking + // the children in reverse, is propagator t_count - 1 - k. Getting + // this backwards swaps the flavour and mass of the two ends of every + // chain with two or more t-channel propagators: in g g > t t~ g the + // gluon line left after the beam emits the jet was read as the top, + // so the beam's parton line stopped at the jet and mu_R picked up + // the jet's own scale. + auto& t_masses = topo.t_propagator_masses(); + auto& t_pdg_ids = topo.t_propagator_pdg_ids(); + std::size_t t_count = t_masses.size(); + auto& t_children = topo.decays().at(0).child_indices; + std::size_t k = 0; + for (int mask = 1; std::size_t index : t_children) { + mask |= particle_masks.at(index); + if (k < t_count) { + // width stays 0: a spacelike propagator is never resonant + LineMeta meta{ + .mass = t_masses.at(k), .width = 0., .pdg_id = t_pdg_ids.at(k) + }; + add_line(diag_index, mask, {meta.mass, 0., meta.pdg_id, true}); + set_mask_meta(mask_meta, mask, meta); + } else { + add_line(diag_index, mask, {}); + } + ++k; + } + k = 0; + for (int mask = 2; std::size_t index : std::views::reverse(t_children)) { + mask |= particle_masks.at(index); + if (k < t_count) { + LineMeta meta{ + .mass = t_masses.at(t_count - 1 - k), + .width = 0., + .pdg_id = t_pdg_ids.at(t_count - 1 - k) + }; + add_line(diag_index, mask, {meta.mass, 0., meta.pdg_id, true}); + set_mask_meta(mask_meta, mask, meta); + } else { + add_line(diag_index, mask, {}); + } + ++k; + } + } + } + + std::map, std::vector> line_pdgs; + for (auto& [diag_index, lines] : diagram_lines) { + for (auto& [mask, meta] : lines) { + line_pdgs[{static_cast(diag_index), mask}].push_back(meta.pdg_id); + } + } + + std::vector masks; + masks.reserve(n_ext); + for (int i = 0; i < n_ext; ++i) { + masks.push_back(1 << i); + } + nested_vector2 states{{}}; + std::map state_map; + std::map, int> bw_indices; + state_map[{{masks}, {all_diags}}] = 0; + CompileContext ctx{ + .valid_diags = valid_diags, + .mask_meta = mask_meta, + .bw_masses = _bw_masses, + .bw_widths = _bw_widths, + .bw_indices = bw_indices, + .pdg_color_types = pdg_color_types, + .have_pdg_ids = have_pdg_ids, + .max_jet_flavor = max_jet_flavor, + .beam_pdg = {have_pdg_ids ? external_pdg_ids.at(0) : 21, + have_pdg_ids ? external_pdg_ids.at(1) : 21}, + .absolute_pdgs = _pdf_absolute_pdgs, + .line_pdgs = line_pdgs, + .has_same_flavor = _has_same_flavor, + }; + std::set dead_states; + if (n_ext == 3) { + // A 2 -> 1 process (the 0-jet sample of p p > z + jets) has nothing to + // cluster: the walk starts at a terminal state, and the kernel takes + // the scales off the single outgoing line, as cluster.f's "special + // 2 -> 1 case" does. + append_terminal_items(states.at(0), all_diags); + } else { + find_clusterings(ctx, masks, all_diags, states, state_map, dead_states, 0); + } + + if (states.at(0).size() == 0) { + throw std::logic_error( + "MLM clustering found no valid clustering for this process" + ); + } + for (std::size_t i = 0; auto& state : states) { + // Dead ends are unreachable: no transition points at them any more, but + // they still occupy a slot in `states` so that the offsets stay stable. + if (state.size() == 0 && !dead_states.contains(static_cast(i))) { + throw std::logic_error( + "MLM clustering reached a state with no valid clustering left" + ); + } + ++i; + } + + append_state_layout(_cluster_state_machine, states); + + if (clustering_history == ClusteringHistory::all_diagrams) { + return; + } + + // One state machine per diagram, appended after the one over every + // diagram, so that offset 0 can keep meaning "no diagram of its own". Each + // is compiled exactly as the union is, only from that diagram's lines: + // where the union takes a line's flavour from whichever diagram came first + // - the gluon of gluon fusion on a t-channel line that a VBF diagram puts + // a W on - these see the diagram's own, and with it its own QCD vertices, + // parton lines and merging jets. + std::size_t diagram_table_size = 0; + for (auto& [diag_index, lines] : diagram_lines) { + diagram_table_size = std::max(diagram_table_size, diag_index + 1); + } + _diagram_start_states.assign(diagram_table_size, 0); + nested_vector2 diagram_valid(1 << n_ext); + std::vector diagram_meta(1 << n_ext); + for (std::size_t leg = 0; leg < n_ext; ++leg) { + diagram_meta.at(1 << leg) = mask_meta.at(1 << leg); + } + CompileContext diagram_ctx{ + .valid_diags = diagram_valid, + .mask_meta = diagram_meta, + .bw_masses = _bw_masses, + .bw_widths = _bw_widths, + .bw_indices = bw_indices, + .pdg_color_types = pdg_color_types, + .have_pdg_ids = have_pdg_ids, + .max_jet_flavor = max_jet_flavor, + .beam_pdg = {have_pdg_ids ? external_pdg_ids.at(0) : 21, + have_pdg_ids ? external_pdg_ids.at(1) : 21}, + .absolute_pdgs = _pdf_absolute_pdgs, + .line_pdgs = line_pdgs, + .has_same_flavor = _has_same_flavor, + }; + nested_vector2 diagram_states; + std::map diagram_state_map; + std::set diagram_dead_states; + std::vector> diagram_roots; + std::set compiled; + for (auto& [diag_index, lines] : diagram_lines) { + if (!compiled.insert(diag_index).second) { + continue; + } + int diag = static_cast(diag_index); + for (auto& [mask, meta] : lines) { + diagram_valid.at(mask) = {diag}; + diagram_meta.at(mask) = meta; + } + // The state keys carry the diagram, so no state is shared between two + // diagrams: their lines differ even where their masks agree. + int root = static_cast(diagram_states.size()); + diagram_states.push_back({}); + diagram_state_map[{masks, {diag}}] = root; + if (n_ext == 3) { + append_terminal_items(diagram_states.at(root), {diag}); + } else { + find_clusterings( + diagram_ctx, + masks, + {diag}, + diagram_states, + diagram_state_map, + diagram_dead_states, + root + ); + } + if (diagram_states.at(root).size() > 0) { + diagram_roots.push_back({diag_index, root}); + } + for (auto& [mask, meta] : lines) { + diagram_valid.at(mask).clear(); + diagram_meta.at(mask) = {}; + } + } + auto diagram_offsets = append_state_layout(_cluster_state_machine, diagram_states); + for (auto [diag_index, root] : diagram_roots) { + _diagram_start_states.at(diag_index) = diagram_offsets.at(root); + } +} + +NamedVector MLMClustering::build_function_impl( + FunctionBuilder& fb, const NamedVector& args +) const { + auto start_state = + fb.full({static_cast(0), fb.batch_size({args.at(0)})}); + return build_kernel(fb, args.at(0), start_state, 0, Value(), {}); +} + +NamedVector MLMClustering::build_with_flavors( + FunctionBuilder& fb, + Value momenta, + Value flavor_index, + const std::vector& leg_flavors +) const { + auto start_state = fb.full({static_cast(0), fb.batch_size({momenta})}); + return build_kernel(fb, momenta, start_state, 0, flavor_index, leg_flavors); +} + +NamedVector MLMClustering::build_along_diagram( + FunctionBuilder& fb, Value momenta, Value diagram +) const { + if (_clustering_history == ClusteringHistory::all_diagrams) { + throw std::logic_error( + "a clustering along one diagram needs a clustering_history other " + "than all_diagrams" + ); + } + return build_from_start_state( + fb, momenta, fb.gather_int(diagram, _diagram_start_states) + ); +} + +NamedVector MLMClustering::build_from_start_state( + FunctionBuilder& fb, + Value momenta, + Value start_state, + Value flavor_index, + const std::vector& leg_flavors +) const { + if (_clustering_history == ClusteringHistory::all_diagrams) { + throw std::logic_error( + "a clustering along one diagram needs a clustering_history other " + "than all_diagrams" + ); + } + return build_kernel( + fb, + momenta, + start_state, + static_cast(_clustering_history), + flavor_index, + leg_flavors + ); +} + +NamedVector MLMClustering::build_kernel( + FunctionBuilder& fb, + Value momenta, + Value start_state, + me_int_t history_mode, + Value flavor_index, + const std::vector& leg_flavors +) const { + std::array mlm_out; + Value random = fb.squeeze(fb.random(fb.batch_size({momenta}), 1)); + // Without the flavours of the event a single row of zeros, which leaves + // every clustering allowed: the history over the union of all diagrams. + if (!flavor_index || leg_flavors.empty()) { + flavor_index = fb.full({static_cast(0), fb.batch_size({momenta})}); + } + std::vector flavor_table = leg_flavors.empty() + ? std::vector(_external_masses.size(), 0) + : leg_flavors; + if (_hadronic) { + mlm_out = fb.mlm_clustering_hadronic( + momenta, + random, + start_state, + _cluster_state_machine, + _external_masses, + _bw_masses, + _bw_widths, + _bw_cutoff, + _jet_radius, + _cm_energy, + static_cast(_jet_scale_scheme), + _xqcut, + static_cast(_scale_scheme), + static_cast(_beam_flags), + static_cast(_jet_leg_mask), + static_cast(_parton_line_scheme), + static_cast(_alphas_scheme), + // 2: the ladder under clustering_mean too (see the kernel) + static_cast( + _pdf_reweighting ? (_clustering_mean_pdf_ladder ? 2 : 1) : 0 + ), + static_cast(_clustering_measure), + history_mode, + flavor_index, + flavor_table + ); + } else { + mlm_out = fb.mlm_clustering_leptonic( + momenta, + random, + start_state, + _cluster_state_machine, + _external_masses, + _bw_masses, + _bw_widths, + _bw_cutoff, + _jet_radius, + _cm_energy, + static_cast(_jet_scale_scheme), + _xqcut, + static_cast(_scale_scheme), + static_cast(_beam_flags), + static_cast(_jet_leg_mask), + static_cast(_parton_line_scheme), + static_cast(_alphas_scheme), + // 2: the ladder under clustering_mean too (see the kernel) + static_cast( + _pdf_reweighting ? (_clustering_mean_pdf_ladder ? 2 : 1) : 0 + ), + static_cast(_clustering_measure), + history_mode, + flavor_index, + flavor_table + ); + } + return {return_types().keys(), {mlm_out.begin(), mlm_out.end()}}; +} + +MLMClusteringAlongDiagram::MLMClusteringAlongDiagram(const MLMClustering& clustering) : + FunctionGenerator( + "MLMClusteringAlongDiagram", + [&] { + auto types = clustering.arg_types(); + types.push_back("diagram", batch_int); + return types; + }(), + clustering.return_types() + ), + _clustering(clustering) {} + +NamedVector MLMClusteringAlongDiagram::build_function_impl( + FunctionBuilder& fb, const NamedVector& args +) const { + return _clustering.build_along_diagram(fb, args.at(0), args.at(1)); +} + +MLMClusteringWithFlavors::MLMClusteringWithFlavors( + const MLMClustering& clustering, std::vector leg_flavors +) : + FunctionGenerator( + "MLMClusteringWithFlavors", + [&] { + auto types = clustering.arg_types(); + types.push_back("flavor_index", batch_int); + return types; + }(), + clustering.return_types() + ), + _clustering(clustering), + _leg_flavors(std::move(leg_flavors)) {} + +NamedVector MLMClusteringWithFlavors::build_function_impl( + FunctionBuilder& fb, const NamedVector& args +) const { + return _clustering.build_with_flavors(fb, args.at(0), args.at(1), _leg_flavors); +} diff --git a/madspace/src/phasespace/scale.cpp b/madspace/src/phasespace/scale.cpp index ea9ce17222..3f75ed9b56 100644 --- a/madspace/src/phasespace/scale.cpp +++ b/madspace/src/phasespace/scale.cpp @@ -11,7 +11,9 @@ EnergyScale::EnergyScale( double fact_scale1, double fact_scale2, double scale_factor, - const std::optional& custom_scale + const std::optional& custom_scale, + double min_scale, + double max_scale ) : FunctionGenerator( "EnergyScale", @@ -27,7 +29,9 @@ EnergyScale::EnergyScale( _fact_scale1(fact_scale1), _fact_scale2(fact_scale2), _scale_factor(scale_factor), - _custom_scale(custom_scale) { + _custom_scale(custom_scale), + _min_scale(min_scale), + _max_scale(max_scale) { if (custom_scale && (custom_scale->type() != CustomFunction::scale || custom_scale->dynamic_pdg_ids())) { @@ -37,17 +41,115 @@ EnergyScale::EnergyScale( } } +EnergyScale::EnergyScale( + const MLMClustering& clustering, double min_scale, double max_scale +) : + FunctionGenerator( + "EnergyScale", + clustering.arg_types(), + (min_scale > 0. || max_scale > 0.) + ? [&] { + auto types = clustering.return_types(); + types.push_back("scale_weight", batch_float); + return types; + }() + : clustering.return_types() + ), + _min_scale(min_scale), + _max_scale(max_scale), + _clustering(clustering) {} + +// The range of scales an event may be evaluated at, applied to whatever +// dynamical scale choice produced them. A PDF grid only covers a band in Q - +// 1 to 10000 GeV for NNPDF23, say - and outside it the densities are not +// defined: below, a scale of a few MeV, above, one of several TeV. Either +// comes back as a NaN that no later cut can remove, because the pdf is +// evaluated before any veto weight multiplies it and NaN times zero is still +// NaN. So the scales are clamped into the range, which is what LHAPDF's +// freezing does for madevent. +// +// An MLM clustering also vetoes the event, through the scale_weight output: +// madevent's setclscales drops a merged event whose factorisation scale is +// under 2 GeV (reweight.f) rather than evaluating it at a frozen density. No +// other scale choice has that veto in madevent, so none has it here. The upper +// end has no counterpart there: LHAPDF extrapolates rather than returning a +// hole, so madevent never has to look. +NamedVector EnergyScale::apply_scale_range( + FunctionBuilder& fb, NamedVector scales +) const { + if (_min_scale <= 0. && _max_scale <= 0.) { + return scales; + } + double low = _min_scale > 0. ? _min_scale : 0.; + double high = _max_scale > 0. ? _max_scale : 1e30; + Value weight; + std::vector names{"fact_scale1", "fact_scale2", "ren_scale"}; + if (_clustering && _clustering->pdf_reweighting()) { + // The scale the density is actually asked for under pdf reweighting. + // It sits below the factorisation scale, so clamping that one is not + // enough to keep the density inside the grid, and madevent applies its + // own 2 GeV floor to the lowered scale too rather than to the central + // one. + names.push_back("pdf_scale1"); + names.push_back("pdf_scale2"); + } + for (auto name : names) { + auto& scale = scales.at(name); + if (_clustering) { + auto pass = fb.cut_one(scale, low, high); + weight = weight ? fb.mul(weight, pass) : pass; + } + auto batch_size = fb.batch_size({scale}); + scale = fb.max(scale, fb.full({low, batch_size})); + scale = fb.min(scale, fb.full({high, batch_size})); + } + if (weight) { + scales.push_back("scale_weight", weight); + } + return scales; +} + +NamedVector EnergyScale::build_mlm_from_start_state( + FunctionBuilder& fb, + Value momenta, + Value start_state, + Value flavor_index, + const std::vector& leg_flavors +) const { + return apply_scale_range( + fb, + _clustering.value().build_from_start_state( + fb, momenta, start_state, flavor_index, leg_flavors + ) + ); +} + +NamedVector EnergyScale::build_mlm_with_flavors( + FunctionBuilder& fb, + Value momenta, + Value flavor_index, + const std::vector& leg_flavors +) const { + return apply_scale_range( + fb, + _clustering.value().build_with_flavors(fb, momenta, flavor_index, leg_flavors) + ); +} + NamedVector EnergyScale::build_function_impl( FunctionBuilder& fb, const NamedVector& args ) const { auto momenta = args.at(0); + if (_clustering) { + return apply_scale_range(fb, _clustering.value().build_function(fb, args)); + } if (_ren_scale_fixed && _fact_scale_fixed) { auto batch_size = fb.batch_size({momenta}); - return { + return apply_scale_range(fb, { {"ren_scale", fb.full({_ren_scale, batch_size})}, {"fact_scale1", fb.full({_fact_scale1, batch_size})}, {"fact_scale2", fb.full({_fact_scale2, batch_size})}, - }; + }); } auto batch_size = fb.batch_size({momenta}); Value ren_scale, fact_scale1, fact_scale2; @@ -85,11 +187,11 @@ NamedVector EnergyScale::build_function_impl( fact_scale1 = fb.mul(fact_scale1, _scale_factor); fact_scale2 = fb.mul(fact_scale2, _scale_factor); } - return { + return apply_scale_range(fb, { {"ren_scale", _ren_scale_fixed ? fb.full({_ren_scale, batch_size}) : ren_scale}, {"fact_scale1", _fact_scale_fixed ? fb.full({_fact_scale1, batch_size}) : fact_scale1}, {"fact_scale2", _fact_scale_fixed ? fb.full({_fact_scale2, batch_size}) : fact_scale2} - }; + }); } diff --git a/madspace/src/phasespace/topology.cpp b/madspace/src/phasespace/topology.cpp index b1216c9833..b0896cfb31 100644 --- a/madspace/src/phasespace/topology.cpp +++ b/madspace/src/phasespace/topology.cpp @@ -17,6 +17,7 @@ bool find_t_vertices( std::vector& integration_order, std::vector& _t_propagator_masses, std::vector& _t_propagator_widths, + std::vector& _t_propagator_pdg_ids, std::size_t current_index, int source_propagator ) { @@ -49,6 +50,7 @@ bool find_t_vertices( integration_order, _t_propagator_masses, _t_propagator_widths, + _t_propagator_pdg_ids, vertices.at(0) == current_index ? vertices.at(1) : vertices.at(0), @@ -60,6 +62,7 @@ bool find_t_vertices( auto& propagator = diagram.propagators().at(line_ref.index()); _t_propagator_masses.push_back(propagator.mass); _t_propagator_widths.push_back(propagator.width); + _t_propagator_pdg_ids.push_back(propagator.pdg_id); } else { out_lines.push_back(line_ref); } @@ -332,6 +335,7 @@ std::vector Topology::topologies(const Diagram& diagram) { integration_order, topo._t_propagator_masses, topo._t_propagator_widths, + topo._t_propagator_pdg_ids, diagram.incoming_vertices().at(1), -1 ); diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index 65c5300551..9a46c0c1a1 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -1896,6 +1896,132 @@ PYBIND11_MODULE(_madspace_py, m) { pydoc::doc("DiscreteFlow::initialize_globals") ); + py::classh mlm(m, "MLMClustering"); + add_enum( + mlm, + "ScaleScheme", + { + {"clustering_mean", ScaleScheme::clustering_mean}, + {"madevent", ScaleScheme::madevent}, + } + ); + add_enum( + mlm, + "JetScaleScheme", + { + {"emission", JetScaleScheme::emission}, + {"production", JetScaleScheme::production}, + } + ); + add_enum( + mlm, + "AlphasScheme", + { + {"none", AlphasScheme::none}, + {"per_vertex", AlphasScheme::per_vertex}, + {"geometric_mean", AlphasScheme::geometric_mean}, + } + ); + add_enum( + mlm, + "ClusteringMeasure", + { + {"fxfx", ClusteringMeasure::fxfx}, + {"madevent", ClusteringMeasure::madevent}, + } + ); + add_enum( + mlm, + "ClusteringHistory", + { + {"all_diagrams", ClusteringHistory::all_diagrams}, + {"diagram", ClusteringHistory::diagram}, + {"madevent", ClusteringHistory::madevent}, + } + ); + add_enum( + mlm, + "PartonLineScheme", + { + {"flavor", PartonLineScheme::flavor}, + {"goodjet", PartonLineScheme::goodjet}, + } + ); + mlm + .def( + py::init< + std::vector, + nested_vector3, + nested_vector2, + double, + JetScaleScheme, + ScaleScheme, + std::unordered_map, + double, + double, + double, + bool, + std::vector, + int, + PartonLineScheme, + AlphasScheme, + bool, + ClusteringMeasure, + ClusteringHistory, + bool>(), + py::arg("topologies"), + py::arg("permutations"), + py::arg("diagram_indices"), + py::arg("cm_energy"), + py::arg("jet_scale_scheme") = JetScaleScheme::production, + py::arg("scale_scheme") = ScaleScheme::clustering_mean, + py::arg("pdg_color_types") = std::unordered_map{}, + py::arg("xqcut") = 0., + py::arg("bw_cutoff") = 15., + py::arg("jet_radius") = 1.0, + py::arg("hadronic") = true, + py::arg("external_pdg_ids") = std::vector{}, + py::arg("max_jet_flavor") = 4, + py::arg("parton_line_scheme") = PartonLineScheme::goodjet, + py::arg("alphas_scheme") = AlphasScheme::per_vertex, + py::arg("pdf_reweighting") = true, + py::arg("clustering_measure") = ClusteringMeasure::fxfx, + py::arg("clustering_history") = ClusteringHistory::all_diagrams, + py::arg("clustering_mean_pdf_ladder") = true + ) + .def_property_readonly( + "pdf_absolute_pdgs", &MLMClustering::pdf_absolute_pdgs + ) + .def_property_readonly( + "clustering_measure", &MLMClustering::clustering_measure + ) + .def_property_readonly( + "clustering_history", &MLMClustering::clustering_history + ) + .def_property_readonly( + "diagram_start_states", &MLMClustering::diagram_start_states + ) + .def_property_readonly( + "cluster_state_machine", &MLMClustering::cluster_state_machine + ) + .def_property_readonly("external_masses", &MLMClustering::external_masses) + .def_property_readonly("bw_masses", &MLMClustering::bw_masses) + .def_property_readonly("bw_widths", &MLMClustering::bw_widths); + + py::classh( + m, "MLMClusteringAlongDiagram" + ) + .def(py::init(), py::arg("clustering")); + + py::classh( + m, "MLMClusteringWithFlavors" + ) + .def( + py::init>(), + py::arg("clustering"), + py::arg("leg_flavors") + ); + py::classh( m, "VegasGridOptimizer", pydoc::doc("VegasGridOptimizer") ) @@ -2182,7 +2308,9 @@ PYBIND11_MODULE(_madspace_py, m) { double, double, double, - const std::optional&>(), + const std::optional&, + double, + double>(), py::arg("particle_count"), py::arg("dynamical_scale_type"), py::arg("ren_scale_fixed"), @@ -2192,8 +2320,18 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("fact_scale2"), py::arg("scale_factor") = 1., py::arg("custom_scale") = std::nullopt, + py::arg("min_scale") = 0., + py::arg("max_scale") = 0., pydoc::doc("EnergyScale::EnergyScale#4") - ); + ) + .def( + py::init(), + py::arg("clustering"), + py::arg("min_scale") = 0., + py::arg("max_scale") = 0., + pydoc::doc("EnergyScale::EnergyScale#5") + ) + .def("is_mlm", &EnergyScale::is_mlm, pydoc::doc("EnergyScale::is_mlm")); py::classh( m, "CachedPdf", pydoc::doc("DifferentialCrossSection::CachedPdf") @@ -2306,7 +2444,8 @@ PYBIND11_MODULE(_madspace_py, m) { const std::vector&, std::size_t, const std::optional&, - const std::optional&>(), + const std::optional&, + int>(), py::arg("mapping"), py::arg("diff_xs"), py::arg("adaptive_map") = std::monostate{}, @@ -2337,6 +2476,7 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("compressed_channel_weight_count") = 50, py::arg("custom_bias") = std::nullopt, py::arg("pdf_grid2") = std::nullopt, + py::arg("mlm_history_qcd_power") = -1, pydoc::doc("Integrand::Integrand") ) .def( @@ -3453,6 +3593,24 @@ PYBIND11_MODULE(_madspace_py, m) { .def_readwrite( "muf", &SystematicsConfig::muf, pydoc::doc("SystematicsConfig::muf") ) + .def_readwrite( + "alps", &SystematicsConfig::alps, pydoc::doc("SystematicsConfig::alps") + ) + .def_readwrite( + "pdf_ladder", + &SystematicsConfig::pdf_ladder, + pydoc::doc("SystematicsConfig::pdf_ladder") + ) + .def_readwrite( + "ladder_min_scale", + &SystematicsConfig::ladder_min_scale, + pydoc::doc("SystematicsConfig::ladder_min_scale") + ) + .def_readwrite( + "ladder_max_scale", + &SystematicsConfig::ladder_max_scale, + pydoc::doc("SystematicsConfig::ladder_max_scale") + ) .def_readwrite( "together", &SystematicsConfig::together, @@ -3552,8 +3710,14 @@ PYBIND11_MODULE(_madspace_py, m) { "pdf_index", &Variation::pdf_index, pydoc::doc("Variation::pdf_index") ) .def_readonly("dyn", &Variation::dyn, pydoc::doc("Variation::dyn")) + .def_readonly("alps", &Variation::alps, pydoc::doc("Variation::alps")) .def_property_readonly( "is_scale", &Variation::is_scale, pydoc::doc("Variation::is_scale") + ) + .def_property_readonly( + "is_emission", + &Variation::is_emission, + pydoc::doc("Variation::is_emission") ); py::classh(m, "PdfGroupInfo", pydoc::doc("PdfGroupInfo")) .def_readonly( @@ -3603,6 +3767,11 @@ PYBIND11_MODULE(_madspace_py, m) { &SystematicsCalculator::scale_variation_indices, pydoc::doc("SystematicsCalculator::scale_variation_indices") ) + .def_property_readonly( + "emission_variation_indices", + &SystematicsCalculator::emission_variation_indices, + pydoc::doc("SystematicsCalculator::emission_variation_indices") + ) .def_property_readonly( "pdf_groups", &SystematicsCalculator::pdf_groups, @@ -3671,20 +3840,39 @@ PYBIND11_MODULE(_madspace_py, m) { const std::vector& fact_scale2, const std::vector& partial_weight_product, const nested_vector3& momenta, - const std::vector& alpha_qcd) { + const std::vector& alpha_qcd, + const nested_vector2& alphas_scales, + const nested_vector3& ladder, + const nested_vector2& ladder_lead) { // build a combined-layout buffer from columns (testing / scripting) std::size_t count = event_weight.size(); std::size_t particle_count = momenta.empty() ? 0 : momenta.at(0).size(); + // MLM vertex v rides on outgoing particle 2 + v + for (auto& row : alphas_scales) { + particle_count = std::max(particle_count, row.size() + 3); + } + // the pdf ladder: step s ([x, pdg, q, info]) on particle s, + // and [q_lead1, q_lead2] per event + for (auto& steps : ladder) { + particle_count = std::max(particle_count, steps.size()); + } + if (!ladder_lead.empty()) { + particle_count = std::max(particle_count, 2); + } + int particle_flags = + (momenta.empty() ? ParticleRecord::f_none + : ParticleRecord::f_particle_data) | + (alphas_scales.empty() ? ParticleRecord::f_none + : ParticleRecord::f_alphas_scales) | + (ladder_lead.empty() ? ParticleRecord::f_none + : ParticleRecord::f_pdf_ladder); DataLayout layout( EventRecord::layout( EventRecord::f_weight | EventRecord::f_subproc_index | EventRecord::f_event_data | EventRecord::f_beam1 | EventRecord::f_beam2 | EventRecord::f_partial_weights ), - ParticleRecord::layout( - particle_count > 0 ? ParticleRecord::f_particle_data - : ParticleRecord::f_none - ) + ParticleRecord::layout(particle_flags) ); EventBuffer buffer(count, particle_count, layout); for (std::size_t i = 0; i < count; ++i) { @@ -3702,7 +3890,9 @@ PYBIND11_MODULE(_madspace_py, m) { event.x2() = x2.at(i); event.fact_scale2() = fact_scale2.at(i); event.partial_weight_product() = partial_weight_product.at(i); - for (std::size_t j = 0; j < particle_count; ++j) { + for (std::size_t j = 0; + !momenta.empty() && j < momenta.at(i).size(); + ++j) { auto particle = buffer.particle(i, j); auto& p = momenta.at(i).at(j); particle.energy() = p.at(0); @@ -3710,6 +3900,25 @@ PYBIND11_MODULE(_madspace_py, m) { particle.py() = p.at(2); particle.pz() = p.at(3); } + for (std::size_t j = 0; !alphas_scales.empty() && j < particle_count; + ++j) { + auto& row = alphas_scales.at(i); + buffer.particle(i, j).alphas_scale() = + j >= 2 && j - 2 < row.size() ? row.at(j - 2) : 0.; + } + for (std::size_t j = 0; !ladder_lead.empty() && j < particle_count; + ++j) { + auto particle = buffer.particle(i, j); + auto& lead = ladder_lead.at(i); + particle.ladder_lead_q() = j < 2 ? lead.at(j) : 0.; + bool has_step = !ladder.empty() && j < ladder.at(i).size(); + auto step = has_step ? ladder.at(i).at(j) + : std::vector(4, 0.); + particle.ladder_x() = step.at(0); + particle.ladder_pdg() = step.at(1); + particle.ladder_q() = step.at(2); + particle.ladder_info() = step.at(3); + } } std::vector weights; calc.compute(buffer, weights); @@ -3733,7 +3942,10 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("fact_scale2"), py::arg("partial_weight_product"), py::arg("momenta") = nested_vector3{}, - py::arg("alpha_qcd") = std::vector{} + py::arg("alpha_qcd") = std::vector{}, + py::arg("alphas_scales") = nested_vector2{}, + py::arg("ladder") = nested_vector3{}, + py::arg("ladder_lead") = nested_vector2{} ); py::classh( m, "EventHistogramSpec", pydoc::doc("EventHistogramSpec") diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py new file mode 100644 index 0000000000..2e1f2fb366 --- /dev/null +++ b/madspace/tests/test_mlm.py @@ -0,0 +1,2767 @@ +"""Tests for the MLM back-clustering (madspace/src/phasespace/mlm_clustering.cpp +and madspace/src/kernels/mlm.hpp). + +The clustering compiler turns the diagram topologies of a subprocess into a +state machine of possible clustering histories; the kernel walks that state +machine for one phase-space point, picks a history, and reads the +renormalisation / factorisation / per-jet scales off it. + +There is no single "right answer" to compare against (see the discussion in the +branch notes: the resonance handling and the factorisation-scale definition +differ from what madevent does on purpose), so these tests pin down the +properties the implementation must have whatever those choices end up being: + + * the compiled state machine is well formed and reachable, + * the kernel's outputs are finite, positive and ordered as documented, + * the clustering is invariant under the symmetries of the phase-space point, + * the individual clustering measures agree with the Fortran ones they were + ported from (Template/NLO/SubProcesses/cluster.f), + * resonant propagators are recognised inside the Breit-Wigner window. +""" + +import json +import os + +import numpy as np +import pytest + +import madspace as ms + +CM_ENERGY = 13000.0 +BATCH_SIZE = 200 +SEED = 5678 + +M_TOP = 173.0 +M_Z, W_Z = 91.188, 2.4414 + +TEST_DATA = os.path.join(os.path.dirname(os.path.realpath(__file__)), "test_data") + +# g g > t t~ g g and g g > t t~ g g g, i.e. legs (g, g, t, t~, g, ...) +PROCESSES = { + "ttgg": ("ttgg.json", [21, 21, 6, -6, 21, 21]), + "ttggg": ("ttggg.json", [21, 21, 6, -6, 21, 21, 21]), +} + + +# -------------------------------------------------------------------------- +# fixtures / helpers +# -------------------------------------------------------------------------- + + +def load_diagrams(file_name): + with open(os.path.join(TEST_DATA, file_name)) as f: + return json.load(f) + + +def make_topologies(diagrams): + return [ + ms.Topology( + ms.Diagram( + d["incoming_masses"], + d["outgoing_masses"], + [ms.Propagator(*p) for p in d["propagators"]], + d["vertices"], + ) + ) + for d in diagrams + ] + + +def make_diagram_indices(diagrams): + """One running index per (topology, permutation) pair, matching what + launch.py hands to MLMClustering.""" + offset = 0 + indices = [] + for d in diagrams: + count = len(d["permutations"]) + indices.append(list(range(offset, offset + count))) + offset += count + return indices + + +def make_clustering(diagrams, **kwargs): + kwargs.setdefault("cm_energy", CM_ENERGY) + return ms.MLMClustering( + make_topologies(diagrams), + [d["permutations"] for d in diagrams], + make_diagram_indices(diagrams), + **kwargs, + ) + + +def sample_momenta(diagrams, batch_size=BATCH_SIZE, seed=SEED): + """Phase-space points for the first topology of the process.""" + rng = np.random.default_rng(seed) + topology = make_topologies(diagrams)[0] + permutations = diagrams[0]["permutations"] + mapping = ms.PhaseSpaceMapping(topology, CM_ENERGY, permutations=permutations) + r = rng.random((batch_size, mapping.random_dim())) + condition = ( + [] + if len(permutations) <= 1 + else [rng.integers(0, len(permutations), batch_size, dtype=np.int32)] + ) + p_ext, *_ = mapping.map_forward([r], condition) + return np.asarray(p_ext) + + +@pytest.fixture(params=sorted(PROCESSES)) +def process(request): + file_name, pdg_ids = PROCESSES[request.param] + diagrams = load_diagrams(file_name) + return request.param, diagrams, pdg_ids + + +def run(clustering, momenta): + """The scale outputs of a clustering, as numpy arrays: ren_scale, + fact_scale1, fact_scale2, outgoing_scales, diagram_index.""" + return run_all(clustering, momenta)[:5] + + +def run_all(clustering, momenta): + """As run(), plus the trailing xqcut_weight. + + Deliberately stops there: the clustering also returns the per-vertex + alpha_s scales and their weight, which these tests are not about, and + unpacking everything would break every caller each time an output is + added. Use alphas_outputs() for those. + """ + return tuple(np.asarray(v) for v in clustering(momenta))[:6] + + +def alphas_outputs(clustering, momenta): + """The alpha_s reweighting outputs: per-vertex scales, and the weight that + drops an event whose vertices sit where the coupling is not usable.""" + out = clustering(momenta) + return np.asarray(out[6]), np.asarray(out[7]) + + +PDF_OUTPUTS = [ + "pdf_scale1", + "pdf_scale2", + "pdf_rw_flavor", + "pdf_rw_x", + "pdf_rw_q_num", + "pdf_rw_q_den", + "pdf_rw_active", + "pdf_rw_beam", + "pdf_rw_x_prev", +] + + +def pdf_outputs(clustering, momenta): + """The pdf reweighting outputs, by name: the scale each beam density is to + be evaluated at, and one slot per step of the ladder it is walked back up. + Named rather than positional because there are eight of them and their + order is not something a test should be pinning down.""" + out = clustering(momenta) + first = len(out) - len(PDF_OUTPUTS) + return { + name: np.asarray(value) + for name, value in zip(PDF_OUTPUTS, list(out)[first:]) + } + + +def assert_jet_scales_agree(reference, other, max_flip_fraction=0.05): + """Compare two sets of per-jet clustering scales that should be identical. + + Which clustering wins a step is an argmin over the candidate measures, and + processes with identical final-state partons produce exactly degenerate + candidates. Recomputing the measures on a symmetry-transformed event + perturbs them at the level of floating-point noise, which can flip the + argmin and hand the scale to the other member of the degenerate pair. The + set of clustering *scales* is unchanged (so mu_R and mu_F are), only which + leg they are booked against, and it happens for a small fraction of events. + """ + matches = np.isclose(reference, other, rtol=1e-6, atol=1e-6) + flipped = np.mean(~matches.all(axis=1)) + assert flipped <= max_flip_fraction, ( + f"{flipped:.1%} of events changed their jet-scale assignment, " + f"more than the {max_flip_fraction:.0%} expected from degenerate " + "clustering candidates" + ) + + +# -------------------------------------------------------------------------- +# construction +# -------------------------------------------------------------------------- + + +def test_construction_succeeds(process): + """Every process in the test data compiles into a state machine. The + constructor throws on an unreachable or dead-ended state, so this also + covers the structure of the compiled machine.""" + _, diagrams, pdg_ids = process + assert make_clustering(diagrams) is not None + assert make_clustering(diagrams, external_pdg_ids=pdg_ids) is not None + + +def test_pdg_id_count_is_checked(process): + """A wrong number of external pdg ids is a caller error, not something to + silently truncate.""" + _, diagrams, pdg_ids = process + with pytest.raises(Exception): + make_clustering(diagrams, external_pdg_ids=pdg_ids[:-1]) + + +def test_flavor_information_selects_which_legs_are_jets(process): + """Only jets get a real clustering scale. Without pdg ids the clustering has + to assume every leg is one; with them, the tops (legs 2 and 3 of these + processes) never are and always fall back to sqrt(s).""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + *_, out_no_pdg, _ = run(make_clustering(diagrams), momenta) + *_, out_pdg, _ = run( + make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta + ) + # the tops are not jets, so with flavor information they never get one + assert np.all(out_pdg[:, 0] == CM_ENERGY) + assert np.all(out_pdg[:, 1] == CM_ENERGY) + # ... whereas without it they do, at least sometimes + assert np.any(out_no_pdg[:, :2] < CM_ENERGY) + # the gluons are jets either way and usually pick up a real scale + assert np.mean(out_pdg[:, 2:].min(axis=1) < CM_ENERGY) > 0.5 + + +def test_unclustered_legs_fall_back_to_the_collider_energy(process): + """Every outgoing leg is reported at a scale, never at zero. + + pt_clust is what an MLM veto compares against qcut, so a leg that no QCD + clustering assigned a scale to has to come out at a value no veto can trip + on. madevent does the same, via the "ptclus = etot" fallback in + Template/LO/SubProcesses/reweight.f. + """ + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams, batch_size=2000, seed=4321) + for clustering in ( + make_clustering(diagrams), + make_clustering(diagrams, external_pdg_ids=pdg_ids), + ): + *_, out, _ = run(clustering, momenta) + assert np.all(out > 0.0) + assert np.all(out <= CM_ENERGY) + # the fallback value appears exactly, not as a rounded-down scale + fallback = out == CM_ENERGY + assert fallback.any() + + +def test_the_fallback_value_follows_the_collider_energy(process): + """The fallback is the collider energy the clustering was built with, so + changing it moves only the unassigned legs.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + *_, out, _ = run( + make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta + ) + *_, out_half, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, cm_energy=CM_ENERGY / 2 + ), + momenta, + ) + assigned = out != CM_ENERGY + assert np.array_equal(out[assigned], out_half[assigned]) + assert np.all(out_half[~assigned] == CM_ENERGY / 2) + + +# -------------------------------------------------------------------------- +# kernel outputs +# -------------------------------------------------------------------------- + + +def test_scales_are_finite_and_positive(process): + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + ren, fac1, fac2, out_scales, diagram = run( + make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta + ) + for name, value in [("ren", ren), ("fac1", fac1), ("fac2", fac2)]: + assert np.all(np.isfinite(value)), f"{name} scale not finite" + assert np.all(value > 0.0), f"{name} scale not positive" + assert np.all(np.isfinite(out_scales)) + assert np.all(out_scales >= 0.0) + + +def test_scales_are_below_the_collider_energy(process): + """No clustering scale can exceed the collider energy.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + ren, fac1, fac2, out_scales, _ = run( + make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta + ) + for value in (ren, fac1, fac2, out_scales): + assert np.all(value <= CM_ENERGY) + + +def test_factorization_scale_does_not_exceed_renormalization_scale(process): + """The kernel caps mu_F at mu_R, and uses one factorisation scale for both + beams.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + ren, fac1, fac2, _, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, + clustering_mean_pdf_ladder=False, + ), momenta + ) + assert np.array_equal(fac1, fac2) + assert np.all(fac1 <= ren * (1.0 + 1e-12)) + + +def test_diagram_index_is_in_range(process): + """The selected diagram index has to be one of the indices handed to the + constructor, since it is passed straight to the matrix element.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + *_, diagram = run( + make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta + ) + total = sum(len(d["permutations"]) for d in diagrams) + assert diagram.min() >= 0 + assert diagram.max() < total + + +def test_batch_entries_are_independent(process): + """Each phase-space point is clustered on its own: running a subset of a + batch must give the same answer as running the whole batch. + + The kernel keeps per-event scratch arrays (momenta_tmp, masses_tmp, the + cluster history), so this catches state leaking between batch entries. + """ + _, diagrams, pdg_ids = process + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + momenta = sample_momenta(diagrams) + ren_full, fac_full, _, out_full, _ = run(clustering, momenta) + # the diagram index draws a random number, so only compare the scales + ren_half, fac_half, _, out_half, _ = run(clustering, momenta[: BATCH_SIZE // 2]) + n = BATCH_SIZE // 2 + assert np.array_equal(ren_full[:n], ren_half) + assert np.array_equal(fac_full[:n], fac_half) + assert np.array_equal(out_full[:n], out_half) + + +def test_longitudinal_boosts_barely_change_the_scales(process): + """The clustering measures themselves are longitudinally boost invariant - + they are built from mT, pseudorapidity differences and azimuthal angles - + but the *choice* of clustering is not. + + compute_scale multiplies an initial-state candidate by 1.000001 when the + emitted parton goes against the beam it is clustered onto, tested as + sign(pz1) != sign(pz2). That sign is not boost invariant, so a boost can + flip which candidate wins for events where two candidates are close. This is + inherited from the Fortran, which uses the same test. + + So: pin that the effect stays small rather than asserting exact invariance. + """ + _, diagrams, pdg_ids = process + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, + clustering_mean_pdf_ladder=False, + ) + momenta = sample_momenta(diagrams) + + rapidity = 0.3 + cosh_y, sinh_y = np.cosh(rapidity), np.sinh(rapidity) + boosted = momenta.copy() + energy, pz = momenta[..., 0], momenta[..., 3] + boosted[..., 0] = cosh_y * energy + sinh_y * pz + boosted[..., 3] = sinh_y * energy + cosh_y * pz + + ren, fac, _, out, _ = run(clustering, momenta) + ren_b, fac_b, _, out_b, _ = run(clustering, boosted) + + unchanged = np.isclose(ren_b, ren, rtol=1e-9) + assert unchanged.mean() > 0.8, ( + f"only {unchanged.mean():.0%} of events keep their renormalisation " + "scale under a longitudinal boost" + ) + # where the history is unchanged, the scales have to agree to full precision + assert ren_b[unchanged] == pytest.approx(ren[unchanged], rel=1e-9) + assert fac_b[unchanged] == pytest.approx(fac[unchanged], rel=1e-9) + assert_jet_scales_agree(out[unchanged], out_b[unchanged]) + + +def test_scales_are_azimuthally_invariant(process): + """Rotating the event around the beam axis must not change any scale.""" + _, diagrams, pdg_ids = process + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, + clustering_mean_pdf_ladder=False, + ) + momenta = sample_momenta(diagrams) + + angle = 0.7 + cos_a, sin_a = np.cos(angle), np.sin(angle) + rotated = momenta.copy() + px, py = momenta[..., 1], momenta[..., 2] + rotated[..., 1] = cos_a * px - sin_a * py + rotated[..., 2] = sin_a * px + cos_a * py + + ren, fac, _, out, _ = run(clustering, momenta) + ren_r, fac_r, _, out_r, _ = run(clustering, rotated) + assert ren_r == pytest.approx(ren, rel=1e-9) + assert fac_r == pytest.approx(fac, rel=1e-9) + assert_jet_scales_agree(out, out_r) + + +# The schemes whose factorisation scale comes out of madevent's beam-line walk +# (setclscales in Template/LO/SubProcesses/reweight.f) rather than from the +# softest clustering. +BEAM_LINE_SCHEMES = { + "madevent": {"scale_scheme": "madevent"}, + "clustering_mean_pdf_ladder": {"clustering_mean_pdf_ladder": True}, +} + +# What the beam-line walk hands the pdf reweighting, all of it built from +# transverse masses and invariants. +LADDER_OUTPUTS = ["pdf_scale1", "pdf_scale2", "pdf_rw_x", "pdf_rw_q_num", "pdf_rw_q_den"] + + +def rotate_about_beam(momenta, angle): + cos_a, sin_a = np.cos(angle), np.sin(angle) + rotated = momenta.copy() + px, py = momenta[..., 1], momenta[..., 2] + rotated[..., 1] = cos_a * px - sin_a * py + rotated[..., 2] = sin_a * px + cos_a * py + return rotated + + +@pytest.mark.parametrize("scheme", sorted(BEAM_LINE_SCHEMES)) +def test_beam_line_scales_are_azimuthally_invariant(process, scheme): + """The same for the schemes that walk the beam lines, which are more + sensitive to the choice of history: which beam takes a parton decides where + that beam's line stops being a QCD line, and with it mu_F. + + Equal measures are common there. With two massless partons left in the + final state they balance in transverse momentum, so clustering either one + into the beam it travels along scores the same. Settled by rounding, a + rotation moved mu_F from 832 to 1195 GeV for one ttgg event in 200; the + tie now goes the way cluster.f's loop order takes it, so nothing may + change, not even which leg a jet scale is booked against. + """ + _, diagrams, pdg_ids = process + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, **BEAM_LINE_SCHEMES[scheme] + ) + momenta = sample_momenta(diagrams) + for angle in (0.7, -2.5): + rotated = rotate_about_beam(momenta, angle) + ren, fac1, fac2, out, diagram = run(clustering, momenta) + ren_r, fac1_r, fac2_r, out_r, diagram_r = run(clustering, rotated) + assert ren_r == pytest.approx(ren, rel=1e-9) + assert fac1_r == pytest.approx(fac1, rel=1e-9) + assert fac2_r == pytest.approx(fac2, rel=1e-9) + assert np.array_equal(diagram_r, diagram) + assert_jet_scales_agree(out, out_r, max_flip_fraction=0.0) + ladder = pdf_outputs(clustering, momenta) + ladder_r = pdf_outputs(clustering, rotated) + for name in LADDER_OUTPUTS: + assert ladder_r[name] == pytest.approx(ladder[name], rel=1e-9), name + + +@pytest.mark.parametrize("scheme", sorted(BEAM_LINE_SCHEMES)) +def test_beam_line_scales_barely_change_under_longitudinal_boosts(process, scheme): + """As test_longitudinal_boosts_barely_change_the_scales, for the schemes + that walk the beam lines: the measures and the transverse masses the walk + reads are boost invariant, but the 1 + 1e-6 against-the-beam penalty is + not, so a boost may change the history of an event with two close + candidates. Where it leaves the history alone, everything has to agree. + """ + _, diagrams, pdg_ids = process + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, **BEAM_LINE_SCHEMES[scheme] + ) + momenta = sample_momenta(diagrams) + + rapidity = 0.3 + cosh_y, sinh_y = np.cosh(rapidity), np.sinh(rapidity) + boosted = momenta.copy() + energy, pz = momenta[..., 0], momenta[..., 3] + boosted[..., 0] = cosh_y * energy + sinh_y * pz + boosted[..., 3] = sinh_y * energy + cosh_y * pz + + ren, fac1, fac2, out, diagram = run(clustering, momenta) + ren_b, fac1_b, fac2_b, out_b, diagram_b = run(clustering, boosted) + alphas, _ = alphas_outputs(clustering, momenta) + alphas_b, _ = alphas_outputs(clustering, boosted) + + # The history, step by step. The vertex scales alone do not tell: when the + # penalty moves from one beam to the other, the parton goes to the other + # beam at the same unpenalised scale, and the later steps that move are + # often non-QCD ones, which carry no alpha_s scale. + unchanged = (diagram_b == diagram) & np.isclose(alphas_b, alphas, rtol=1e-9).all( + axis=1 + ) + assert unchanged.mean() > 0.8, ( + f"only {unchanged.mean():.0%} of events keep their clustering history " + "under a longitudinal boost" + ) + assert ren_b[unchanged] == pytest.approx(ren[unchanged], rel=1e-9) + assert fac1_b[unchanged] == pytest.approx(fac1[unchanged], rel=1e-9) + assert fac2_b[unchanged] == pytest.approx(fac2[unchanged], rel=1e-9) + assert_jet_scales_agree(out[unchanged], out_b[unchanged]) + + +def test_scales_are_homogeneous_for_a_single_clustering(): + """Every clustering measure has mass dimension one, so scaling all momenta + by a factor scales every returned scale by the same factor. + + This is exact only as long as update_momenta is not involved: it keeps the + Fortran's absolute 100 GeV^2 guard against boosting into a degenerate frame + ("prevent too extreme boost" in Template/NLO/SubProcesses/cluster.f), which + is not scale free. A 2 -> 2 process needs a single clustering, whose + update_momenta result is never used again, so there the homogeneity is + exact. + """ + diagrams = [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0], + "propagators": [[0.0, 0.0]], + "vertices": [["i0", "o0", "p0"], ["p0", "o1", "i1"]], + "permutations": [[0, 1, 2, 3]], + } + ] + pdg_ids = [21, 21, 21, 21] + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + momenta = sample_momenta(diagrams, batch_size=128, seed=11) + factor = 3.0 + ren, fac, _, out, _ = run(clustering, momenta) + ren_s, fac_s, _, out_s, _ = run(clustering, momenta * factor) + # the only measure available here is djb_clus = (E - pz)(E + pz), which + # cancels badly for forward partons, so this cannot be pushed much below + # 1e-6 even though the relation is exact in real arithmetic + assert ren_s == pytest.approx(ren * factor, rel=1e-6) + assert fac_s == pytest.approx(fac * factor, rel=1e-6) + # the sqrt(s) fallback is a fixed constant, so only the legs that were + # actually assigned a clustering scale scale with the momenta + assigned = (out != CM_ENERGY) & (out_s != CM_ENERGY) + assert_jet_scales_agree( + np.where(assigned, out * factor, 0.0), + np.where(assigned, out_s, 0.0), + max_flip_fraction=0.3, + ) + + +def test_scales_mostly_scale_with_the_momenta(process): + """The same for a multi-step clustering, where the absolute boost guard in + update_momenta breaks exact homogeneity for the events that cross it. + + Pin how often that happens: the guard is a deliberate numerical safeguard, + but a large fraction here would mean the scales depend on the overall energy + of the event far more than intended. + """ + _, diagrams, _ = process + # drop the top masses so nothing else sets an absolute scale + massless = json.loads(json.dumps(diagrams)) + for d in massless: + d["outgoing_masses"] = [0.0] * len(d["outgoing_masses"]) + pdg_massless = [21] * (len(massless[0]["outgoing_masses"]) + 2) + + clustering = make_clustering(massless, external_pdg_ids=pdg_massless) + momenta = sample_momenta(massless) + factor = 2.0 + ren, _, _, _, _ = run(clustering, momenta) + ren_s, _, _, _, _ = run(clustering, momenta * factor) + + ratio = ren_s / (ren * factor) + exact = np.isclose(ratio, 1.0, rtol=1e-9) + assert np.median(ratio) == pytest.approx(1.0, rel=1e-9) + assert exact.mean() > 0.5, ( + f"only {exact.mean():.0%} of events scale exactly; the absolute boost " + "guard in update_momenta should affect far fewer" + ) + + +def test_renormalization_scale_is_a_geometric_mean(process): + """mu_R is the geometric mean of the clustering scales, so it lies between + the smallest and the largest of them. mu_F is the smallest QCD clustering + scale (capped at mu_R), which bounds mu_R from below.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + ren, fac, _, out, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, + clustering_mean_pdf_ladder=False, + ), momenta + ) + assert np.all(ren >= fac * (1.0 - 1e-12)) + # mu_F is the smallest QCD clustering scale, so every jet scale - each of + # which is a QCD clustering scale - is at least as large + assigned = out != CM_ENERGY + smallest_jet_scale = np.where(assigned, out, np.inf).min(axis=1) + assert np.all(smallest_jet_scale >= fac * (1.0 - 1e-9)) + + +# -------------------------------------------------------------------------- +# resonances +# -------------------------------------------------------------------------- + + +M_B = 4.7 + + +def z_to_bb_diagram(width): + """u u~ > Z(> b b~) g g with the Z either given its physical width, so it + can be recognised as a resonance, or a zero width, so it cannot. + + The b quarks are massive on purpose. compute_scale returns the invariant + mass of the pair both when the clustering is resonant and when the parent is + massive and both children are massless (itype 8 in the Fortran), so a + resonance decaying to massless particles cannot tell the two branches apart. + With massive children the non-resonant case falls through to the kt measure + instead, and the resonance branch becomes observable. + + Legs: (u, u~, b, b~, g, g); the Z propagator recombines legs 2 and 3. + """ + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [M_B, M_B, 0.0, 0.0], + # p0 = Z (b b~), p1/p2 = t-channel quark lines + "propagators": [[M_Z, width], [0.0, 0.0], [0.0, 0.0]], + "vertices": [ + ["o0", "o1", "p0"], + ["i0", "o2", "p1"], + ["p1", "o3", "p2"], + ["p2", "p0", "i1"], + ], + "permutations": [[0, 1, 2, 3, 4, 5]], + } + ] + + +Z_TO_BB_PDGS = [2, -2, 5, -5, 21, 21] + + +def test_resonant_propagators_get_a_breit_wigner_index(): + """Only a propagator with a width can be resonant, and only in a final-state + clustering: a t-channel line is spacelike and never goes on shell.""" + with_width = make_clustering( + z_to_bb_diagram(W_Z), external_pdg_ids=Z_TO_BB_PDGS + ) + without = make_clustering(z_to_bb_diagram(0.0), external_pdg_ids=Z_TO_BB_PDGS) + + assert list(with_width.bw_masses) == [pytest.approx(M_Z)] + assert list(with_width.bw_widths) == [pytest.approx(W_Z)] + assert list(without.bw_masses) == [] + + # exactly the b b~ clustering (legs 2 and 3) carries the index + resonant_pairs = set() + non_terminal, _ = walk(np.asarray(with_width.cluster_state_machine), 6) + for _, (_, transitions) in non_terminal.items(): + for data, _, _ in transitions: + if field(data, BIT_MASS_INDEX) != 0: + resonant_pairs.add( + (field(data, BIT_PARTICLE1), field(data, BIT_PARTICLE2)) + ) + assert resonant_pairs == {(2, 3)} + + +def test_resonant_clustering_uses_the_invariant_mass(): + """A clustering onto a propagator inside its Breit-Wigner window is scored + with the invariant mass of the pair rather than the kt measure. Compare + against the same topology with the width set to zero, which switches the + resonance branch off entirely.""" + momenta = sample_momenta(z_to_bb_diagram(W_Z)) + resonant = make_clustering( + z_to_bb_diagram(W_Z), external_pdg_ids=Z_TO_BB_PDGS, bw_cutoff=15.0, + clustering_mean_pdf_ladder=False, + ) + non_resonant = make_clustering( + z_to_bb_diagram(0.0), external_pdg_ids=Z_TO_BB_PDGS, + clustering_mean_pdf_ladder=False, + ) + + ren_res, fac_res, _, _, _ = run(resonant, momenta) + ren_non, _, _, _, _ = run(non_resonant, momenta) + + # the resonance branch has to actually change the answer + assert not np.allclose(ren_res, ren_non) + assert np.all(np.isfinite(ren_res)) and np.all(ren_res > 0.0) + assert np.all(fac_res <= ren_res * (1.0 + 1e-12)) + + +def test_breit_wigner_cutoff_widens_the_resonance_window(): + """bw_cutoff scales the half width of the window in which a propagator counts + as resonant, exactly as it does for the phase-space mapping. A cutoff of zero + closes the window, which has to reproduce the zero-width result.""" + momenta = sample_momenta(z_to_bb_diagram(W_Z)) + + closed = make_clustering( + z_to_bb_diagram(W_Z), external_pdg_ids=Z_TO_BB_PDGS, bw_cutoff=0.0 + ) + zero_width = make_clustering( + z_to_bb_diagram(0.0), external_pdg_ids=Z_TO_BB_PDGS + ) + wide = make_clustering( + z_to_bb_diagram(W_Z), external_pdg_ids=Z_TO_BB_PDGS, bw_cutoff=15.0 + ) + + ren_closed, *_ = run(closed, momenta) + ren_zero, *_ = run(zero_width, momenta) + ren_wide, *_ = run(wide, momenta) + + assert ren_closed == pytest.approx(ren_zero, rel=1e-12) + assert not np.allclose(ren_wide, ren_closed) + + +# -------------------------------------------------------------------------- +# clustering measures, against the Fortran they were ported from +# -------------------------------------------------------------------------- + + +def djb_clus_reference(p, hadronic=True): + """djb_clus from Template/NLO/SubProcesses/cluster.f.""" + r = (p[0] - p[3]) * (p[0] + p[3]) if hadronic else p[0] * p[0] + return max(r, 0.0) + + +def dj_clus_reference(p1, p2, mass1, mass2, jet_radius): + """The both-massless / both-massive branch of dj_clus: + min(pt1^2, pt2^2) * 2 * (cosh(deta) - cos(dphi)) / D^2, in the form the + kernel evaluates it, 4 * (sinh^2(deta/2) + sin^2(dphi/2)), which keeps its + digits for a nearly collinear pair.""" + pt1_sq = p1[1] ** 2 + p1[2] ** 2 + pt2_sq = p2[1] ** 2 + p2[2] ** 2 + if pt1_sq == 0.0 or pt2_sq == 0.0: + return 0.0 + deta = np.arcsinh(p1[3] / np.sqrt(pt1_sq)) - np.arcsinh(p2[3] / np.sqrt(pt2_sq)) + dphi = np.arctan2(p1[1] * p2[2] - p1[2] * p2[1], p1[1] * p2[1] + p1[2] * p2[2]) + m_max_sq = max(mass1**2, mass2**2) + return max( + m_max_sq + + min(pt1_sq, pt2_sq) + * 4.0 + * (np.sinh(deta / 2) ** 2 + np.sin(dphi / 2) ** 2) + / jet_radius**2, + 0.0, + ) + + +def durham_reference(p1, p2): + """The lepton-collider branch of dj_clus, 2 * min(E1^2, E2^2) * + (1 - cos_theta), as 4 * min(E1^2, E2^2) * sin^2(theta/2).""" + cross = np.cross(p1[1:], p2[1:]) + theta = np.arctan2(np.linalg.norm(cross), np.dot(p1[1:], p2[1:])) + return 4.0 * min(p1[0] ** 2, p2[0] ** 2) * np.sin(theta / 2) ** 2 + + +def massless(px, py, pz): + return np.array([np.sqrt(px * px + py * py + pz * pz), px, py, pz]) + + +def collinear_pair_event(hadronic): + """Two final-state partons about 1e-5 apart, legs 2 and 4 (e+ e- > d d~ g) + or 2 and 3 (g g > g g g), with the third one recoiling and the beams along + z: momentum is conserved. For such a pair cosh(deta) - cos(dphi), or + 1 - cos_theta, is of order 1e-10.""" + def pt_eta_phi(pt, eta, phi): + return massless(pt * np.cos(phi), pt * np.sin(phi), pt * np.sinh(eta)) + a = pt_eta_phi(300.0, 2.5, 0.7) + b = pt_eta_phi(250.0, 2.5 + 8e-6, 0.7 + 6e-6) + if hadronic: + c = massless(-(a[1] + b[1]), -(a[2] + b[2]), -400.0) + final = [a, b, c] + else: + c = massless(-(a[1] + b[1]), -(a[2] + b[2]), -(a[3] + b[3])) + final = [a, c, b] + energy = sum(p[0] for p in final) + pz = sum(p[3] for p in final) + beam1 = np.array([(energy + pz) / 2, 0.0, 0.0, (energy + pz) / 2]) + beam2 = np.array([(energy - pz) / 2, 0.0, 0.0, -(energy - pz) / 2]) + return np.array([[beam1, beam2] + final]) + + +@pytest.mark.parametrize("hadronic", [True, False], ids=["kt", "durham"]) +def test_a_nearly_collinear_pair_keeps_its_clustering_scale(hadronic): + """The clustering scale of two partons 1e-5 apart. Written as in cluster.f, + cosh(deta) - cos(dphi) (or 1 - cos_theta) is the difference of two numbers + within 1e-10 of one, which left about 1e-6 of relative error, enough for + scalar and SIMD math to give different scales and histories. The kernel's + form of the measure has none of that cancellation.""" + jet_radius = 0.4 + if hadronic: + pdg_ids = [21, 21, 21, 21, 21] + vertices = [["o0", "o1", "p0"], ["i0", "o2", "p1"], ["p1", "p0", "i1"]] + legs = (2, 3) + else: + pdg_ids = [-11, 11, 1, -1, 21] + vertices = [["o0", "o2", "p0"], ["p0", "o1", "p1"], ["i0", "p1", "i1"]] + legs = (2, 4) + diagrams = [{ + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0, 0.0], + "propagators": [[0.0, 0.0], [0.0, 0.0]], + "vertices": vertices, + "permutations": [[0, 1, 2, 3, 4]], + }] + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_radius=jet_radius, + hadronic=hadronic, clustering_mean_pdf_ladder=False, + jet_scale_scheme=ms.MLMClustering.JetScaleScheme.emission, + ) + event = collinear_pair_event(hadronic) + _, _, _, jets, _ = run(clustering, event) + p1, p2 = event[0][legs[0]], event[0][legs[1]] + if hadronic: + expected = np.sqrt(dj_clus_reference(p1, p2, 0.0, 0.0, jet_radius)) + else: + expected = np.sqrt(durham_reference(p1, p2)) + assert 1e-4 < expected < 1e-1, "not the collinear configuration intended" + # the softest clustering is that pair: both its legs carry its scale + for leg in legs: + assert jets[0][leg - 2] == pytest.approx(expected, rel=1e-9) + + +def first_step_candidates(clustering, n_ext, event, jet_radius, masses): + """The clustering measures the kernel evaluates in its first step. + + Which pairs are candidates at all is decided by the compiled state machine - + only clusterings that some diagram topology allows are offered - so read + them off it rather than enumerating every pair. + """ + flat = np.asarray(clustering.cluster_state_machine) + candidates = {} + for data, _, _ in decode_transitions(flat, 0): + p1 = field(data, BIT_PARTICLE1) + p2 = field(data, BIT_PARTICLE2) + if p1 < 2: + # initial-state clustering: mT of the final-state parton, with a + # 1.000001 penalty when it goes against the beam + measure = np.sqrt(djb_clus_reference(event[p2])) + if (event[p1][3] < 0.0) != (event[p2][3] < 0.0): + measure *= 1.000001 + else: + measure = np.sqrt( + dj_clus_reference( + event[p1], event[p2], masses[p1], masses[p2], jet_radius + ) + ) + candidates[(p1, p2)] = measure + return candidates + + +def test_single_clustering_scale_matches_the_measures(): + """For a 2 -> 2 process there is exactly one clustering step, so the scale + the kernel returns is fully predicted by the measures above. + + This is the tightest available check that the kernel walks the state machine + and applies the same measure the Fortran does. + """ + diagrams = [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0], + "propagators": [[0.0, 0.0]], + "vertices": [["i0", "o0", "p0"], ["p0", "o1", "i1"]], + "permutations": [[0, 1, 2, 3]], + } + ] + pdg_ids = [21, 21, 21, 21] + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, + clustering_mean_pdf_ladder=False, + ) + momenta = sample_momenta(diagrams, batch_size=128, seed=99) + ren, fac, _, _, _ = run(clustering, momenta) + + # one clustering step: mu_R is that step's scale, and mu_F (the smallest QCD + # clustering scale, capped at mu_R) has to equal it + assert fac == pytest.approx(ren, rel=1e-12) + + masses = [0.0] * 4 + for event, scale in zip(momenta, ren): + candidates = first_step_candidates(clustering, 4, event, 1.0, masses) + assert candidates, "no clustering offered from the initial state" + assert scale == pytest.approx(min(candidates.values()), rel=1e-9) + + +def test_the_default_jet_radius_is_madevents(): + """madevent's hidden run_card d (common/to_dj/D in kin_functions.f) and + FxFx both put 1 in the final-state kt measure. With 0.4 that measure came + out 2.5 times larger, and the merging cut weaker for final-state + splittings, than in the madevent samples it is compared against.""" + diagrams = load_diagrams(PROCESSES["ttggg"][0]) + pdg_ids = PROCESSES["ttggg"][1] + momenta = sample_momenta(diagrams) + default = run(make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta) + one = run( + make_clustering(diagrams, external_pdg_ids=pdg_ids, jet_radius=1.0), momenta + ) + old = run( + make_clustering(diagrams, external_pdg_ids=pdg_ids, jet_radius=0.4), momenta + ) + for a, b in zip(default, one): + np.testing.assert_array_equal(a, b) + assert not np.array_equal(default[3], old[3]) + + +def test_kt_measure_matches_the_fortran(): + """The first clustering step of g g > g g g, where the (o0, o1) final-state + pair competes with the initial-state candidates and is scored by the kt + measure. Checked against a transcription of dj_clus / djb_clus from + Template/NLO/SubProcesses/cluster.f. + """ + jet_radius = 0.4 + diagrams = [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0, 0.0], + "propagators": [[0.0, 0.0], [0.0, 0.0]], + "vertices": [ + ["o0", "o1", "p0"], + ["i0", "o2", "p1"], + ["p1", "p0", "i1"], + ], + "permutations": [[0, 1, 2, 3, 4]], + } + ] + pdg_ids = [21, 21, 21, 21, 21] + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_radius=jet_radius, + clustering_mean_pdf_ladder=False, + ) + momenta = sample_momenta(diagrams, batch_size=128, seed=99) + _, fac, _, _, _ = run(clustering, momenta) + + # the state machine has to offer the final-state pair, otherwise this test + # would never exercise dj_clus at all + flat = np.asarray(clustering.cluster_state_machine) + offered = { + (field(d, BIT_PARTICLE1), field(d, BIT_PARTICLE2)) + for d, _, _ in decode_transitions(flat, 0) + } + assert (2, 3) in offered + + masses = [0.0] * 5 + for event, scale in zip(momenta, fac): + candidates = first_step_candidates( + clustering, 5, event, jet_radius, masses + ) + # two clustering steps here (n_ext - 3), and mu_F is the smallest QCD + # clustering scale of the whole history, so it is at most the smallest + # measure available in the first step + assert scale <= min(candidates.values()) * (1.0 + 1e-9) + + +# -------------------------------------------------------------------------- +# structure of the compiled state machine +# -------------------------------------------------------------------------- +# +# The kernel walks a flat integer array. A non-terminal state is a run of +# (data, next_offset) pairs ending at the pair whose data has bit 30 set; a +# terminal state is a count followed by that many diagram indices. Which of the +# two a given offset is depends only on how many clusterings have been done, so +# the walker below tracks the depth the same way the kernel does. + +BIT_PARTICLE1 = (0, 0xFF) +BIT_PARTICLE2 = (8, 0xFF) +BIT_MASS_INDEX = (16, 0xFF) +BIT_MASSIVE_IN = (24, 1) +BIT_MASSIVE_OUT1 = (25, 1) +BIT_MASSIVE_OUT2 = (26, 1) +BIT_IS_QCD = (27, 1) +BIT_IS_JET1 = (28, 1) +BIT_IS_JET2 = (29, 1) +BIT_IS_LAST = (30, 1) + +# Words per transition: (data, next_offset, trace_data). +STATE_ITEM_SIZE = 3 + +# trace_data: the low two bits say which daughter the mother's parton line +# continues into, the next two carry the mother's own flavour. +TRACE_MODE_MASK = 0x3 +TRACE_FIRST, TRACE_SECOND, TRACE_HARDER, TRACE_BOTH = 0, 1, 2, 3 +TRACE_MODES = {TRACE_FIRST, TRACE_SECOND, TRACE_HARDER, TRACE_BOTH} +TRACE_IS_JET_IN = 1 << 2 +TRACE_IS_COLORED_IN = 1 << 3 + + +def trace_mode(trace): + return trace & TRACE_MODE_MASK + + +def field(data, spec): + shift, mask = spec + return (data >> shift) & mask + + +def decode_transitions(machine, offset): + """The (data, next_offset, trace) triples of the non-terminal state at + `offset`.""" + transitions = [] + while True: + assert offset + STATE_ITEM_SIZE <= len(machine), ( + "transition list runs past the machine" + ) + data, next_offset, trace = machine[offset : offset + STATE_ITEM_SIZE] + transitions.append((data, next_offset, trace)) + if field(data, BIT_IS_LAST): + return transitions + offset += STATE_ITEM_SIZE + + +def walk(machine, n_ext): + """Visit every reachable state, returning + {offset: (depth, transitions)} for non-terminal states and + {offset: diagram indices} for terminal ones.""" + cluster_max = n_ext - 3 + non_terminal, terminal = {}, {} + todo = [(0, 0)] + seen = set() + while todo: + offset, depth = todo.pop() + if (offset, depth) in seen: + continue + seen.add((offset, depth)) + if depth == cluster_max: + count = machine[offset] + assert count > 0, f"terminal state at {offset} selects no diagram" + assert offset + count < len(machine) + terminal[offset] = list(machine[offset + 1 : offset + 1 + count]) + continue + transitions = decode_transitions(machine, offset) + non_terminal[offset] = (depth, transitions) + for _, next_offset, _ in transitions: + todo.append((next_offset, depth + 1)) + return non_terminal, terminal + + +@pytest.fixture +def machine(process): + _, diagrams, pdg_ids = process + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + n_ext = len(pdg_ids) + return clustering, np.asarray(clustering.cluster_state_machine), n_ext + + +def test_state_machine_is_fully_reachable(machine): + """Every state the walk can reach is well formed, and a state is never + reached at two different depths (which would make the terminal/non-terminal + reading of an offset ambiguous).""" + _, flat, n_ext = machine + non_terminal, terminal = walk(flat, n_ext) + assert non_terminal, "no clustering states compiled" + assert terminal, "no terminal states compiled" + depths = {} + for offset, (depth, _) in non_terminal.items(): + assert depths.setdefault(offset, depth) == depth + for offset in terminal: + assert offset not in non_terminal + + +def test_state_machine_transitions_are_in_range(machine): + """Every transition names two distinct external particles in range and + points at an offset inside the machine.""" + _, flat, n_ext = machine + non_terminal, _ = walk(flat, n_ext) + for offset, (_, transitions) in non_terminal.items(): + for data, next_offset, _ in transitions: + particle1 = field(data, BIT_PARTICLE1) + particle2 = field(data, BIT_PARTICLE2) + # the merged pseudo-particle is always named by the lower index, so + # the kernel's is_initial test (particle1 < 2) is meaningful + assert particle1 < particle2 < n_ext + assert 0 <= next_offset < len(flat) + + +def test_state_machine_has_no_duplicate_transitions(machine): + """A state reached along several paths must be expanded only once. + Expanding it again appends a second copy of the same transitions, which + grows the table by a factor that blows up with the multiplicity.""" + _, flat, n_ext = machine + non_terminal, _ = walk(flat, n_ext) + for offset, (_, transitions) in non_terminal.items(): + keys = [ + (field(d, BIT_PARTICLE1), field(d, BIT_PARTICLE2), nxt) + for d, nxt, _ in transitions + ] + assert len(keys) == len(set(keys)), f"duplicate transitions at state {offset}" + + +def test_state_machine_marks_exactly_one_last_transition(machine): + """The kernel decides a state is finished when it sees the is_last bit, so + exactly one transition per state carries it, and it is the final one.""" + _, flat, n_ext = machine + non_terminal, _ = walk(flat, n_ext) + for offset, (_, transitions) in non_terminal.items(): + flags = [field(d, BIT_IS_LAST) for d, _, _ in transitions] + assert flags[-1] == 1 + assert sum(flags) == 1 + + +def test_state_machine_mass_indices_are_in_range(machine): + """mass_index is a 1-based index into the Breit-Wigner tables, or 0 for + 'never resonant'. An out-of-range value would read past those tables.""" + clustering, flat, n_ext = machine + bw_count = len(clustering.bw_masses) + assert len(clustering.bw_widths) == bw_count + non_terminal, _ = walk(flat, n_ext) + for _, (_, transitions) in non_terminal.items(): + for data, _, _ in transitions: + assert field(data, BIT_MASS_INDEX) <= bw_count + + +def test_initial_state_clusterings_are_never_resonant(machine): + """A t-channel propagator is spacelike, so it can never be on shell.""" + _, flat, n_ext = machine + non_terminal, _ = walk(flat, n_ext) + for _, (_, transitions) in non_terminal.items(): + for data, _, _ in transitions: + if field(data, BIT_PARTICLE1) < 2: + assert field(data, BIT_MASS_INDEX) == 0 + # a beam leg is not a final-state particle and gets no jet scale + assert field(data, BIT_IS_JET1) == 0 + + +def test_state_machine_trace_modes_are_valid(machine): + """Every transition says how the mother's parton line continues into its + daughters, and an initial-state clustering always carries the beam line on, + which is daughter 1 by construction.""" + _, flat, n_ext = machine + non_terminal, _ = walk(flat, n_ext) + for _, (_, transitions) in non_terminal.items(): + for data, _, trace in transitions: + assert trace_mode(trace) in TRACE_MODES + if field(data, BIT_PARTICLE1) < 2: + assert trace_mode(trace) == TRACE_FIRST + + +def trace_modes_of(clustering, n_ext): + non_terminal, _ = walk(np.asarray(clustering.cluster_state_machine), n_ext) + return { + (field(data, BIT_PARTICLE1), field(data, BIT_PARTICLE2)): trace_mode(trace) + for _, (_, transitions) in non_terminal.items() + for data, _, trace in transitions + } + + +def three_gluon_diagram(propagator_pdgs): + """g g > g g g through an s-channel gluon splitting, legs (g, g, g, g, g).""" + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0, 0.0], + "propagators": [[0.0, 0.0, 0, 0.0, 0.0, pdg] for pdg in propagator_pdgs], + "vertices": [ + ["o0", "o1", "p0"], + ["i0", "o2", "p1"], + ["p1", "p0", "i1"], + ], + "permutations": [[0, 1, 2, 3, 4]], + } + ] + + +def test_gluon_splitting_follows_the_harder_gluon(): + """A g -> g g vertex carries the parton line into the harder gluon, as + ipartupdate does. p0 recombines legs 2 and 3 into a gluon.""" + clustering = make_clustering( + three_gluon_diagram([21, 21]), external_pdg_ids=[21] * 5 + ) + assert trace_modes_of(clustering, 5)[(2, 3)] == TRACE_HARDER + + +def test_gluon_to_quarks_carries_the_line_into_both(): + """A g -> q qbar vertex has no single continuing line, so both daughters + carry it on and both get booked at later vertices.""" + diagrams = three_gluon_diagram([21, 21]) + diagrams[0]["propagators"][0] = [0.0, 0.0, 0, 0.0, 0.0, 21] + clustering = make_clustering(diagrams, external_pdg_ids=[21, 21, 1, -1, 21]) + assert trace_modes_of(clustering, 5)[(2, 3)] == TRACE_BOTH + + +def test_quark_line_follows_the_quark(): + """A q -> q g vertex keeps the line on the quark, whichever daughter it is.""" + # p0 recombines legs 2 and 3 into a quark + quark_first = make_clustering( + three_gluon_diagram([1, 21]), external_pdg_ids=[21, 21, 1, 21, 21] + ) + assert trace_modes_of(quark_first, 5)[(2, 3)] == TRACE_FIRST + quark_second = make_clustering( + three_gluon_diagram([1, 21]), external_pdg_ids=[21, 21, 21, 1, 21] + ) + assert trace_modes_of(quark_second, 5)[(2, 3)] == TRACE_SECOND + + +def test_terminal_states_select_known_diagrams(machine): + _, flat, n_ext = machine + _, terminal = walk(flat, n_ext) + for offset, diagrams in terminal.items(): + assert len(diagrams) == len(set(diagrams)) + assert all(index >= 0 for index in diagrams) + + +def massive_leg_permutation_diagram(): + """A topology whose permutations move a massive leg onto a slot that a + different permutation gives to a massless one. + + The shipped fixtures cannot exercise the slot-versus-leg question at all: + their permutations only exchange identical gluons, so the two orderings + agree and indexing by the wrong one is invisible. Here leg 2 is the top + under the first permutation and a gluon under the second. + """ + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [M_TOP, 0.0, 0.0], + "propagators": [[0.0, 0.0], [0.0, 0.0]], + "vertices": [ + ["o0", "o1", "p0"], + ["i0", "o2", "p1"], + ["p1", "p0", "i1"], + ], + "permutations": [[0, 1, 2, 3, 4], [0, 1, 3, 2, 4]], + } + ] + + +def test_permutations_moving_a_mass_between_legs_are_rejected(): + """external_masses is indexed by external leg, while a topology's masses + are indexed by topology slot, so the permutation has to be applied. The + kernel keeps a single mass per leg, so permutations that disagree about a + leg's mass cannot be represented at all and have to be refused rather than + silently given one of the two answers. + """ + with pytest.raises(Exception) as caught: + make_clustering( + massive_leg_permutation_diagram(), external_pdg_ids=[21, 21, 6, 21, 21] + ) + assert "mass" in str(caught.value).lower() + + +def test_external_masses_are_in_leg_order(process): + """The kernel indexes external_masses by external leg while a topology's + masses are indexed by topology slot, so the permutation has to be applied. + For these processes legs 2 and 3 are the tops. + + On its own this does not pin the ordering down: these fixtures permute only + identical gluons, so slot and leg order coincide. The case that tells them + apart is test_permutations_moving_a_mass_between_legs_are_rejected. + """ + _, diagrams, pdg_ids = process + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + masses = list(clustering.external_masses) + assert len(masses) == len(pdg_ids) + assert masses[0] == 0.0 and masses[1] == 0.0 + assert masses[2] == pytest.approx(M_TOP) + assert masses[3] == pytest.approx(M_TOP) + assert all(m == 0.0 for m in masses[4:]) + + +# -------------------------------------------------------------------------- +# robustness +# -------------------------------------------------------------------------- + + +def test_extreme_kinematics_do_not_break_the_walk(process): + """The boost and rotation applied after an initial-state clustering can + leave a pair exactly collinear, making the clustering measure inf or NaN. + The kernel has to keep choosing a clustering anyway: if nothing wins a step + it would jump to a stale or unset state offset and read outside the state + machine. + + Strongly asymmetric beam energies reach that configuration, so this is a + regression test for the crash it used to cause. Three guards stand between + that crash and this assertion - dj_clus zeroing a NaN, the selection + mapping a non-finite measure onto SCALE_MAX, and taking the first candidate + unconditionally - and they are deliberately redundant, so dropping any one + of them leaves the others covering it. What is pinned here is the behaviour + they exist for, not each guard individually. + """ + _, diagrams, pdg_ids = process + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + momenta = sample_momenta(diagrams, batch_size=2000, seed=1234) + ren, fac1, fac2, out, diagram = run(clustering, momenta) + assert np.all(np.isfinite(ren)) + assert np.all(np.isfinite(fac1)) and np.all(np.isfinite(fac2)) + assert np.all(np.isfinite(out)) + total = sum(len(d["permutations"]) for d in diagrams) + assert np.all((diagram >= 0) & (diagram < total)) + + +def test_exactly_collinear_momenta_are_handled(process): + """The same thing, but forced: put two final-state partons exactly along the + beam axis so the pseudorapidity of the pair is genuinely undefined.""" + _, diagrams, pdg_ids = process + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + momenta = sample_momenta(diagrams, batch_size=32, seed=7) + degenerate = momenta.copy() + # zero the transverse components of the last two legs + degenerate[:, -2:, 1] = 0.0 + degenerate[:, -2:, 2] = 0.0 + ren, fac1, _, out, diagram = run(clustering, degenerate) + assert np.all(np.isfinite(ren)) + assert np.all(np.isfinite(fac1)) + assert np.all(np.isfinite(out)) + + +# -------------------------------------------------------------------------- +# the jet-scale scheme switch +# -------------------------------------------------------------------------- + + +EMISSION = ms.MLMClustering.JetScaleScheme.emission +PRODUCTION = ms.MLMClustering.JetScaleScheme.production + + +def test_production_scheme_is_never_softer_than_emission(process): + """The two schemes book the same vertices onto a leg plus, for "production", + every later vertex on the line the leg belongs to. Since "production" keeps + the hardest of those and "emission" keeps the one "production" starts from, + the production scale of a leg can only be larger or equal. + """ + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams, batch_size=1000, seed=2468) + *_, emission, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=EMISSION + ), + momenta, + ) + *_, production, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=PRODUCTION + ), + momenta, + ) + assert np.all(production >= emission * (1.0 - 1e-12)) + + +def gluon_chain_diagram(): + """g g > g g g g through a chain of two s-channel gluon splittings, so that + one gluon line carries on past the vertex that emitted it: + p0 = g(o0, o1), p1 = g(p0, o2). Legs (g, g, g, g, g, g).""" + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0, 0.0, 0.0], + "propagators": [[0.0, 0.0, 0, 0.0, 0.0, 21] for _ in range(3)], + "vertices": [ + ["o0", "o1", "p0"], + ["p0", "o2", "p1"], + ["i0", "o3", "p2"], + ["p2", "p1", "i1"], + ], + "permutations": [[0, 1, 2, 3, 4, 5]], + } + ] + + +def test_the_two_schemes_disagree_on_continuing_lines(): + """The schemes differ only for a leg whose parton line carries on past the + vertex that emitted it. In the chain above the harder of the first two + gluons stays on the line and is booked again at the second vertex, so + "production" gives it the harder of the two scales and "emission" the + softer. That is the whole point of the switch.""" + diagrams = gluon_chain_diagram() + pdg_ids = [21] * 6 + momenta = sample_momenta(diagrams, batch_size=1000, seed=2468) + *_, emission, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=EMISSION + ), + momenta, + ) + *_, production, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=PRODUCTION + ), + momenta, + ) + differs = ~np.isclose(production, emission) + assert differs.any(), "the two schemes gave identical jet scales" + # and where they differ it is because production kept a harder vertex + assert np.all(production[differs] > emission[differs]) + + +def test_the_schemes_coincide_when_no_line_continues(process): + """With propagator flavours unknown a merged node is not recognised as a + jet, so nothing is booked onto a continuing line and the two schemes fall + back to the same answer. The ttgg/ttggg fixtures carry propagator masses + only, so this pins that degradation as intentional rather than accidental. + """ + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams, batch_size=500, seed=2468) + *_, emission, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=EMISSION + ), + momenta, + ) + *_, production, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=PRODUCTION + ), + momenta, + ) + assert np.array_equal(production, emission) + + +def test_the_schemes_agree_for_a_single_clustering(): + """A 2 -> 3 process has one clustering per leg, so nothing continues past + the vertex that emitted it and the two schemes have to coincide.""" + diagrams = three_gluon_diagram([21, 21]) + pdg_ids = [21] * 5 + momenta = sample_momenta(diagrams, batch_size=256, seed=1357) + *_, emission, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=EMISSION + ), + momenta, + ) + *_, production, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=PRODUCTION + ), + momenta, + ) + assert np.array_equal(production, emission) + + +def test_the_scheme_does_not_change_the_scales(process): + """The switch only moves which leg a clustering scale is booked against; the + clustering history, and therefore mu_R and mu_F, are untouched.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams, batch_size=1000, seed=2468) + ren_e, fac_e, _, _, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=EMISSION + ), + momenta, + ) + ren_p, fac_p, _, _, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=PRODUCTION + ), + momenta, + ) + assert np.array_equal(ren_e, ren_p) + assert np.array_equal(fac_e, fac_p) + + +def test_production_is_the_default(): + """The default matches madevent, which is the validated reference.""" + diagrams = three_gluon_diagram([21, 21]) + assert ( + ms.MLMClustering.JetScaleScheme.__members__["production"] == PRODUCTION + ) + default = make_clustering(diagrams, external_pdg_ids=[21] * 5) + explicit = make_clustering( + diagrams, external_pdg_ids=[21] * 5, jet_scale_scheme=PRODUCTION + ) + momenta = sample_momenta(diagrams, batch_size=64, seed=11) + assert np.array_equal(run(default, momenta)[3], run(explicit, momenta)[3]) + + +# -------------------------------------------------------------------------- +# the generation-level merging cut +# -------------------------------------------------------------------------- + + +def xqcut_weights(diagrams, pdg_ids, momenta, xqcut, **kwargs): + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, xqcut=xqcut, **kwargs + ) + return run_all(clustering, momenta)[5] + + +def test_xqcut_is_off_by_default(process): + """Without a merging cut every event keeps its weight, so turning the + feature on is the only thing that can change an existing result.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + weights = run_all( + make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta + )[5] + assert np.all(weights == 1.0) + + +def test_xqcut_weight_is_a_veto(process): + """It multiplies the event weight, so it has to be exactly zero or one.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + weights = xqcut_weights(diagrams, pdg_ids, momenta, 50.0) + assert np.all((weights == 0.0) | (weights == 1.0)) + assert np.any(weights == 0.0), "a 50 GeV cut should reject something" + + +def test_xqcut_rejects_monotonically(process): + """Raising the cut can only ever reject more events.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams, batch_size=1000, seed=808) + kept = [ + float(xqcut_weights(diagrams, pdg_ids, momenta, cut).sum()) + for cut in (0.0, 10.0, 30.0, 60.0, 120.0) + ] + assert kept == sorted(kept, reverse=True), kept + assert kept[0] == len(momenta) + assert kept[-1] < kept[0] + + +def test_xqcut_rejects_exactly_the_soft_jet_emissions(process): + """madevent's rule: a clustering step whose daughter is still a bare + final-state jet has to be at or above xqcut. In the "emission" scheme the + per-jet scales are booked at exactly those steps, so the veto can be + recomputed from them and compared.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams, batch_size=500, seed=99) + cut = 40.0 + clustering = make_clustering( + diagrams, + external_pdg_ids=pdg_ids, + xqcut=cut, + jet_scale_scheme=EMISSION, + ) + *_, jet_scales, _, weights = run_all(clustering, momenta) + # a leg left at the sqrt(s) fallback was never booked and cannot fail + booked = jet_scales < CM_ENERGY + softest = np.where(booked, jet_scales, np.inf).min(axis=1) + expected = np.where(np.isfinite(softest) & (softest < cut), 0.0, 1.0) + assert np.array_equal(weights, expected) + + +def test_xqcut_does_not_disturb_the_scales(process): + """The cut only vetoes. Events that survive keep exactly the scales the + clustering would have given without it.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + ren_off, fac_off, _, out_off, _ = run( + make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta + ) + ren_on, fac_on, _, out_on, _, weights = run_all( + make_clustering(diagrams, external_pdg_ids=pdg_ids, xqcut=40.0), momenta + ) + keep = weights > 0 + assert keep.any() + assert np.array_equal(ren_off[keep], ren_on[keep]) + assert np.array_equal(fac_off[keep], fac_on[keep]) + assert np.array_equal(out_off[keep], out_on[keep]) + + +def test_xqcut_survivors_have_no_soft_jet_scale(process): + """Every booked jet scale of a surviving event is at or above the cut, + which is what makes the merging scale mean anything downstream.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams, batch_size=1000, seed=4242) + cut = 35.0 + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, xqcut=cut, jet_scale_scheme=EMISSION + ) + *_, jet_scales, _, weights = run_all(clustering, momenta) + booked = jet_scales[weights > 0] + booked = booked[booked < CM_ENERGY] + assert len(booked) > 0 + assert np.all(booked >= cut) + + +def test_xqcut_does_not_depend_on_the_jet_scale_scheme(process): + """The veto is defined on the clustering history, which the scheme switch + does not touch: it only moves which leg a scale is booked against.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams, batch_size=500, seed=17) + emission = xqcut_weights( + diagrams, pdg_ids, momenta, 40.0, jet_scale_scheme=EMISSION + ) + production = xqcut_weights( + diagrams, pdg_ids, momenta, 40.0, jet_scale_scheme=PRODUCTION + ) + assert np.array_equal(emission, production) + + +# -------------------------------------------------------------------------- +# the alpha_s and pdf reweighting a merged event carries +# -------------------------------------------------------------------------- +# +# Both are corrections madevent applies to a merged event that a single scale +# does not describe: the coupling belongs at the scale of each emission, and +# the beam density belongs at the scale that took the parton out of the beam. +# The reference is the rewgt loop of Template/LO/SubProcesses/reweight.f. +# +# What is checkable here is the bookkeeping, not the densities: which one is +# asked for is only settled once the flavour has been sampled, which happens in +# the integrand and not in the clustering. + + +def t_channel_diagram(): + """u u~ > b b~ g g through a t-channel quark line, with the propagator + flavours filled in. + + The pdf reweighting follows a beam's parton line through the clustering, so + a test of it needs a process where a beam emits more than once and stays a + parton in between. Here beam 0 emits both gluons before the Z attaches: the + line is a u throughout, which is also the case the "same flavour as the + beam" class exists for. + + The propagator pdg ids matter and the json fixtures do not carry them. A + line whose flavour is unknown is not a parton as far as the reweighting is + concerned, so on those fixtures the chain stops at the first clustering and + nothing is exercised. + """ + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [M_B, M_B, 0.0, 0.0], + # p0 = Z (b b~), p1/p2 = the t-channel u line + "propagators": [(M_Z, W_Z, 23), (0.0, 0.0, 2), (0.0, 0.0, 2)], + "vertices": [ + ["o0", "o1", "p0"], + ["i0", "o2", "p1"], + ["p1", "o3", "p2"], + ["p2", "p0", "i1"], + ], + "permutations": [[0, 1, 2, 3, 4, 5]], + } + ] + + +T_CHANNEL_PDGS = [2, -2, 5, -5, 21, 21] + + +def t_channel_clustering(**kwargs): + kwargs.setdefault("scale_scheme", ms.MLMClustering.ScaleScheme.madevent) + diagrams = t_channel_diagram() + return ms.MLMClustering( + [ + ms.Topology( + ms.Diagram( + d["incoming_masses"], + d["outgoing_masses"], + [ms.Propagator(mass=m, width=w, pdg_id=i) + for m, w, i in d["propagators"]], + d["vertices"], + ) + ) + for d in diagrams + ], + [d["permutations"] for d in diagrams], + make_diagram_indices(diagrams), + cm_energy=CM_ENERGY, + external_pdg_ids=T_CHANNEL_PDGS, + **kwargs, + ) + + +@pytest.fixture(scope="module") +def t_channel_momenta(): + return sample_momenta(t_channel_diagram(), batch_size=2000) + + +def test_alphas_scales_are_clustering_scales_or_zero(process): + """Every vertex is handed either its own scale or 0, the latter meaning + "not reweighted" (evaluated at mu_R). Not mu_R itself: a reweighted vertex + can sit exactly at mu_R, and the systematics vary the two differently.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + scales, weight = alphas_outputs(clustering, momenta) + assert np.all(np.isfinite(scales)) + assert np.all(scales >= 0.0) + assert np.all((weight == 0.0) | (weight == 1.0)) + # gluon-only processes: every vertex is a QCD one, so few of them should + # have been left out + assert np.mean(scales == 0.0) < 0.5 + # without alpha_s reweighting none is + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, + alphas_scheme=ms.MLMClustering.AlphasScheme.none, + ) + scales, weight = alphas_outputs(clustering, momenta) + assert np.all(scales == 0.0) and np.all(weight == 1.0) + + +def test_pdf_reweighting_off_leaves_the_density_where_the_scale_is( + t_channel_momenta, +): + clustering = t_channel_clustering(pdf_reweighting=False) + _, fact1, fact2, _, _ = run(clustering, t_channel_momenta) + out = pdf_outputs(clustering, t_channel_momenta) + assert out["pdf_scale1"] == pytest.approx(fact1) + assert out["pdf_scale2"] == pytest.approx(fact2) + assert np.all(out["pdf_rw_active"] == 0.0) + + +def test_clustering_mean_pdf_ladder_takes_only_the_densities_from_madevent( + t_channel_momenta, +): + """The ladder under clustering_mean is madevent's pdf half grafted onto + clustering_mean: the densities, the factorisation scales they end at and + every ladder slot are madevent's, while mu_R, the jet scales, the alpha_s + vertex scales and the xqcut veto stay clustering_mean's.""" + scheme = ms.MLMClustering.ScaleScheme + mean = t_channel_clustering( + scale_scheme=scheme.clustering_mean, xqcut=20.0, + clustering_mean_pdf_ladder=False, + ) + madevent = t_channel_clustering(scale_scheme=scheme.madevent, xqcut=20.0) + # on by default + ladder = t_channel_clustering(scale_scheme=scheme.clustering_mean, xqcut=20.0) + # switched off, clustering_mean keeps the density at its scale + assert np.all(pdf_outputs(mean, t_channel_momenta)["pdf_rw_active"] == 0.0) + + ren, _, _, jets, diagram, veto = run_all(ladder, t_channel_momenta) + ren_m, _, _, jets_m, diagram_m, veto_m = run_all(mean, t_channel_momenta) + assert np.array_equal(ren, ren_m) + assert np.array_equal(jets, jets_m) + assert np.array_equal(diagram, diagram_m) + assert np.array_equal(veto, veto_m) + for got, want in zip( + alphas_outputs(ladder, t_channel_momenta), + alphas_outputs(mean, t_channel_momenta), + ): + assert np.array_equal(got, want) + + _, fact1, fact2, _, _ = run(ladder, t_channel_momenta) + _, fact1_e, fact2_e, _, _ = run(madevent, t_channel_momenta) + assert np.array_equal(fact1, fact1_e) + assert np.array_equal(fact2, fact2_e) + out = pdf_outputs(ladder, t_channel_momenta) + out_e = pdf_outputs(madevent, t_channel_momenta) + assert (out["pdf_rw_active"] == 1.0).any() + for name in PDF_OUTPUTS: + assert np.array_equal(out[name], out_e[name]), name + + # and the switch is inert without pdf_reweighting + off = t_channel_clustering( + scale_scheme=scheme.clustering_mean, xqcut=20.0, pdf_reweighting=False, + ) + _, fact1_o, fact2_o, _, _ = run(off, t_channel_momenta) + _, fact1_m, fact2_m, _, _ = run(mean, t_channel_momenta) + assert np.array_equal(fact1_o, fact1_m) + assert np.array_equal(fact2_o, fact2_m) + + +@pytest.mark.parametrize( + "history", list(ms.MLMClustering.ClusteringHistory.__members__.values()) +) +@pytest.mark.parametrize( + "jet_scale_scheme", list(ms.MLMClustering.JetScaleScheme.__members__.values()) +) +def test_the_ladder_leaves_every_other_clustering_mean_output_alone( + process, history, jet_scale_scheme +): + """The walk under clustering_mean + ladder only writes the densities and + mu_F; everything else it computes is madevent's, and has to stay out of the + event. Checked on the gluon-rich processes, for every history and both jet + scale schemes, against plain clustering_mean (every non-pdf output, exactly) + and against madevent (mu_F and every ladder output, exactly).""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + scheme = ms.MLMClustering.ScaleScheme + + def clustering(**kwargs): + return make_clustering( + diagrams, external_pdg_ids=pdg_ids, xqcut=20.0, + clustering_history=history, jet_scale_scheme=jet_scale_scheme, + **kwargs, + ) + + ladder = clustering(scale_scheme=scheme.clustering_mean) + mean = clustering( + scale_scheme=scheme.clustering_mean, clustering_mean_pdf_ladder=False + ) + madevent = clustering(scale_scheme=scheme.madevent) + + ren, fact1, fact2, jets, diagram, veto = run_all(ladder, momenta) + ren_m, _, _, jets_m, diagram_m, veto_m = run_all(mean, momenta) + assert np.array_equal(ren, ren_m) + assert np.array_equal(jets, jets_m) + assert np.array_equal(diagram, diagram_m) + assert np.array_equal(veto, veto_m) + for got, want in zip( + alphas_outputs(ladder, momenta), alphas_outputs(mean, momenta) + ): + assert np.array_equal(got, want) + + _, fact1_e, fact2_e, _, diagram_e, _ = run_all(madevent, momenta) + # the same history, or mu_F and the ladder have nothing to agree on + same = diagram == diagram_e + assert same.mean() > 0.5 + assert np.array_equal(fact1[same], fact1_e[same]) + assert np.array_equal(fact2[same], fact2_e[same]) + # (no ladder step fires on these two fixtures, under madevent either: the + # steps themselves are compared on the t-channel process above) + out = pdf_outputs(ladder, momenta) + out_e = pdf_outputs(madevent, momenta) + for name in PDF_OUTPUTS: + assert np.array_equal(out[name][same], out_e[name][same]), name + + +def test_pdf_reweighting_drops_the_density_down_the_ladder(t_channel_momenta): + """The point of the whole thing: with it on, the density is asked for at or + below the factorisation scale, and for a real part of the sample strictly + below it. If it were never below there would be nothing to walk back up.""" + clustering = t_channel_clustering() + _, fact1, fact2, _, _ = run(clustering, t_channel_momenta) + out = pdf_outputs(clustering, t_channel_momenta) + for scale, fact in [(out["pdf_scale1"], fact1), (out["pdf_scale2"], fact2)]: + assert np.all(np.isfinite(scale)) + assert np.all(scale > 0.0) + assert np.all(scale <= fact * (1.0 + 1e-9)) + assert np.mean(scale < fact * (1.0 - 1e-9)) > 0.1 + + +def test_pdf_reweighting_steps_only_ever_raise_the_scale(t_channel_momenta): + """A step exists precisely to move the density from where it was last + evaluated up to this clustering, so its two scales are ordered. reweight.f + reaches the same place through its pt2pdf(ida) < q2now test.""" + out = pdf_outputs(t_channel_clustering(), t_channel_momenta) + active = out["pdf_rw_active"] == 1.0 + assert active.any(), "no step fired; this test would prove nothing" + assert np.all(out["pdf_rw_q_num"][active] > out["pdf_rw_q_den"][active]) + + +def test_pdf_reweighting_ends_at_the_factorisation_scale(t_channel_momenta): + """A chain that fired at all has to arrive back at the scale the event is + reported at, otherwise the lowering it started from is never undone.""" + clustering = t_channel_clustering() + _, fact1, fact2, _, _ = run(clustering, t_channel_momenta) + out = pdf_outputs(clustering, t_channel_momenta) + fact = np.stack([fact1, fact2], axis=1) + for beam in (0, 1): + on_beam = (out["pdf_rw_active"] == 1.0) & (out["pdf_rw_beam"] == beam) + fired = on_beam.any(axis=1) + if not fired.any(): + continue + top = np.max(np.where(on_beam, out["pdf_rw_q_num"], 0.0), axis=1) + assert top[fired] == pytest.approx(fact[fired, beam], rel=1e-9) + + +def test_inert_pdf_slots_cannot_change_the_weight(t_channel_momenta): + """A slot no step claimed still costs two density evaluations, because the + graph is the same for every event. Its two scales are equal so that its + ratio is exactly one whatever those come out as.""" + out = pdf_outputs(t_channel_clustering(), t_channel_momenta) + inert = out["pdf_rw_active"] == 0.0 + assert inert.any() + assert out["pdf_rw_q_num"][inert] == pytest.approx(out["pdf_rw_q_den"][inert]) + assert np.all(out["pdf_rw_q_den"][inert] > 0.0) + + +def test_pdf_reweighting_only_ever_takes_momentum_off_the_beam( + t_channel_momenta, +): + """The fraction travels down the ladder as a product of the clustering z, + each of which reweight.f only applies when it lies in (0, 1).""" + x = pdf_outputs(t_channel_clustering(), t_channel_momenta)["pdf_rw_x"] + assert np.all(x > 0.0) + assert np.all(x <= 1.0) + # and it does move: a chain that never rescaled x would be asking for the + # density of the beam parton at every rung + assert np.mean(x < 1.0) > 0.05 + + +def test_pdf_flavor_classes_stay_inside_the_table(t_channel_momenta): + """The class is an index the consumer looks up, so an out-of-range one + would read off the end of a table rather than fail visibly.""" + clustering = t_channel_clustering() + class_count = 3 + len(clustering.pdf_absolute_pdgs) + out = pdf_outputs(clustering, t_channel_momenta) + assert np.all(out["pdf_rw_flavor"] >= 0) + assert np.all(out["pdf_rw_flavor"] < class_count) + # the t-channel line is a u throughout, which is beam 0's own flavour, so + # no absolute flavour is needed and none should have been allocated + assert list(clustering.pdf_absolute_pdgs) == [] + active = out["pdf_rw_active"] == 1.0 + assert np.all(out["pdf_rw_flavor"][active] == 1) + + +def test_pdf_steps_name_a_real_beam(t_channel_momenta): + beam = pdf_outputs(t_channel_clustering(), t_channel_momenta)["pdf_rw_beam"] + assert np.all((beam == 0.0) | (beam == 1.0)) + + +def test_pdf_reweighting_is_off_under_the_other_scale_scheme(t_channel_momenta): + """Under clustering_mean with the ladder switched off there is no beam + parton line to walk, so asking for the reweighting there must do nothing + rather than something arbitrary.""" + clustering = t_channel_clustering( + scale_scheme=ms.MLMClustering.ScaleScheme.clustering_mean, + clustering_mean_pdf_ladder=False, + ) + _, fact1, fact2, _, _ = run(clustering, t_channel_momenta) + out = pdf_outputs(clustering, t_channel_momenta) + assert np.all(out["pdf_rw_active"] == 0.0) + assert out["pdf_scale1"] == pytest.approx(fact1) + assert out["pdf_scale2"] == pytest.approx(fact2) + + +def write_flat_pdf_grid(path, value): + """An lhagrid1 file with x f(x, Q) = value for every parton, x and Q.""" + xs = np.geomspace(1e-7, 1.0, 30) + qs = np.geomspace(1.0, 1e5, 20) + pids = [-5, -4, -3, -2, -1, 1, 2, 3, 4, 5, 21] + with open(path, "w") as f: + f.write("PdfType: central\nFormat: lhagrid1\n---\n") + f.write(" ".join(f"{x:.12e}" for x in xs) + "\n") + f.write(" ".join(f"{q:.12e}" for q in qs) + "\n") + f.write(" ".join(str(pid) for pid in pids) + "\n") + row = " ".join(f"{value:.6e}" for _ in pids) + "\n" + f.write(row * (len(xs) * len(qs))) + f.write("---\n") + + +def t_channel_integrand_outputs( + tmp_path, pdf_value, batch_size=4000, pdf_reweighting=True, partial_weights=False, + min_scale=0.0, **clustering_kwargs, +): + """Every output of the t-channel process run through a whole Integrand + under MLM (pdf reweighting by default), with the built-in flat test matrix + element and a flat PDF of the given value. clustering_kwargs go to + t_channel_clustering (scale_scheme madevent unless given); min_scale is the + scale floor of the EnergyScale, beam.min_scale in a run.""" + context = ms.Context(device=ms.cpu_device(), thread_count=1) + alphas_file = tmp_path / "alphas.info" + alphas_file.write_text( + "AlphaS_Qs: [1.0, 10.0, 100.0, 1000.0, 100000.0]\n" + "AlphaS_Vals: [0.3, 0.2, 0.12, 0.1, 0.08]\n" + ) + alphas_grid = ms.AlphaSGrid(str(alphas_file)) + alphas_grid.initialize_globals(context) + pdf_file = tmp_path / "flat.dat" + write_flat_pdf_grid(pdf_file, pdf_value) + pdf_grid = ms.PdfGrid(str(pdf_file)) + pdf_grid.initialize_globals(context) + + diagrams = t_channel_diagram() + mapping = ms.PhaseSpaceMapping( + make_topologies(diagrams)[0], + CM_ENERGY, + permutations=diagrams[0]["permutations"], + ) + # 0xBADCAFE: the built-in flat test matrix element, no library needed + matrix_element = ms.MatrixElement( + 0xBADCAFE, + len(T_CHANNEL_PDGS), + ms.Integrand.matrix_element_inputs, + ms.Integrand.matrix_element_outputs, + 1, + True, + ) + diff_xs = ms.DifferentialCrossSection( + matrix_element=matrix_element, + cm_energy=CM_ENERGY, + running_coupling=None, + energy_scale=ms.CachedScale(), + pdf1=ms.CachedPdf(), + pdf2=ms.CachedPdf(), + ) + integrand = ms.Integrand( + mapping, + [diff_xs], + pid_options=[T_CHANNEL_PDGS], + pdf_grid=pdf_grid, + running_coupling=ms.RunningCoupling(alphas_grid), + energy_scale=ms.EnergyScale( + t_channel_clustering(pdf_reweighting=pdf_reweighting, **clustering_kwargs), + min_scale=min_scale, + ), + channel_indices=[0], + partial_weights=partial_weights, + ) + return ms.FunctionRuntime(integrand.function(), context)( + np.array([batch_size], dtype=np.int64) + ) + + +def t_channel_integrand_weights(tmp_path, pdf_value, batch_size=4000, **kwargs): + """Weights and momenta of t_channel_integrand_outputs.""" + out = t_channel_integrand_outputs(tmp_path, pdf_value, batch_size, **kwargs) + return np.asarray(out[0]), np.asarray(out[1]) + + +# The ladder under clustering_mean (clustering_mean_pdf_ladder, on by default) +# goes through the same integrand path. This process has a single flavour and no +# mirror, which is where a weight_after_cuts left out of the weight product +# would drop the ladder's veto without a trace. With no jet cut, the geometric +# mean mu_R of clustering_mean falls below the alpha_s grid for a few percent of +# the events, so that case runs with the 2 GeV floor of beam.min_scale, which +# drops them as a run does (without it their weight is 0 * NaN). +LADDER_SCHEMES = [ + pytest.param({"scale_scheme": ms.MLMClustering.ScaleScheme.madevent}, + id="madevent"), + pytest.param({"scale_scheme": ms.MLMClustering.ScaleScheme.clustering_mean, + "min_scale": 2.0}, + id="clustering_mean_pdf_ladder"), +] + + +@pytest.mark.parametrize("scheme", LADDER_SCHEMES) +def test_a_density_too_small_to_divide_by_drops_the_event(tmp_path, scheme): + """reweight.f sets rewgt = 0 when the density it would divide by is under + 1e-10. With x f = 1e-12 everywhere every step of the ladder is such a + division, so every event with a step on it must come out at zero. + + A ramp in the denominator used to let these through at num den 1e20 - a + hundredth of the weight per step here, not zero.""" + weights, momenta = t_channel_integrand_weights(tmp_path, 1e-12, **scheme) + clustering = {k: v for k, v in scheme.items() if k != "min_scale"} + out = pdf_outputs(t_channel_clustering(pdf_reweighting=True, **clustering), momenta) + assert not np.isnan(weights).any() + stepped = (out["pdf_rw_active"] == 1.0).any(axis=1) + assert stepped.any() and not stepped.all(), "both kinds needed" + assert np.all(weights[stepped] == 0.0) + # The other vetoes (alpha_s and the 2 GeV scale floor) still drop the odd + # event of the rest, so only most of those survive. + assert np.mean(weights[~stepped] > 0.0) > 0.9 + + +def test_no_pdf_product_for_the_systematics_under_pdf_reweighting(tmp_path): + """The systematics divide each PDF variation, taken at (k times) the + factorisation scale, by the event's partial_weight_product. Under pdf + reweighting that product is of the densities at pdf_scale, low on the + clustering ladder, so even the central member came out != 1. The + integrand leaves it out there, and the systematics take the nominal + densities at the factorisation scale themselves.""" + def output_count(**kwargs): + return len(t_channel_integrand_outputs(tmp_path, 1.0, 16, **kwargs)) + + # the product is there without pdf reweighting ... + assert (output_count(pdf_reweighting=False, partial_weights=True) + == output_count(pdf_reweighting=False) + 1) + # ... and left out with it + assert (output_count(pdf_reweighting=True, partial_weights=True) + == output_count(pdf_reweighting=True)) + # the same under clustering_mean: left out with the ladder, which is on by + # default, and there without it + mean = {"scale_scheme": ms.MLMClustering.ScaleScheme.clustering_mean} + assert (output_count(partial_weights=True, **mean) + == output_count(**mean)) + no_ladder = dict(mean, clustering_mean_pdf_ladder=False) + assert (output_count(partial_weights=True, **no_ladder) + == output_count(**no_ladder) + 1) + + +# -------------------------------------------------------------------------- +# the merging cut must not fire at a vertex that produced no radiation +# -------------------------------------------------------------------------- + + +def w_plus_jet_diagram(): + """g u > e+ ve d, the diagram in which the jet pairs into the W. + + Vertices as mg7 builds them: the two beams give the t-channel quark p0, the + leptons give the W p1, and then p0 and the jet give the W again - so once + the W exists, (W, jet) is a valid clustering. Its measure is a kt between + the W and the jet and has nothing to do with the jet's own transverse + momentum, so a jet well above the merging cut can be clustered there at a + scale well below it. + + That vertex is not a QCD splitting - the W carries no color - and madevent + never applies the merging cut to a leg that was not emitted at a jet vertex + (its iqjets gate). Reading the cut off any vertex instead removed 19% of + this process at xqcut = 60, all of it jets that were already above the cut. + """ + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0, 0.0], + # p0 = t-channel quark, p1 = W + "propagators": [(0.0, 0.0, 1), (M_W, W_W, 24)], + "vertices": [ + ["i1", "i0", "p0"], + ["o0", "o1", "p1"], + ["p0", "o2", "p1"], + ], + "permutations": [[0, 1, 2, 3, 4]], + } + ] + + +M_W, W_W = 80.419002, 2.0476 +W_PLUS_JET_PDGS = [21, 2, -11, 12, 1] + + +def w_plus_jet_clustering(xqcut): + diagrams = w_plus_jet_diagram() + return ms.MLMClustering( + [ + ms.Topology( + ms.Diagram( + d["incoming_masses"], + d["outgoing_masses"], + [ms.Propagator(mass=m, width=w, pdg_id=i) + for m, w, i in d["propagators"]], + d["vertices"], + ) + ) + for d in diagrams + ], + [d["permutations"] for d in diagrams], + make_diagram_indices(diagrams), + cm_energy=CM_ENERGY, + external_pdg_ids=W_PLUS_JET_PDGS, + scale_scheme=ms.MLMClustering.ScaleScheme.madevent, + xqcut=xqcut, + ) + + +def test_a_jet_above_the_merging_cut_is_never_vetoed(): + """The property the whole merging rests on: an event whose only jet is + already above xqcut belongs to the matrix element, and the merging cut must + leave it alone whatever the clustering history turns out to be.""" + xqcut = 60.0 + diagrams = w_plus_jet_diagram() + momenta = sample_momenta(diagrams, batch_size=4000) + clustering = w_plus_jet_clustering(xqcut) + weight = run_all(clustering, momenta)[5] + + # mT of the one final-state parton, which is its pt here since it is + # massless, and which is the measure of every clustering that can take it + # back into a beam + jet = momenta[:, 4, :] + mt = np.sqrt(np.maximum(jet[:, 0] ** 2 - jet[:, 3] ** 2, 0.0)) + above = mt > xqcut * (1.0 + 1e-9) + + assert above.sum() > 200, "too few jets above the cut to prove anything" + assert np.all(weight[above] == 1.0), ( + "%d of %d events with the jet above the merging cut were vetoed" + % (int((weight[above] != 1.0).sum()), int(above.sum())) + ) + + +def test_a_jet_emitted_at_the_w_vertex_is_not_a_merging_jet(): + """The other half of the same rule, and the reason the test above is not + vacuous: in this one diagram the jet is produced at the W vertex, so it is + an electroweak emission and not radiation the shower would have made. Nothing + is vetoed here at any merging cut - which is madevent's behaviour, since its + iqjets is only ever set for a leg emitted at a jet vertex. + + That the cut does still remove soft QCD emissions is covered by + test_xqcut_rejects_exactly_the_soft_jet_emissions and its neighbours, which + run on a process whose jets do sit on QCD vertices. + """ + diagrams = w_plus_jet_diagram() + momenta = sample_momenta(diagrams, batch_size=4000) + for xqcut in (60.0, 500.0): + weight = run_all(w_plus_jet_clustering(xqcut), momenta)[5] + assert np.all(weight == 1.0), ( + "xqcut = %g vetoed %d events whose jet is not a QCD emission" + % (xqcut, int((weight != 1.0).sum())) + ) + + +# -------------------------------------------------------------------------- +# mt2last: an s-channel QCD root takes the mean transverse mass of the pair +# -------------------------------------------------------------------------- + + +def s_channel_ttbar_diagram(propagator): + """u u~ > t t~ through one s-channel propagator: a gluon, which is the only + diagram q q~ > t t~ has, or a Z as the colourless control. + + The last recorded clustering is then the final-state (t, t~) pair and the + root is the only other step, so this is exactly the case setclscales' + mt2last rule exists for.""" + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [M_TOP, M_TOP], + "propagators": [propagator], + "vertices": [["i0", "i1", "p0"], ["o0", "o1", "p0"]], + "permutations": [[0, 1, 2, 3]], + } + ] + + +S_CHANNEL_TTBAR_PDGS = [2, -2, 6, -6] + + +def s_channel_ttbar_clustering(propagator): + diagrams = s_channel_ttbar_diagram(propagator) + return ms.MLMClustering( + [ + ms.Topology( + ms.Diagram( + d["incoming_masses"], + d["outgoing_masses"], + [ms.Propagator(mass=m, width=w, pdg_id=i) + for m, w, i in d["propagators"]], + d["vertices"], + ) + ) + for d in diagrams + ], + [d["permutations"] for d in diagrams], + make_diagram_indices(diagrams), + cm_energy=CM_ENERGY, + external_pdg_ids=S_CHANNEL_TTBAR_PDGS, + scale_scheme=ms.MLMClustering.ScaleScheme.madevent, + ) + + +def transverse_mass(p): + return np.sqrt(np.maximum(p[..., 0] ** 2 - p[..., 3] ** 2, 0.0)) + + +def test_an_s_channel_qcd_root_uses_the_mean_transverse_mass_of_the_pair(): + """q q~ > g > t t~ is reported at sqrt(mT(t) mT(t~)), madevent's mt2last, + for both scales. Before this rule the root kept the mT of the whole final + state, sqrt(shat), which made q q~ > t t~ 18% low against madevent at + 0 jets.""" + gluon = (0.0, 0.0, 21) + momenta = sample_momenta(s_channel_ttbar_diagram(gluon), batch_size=500) + ren, fac1, fac2, _, _ = run(s_channel_ttbar_clustering(gluon), momenta) + + expected = np.sqrt( + transverse_mass(momenta[:, 2]) * transverse_mass(momenta[:, 3]) + ) + shat_mt = transverse_mass(momenta[:, 2] + momenta[:, 3]) + assert ren == pytest.approx(expected, rel=1e-9) + assert fac1 == pytest.approx(expected, rel=1e-9) + assert fac2 == pytest.approx(expected, rel=1e-9) + # and it is a different answer from the one the root would otherwise give + assert np.all(expected <= 0.5 * shat_mt * (1.0 + 1e-9)) + + +def test_a_colourless_s_channel_root_keeps_the_transverse_mass_of_the_final_state(): + """The rule needs the last clustering to be a QCD one. Through a Z the pair + joins into a colourless line, so the root keeps mT of the final state - the + Drell-Yan-like answer, and the reason W and Z + 0 jets never needed it.""" + z = (M_Z, W_Z, 23) + momenta = sample_momenta(s_channel_ttbar_diagram(z), batch_size=500) + ren, fac1, fac2, _, _ = run(s_channel_ttbar_clustering(z), momenta) + + shat_mt = transverse_mass(momenta[:, 2] + momenta[:, 3]) + assert ren == pytest.approx(shat_mt, rel=1e-9) + assert fac1 == pytest.approx(shat_mt, rel=1e-9) + assert fac2 == pytest.approx(shat_mt, rel=1e-9) + + +# -------------------------------------------------------------------------- +# t-channel propagator flavours along the chain +# -------------------------------------------------------------------------- + + +def gg_ttbar_g_chain_diagram(): + """g g > t t~ g through a t-channel chain whose two propagators differ: + + beam 0 --emits the jet--> gluon --emits t--> top --meets beam 1--> t~ + + A chain with a single propagator reads the same from both ends, so only a + chain like this one can tell whether each end of it got its own flavour and + mass. Legs: (g, g, t, t~, g).""" + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [M_TOP, M_TOP, 0.0], + "propagators": [(0.0, 0.0, 21), (M_TOP, 0.0, 6)], + "vertices": [["i0", "o2", "p0"], ["p0", "o0", "p1"], ["p1", "o1", "i1"]], + "permutations": [[0, 1, 2, 3, 4]], + } + ] + + +GG_TTBAR_G_PDGS = [21, 21, 6, -6, 21] + + +def gg_ttbar_g_chain_clustering(): + diagrams = gg_ttbar_g_chain_diagram() + return ms.MLMClustering( + [ + ms.Topology( + ms.Diagram( + d["incoming_masses"], + d["outgoing_masses"], + [ms.Propagator(mass=m, width=w, pdg_id=i) + for m, w, i in d["propagators"]], + d["vertices"], + ) + ) + for d in diagrams + ], + [d["permutations"] for d in diagrams], + make_diagram_indices(diagrams), + cm_energy=CM_ENERGY, + external_pdg_ids=GG_TTBAR_G_PDGS, + scale_scheme=ms.MLMClustering.ScaleScheme.madevent, + ) + + +def first_initial_state_transitions(clustering, n_ext): + """{(beam, leg): (massive_in, is_jet_in)} for the clusterings the walk can + take first, which are the ones whose mother is the propagator next to a + beam.""" + machine = np.asarray(clustering.cluster_state_machine) + out = {} + for data, _, trace in decode_transitions(machine, 0): + p1, p2 = field(data, BIT_PARTICLE1), field(data, BIT_PARTICLE2) + if p1 < 2: + out[(p1, p2)] = ( + field(data, BIT_MASSIVE_IN), + int(bool(trace & TRACE_IS_JET_IN)), + ) + return out + + +def test_each_end_of_a_t_channel_chain_gets_its_own_flavour(): + """Beam 0 emitting the jet leaves the gluon propagator, massless and a jet; + beam 1 taking back the t~ leaves the top propagator, massive and not a jet. + With the two ends swapped the gluon was read as the top, the beam's parton + line stopped at the jet, and mu_R picked up the jet's own scale - 3 to 12% + too much t t~ + jets against madevent.""" + first = first_initial_state_transitions(gg_ttbar_g_chain_clustering(), 5) + assert first[(0, 4)] == (0, 1), "beam 0 + jet should leave a massless jet line" + assert first[(1, 3)] == (1, 0), "beam 1 + t~ should leave a massive, non-jet line" + + +def test_a_jet_emitted_before_the_tops_does_not_set_the_renormalisation_scale(): + """setclscales carries a beam's parton line on past a jet emission to the + vertex where the top attaches, so when the jet is clustered into a beam and + the tops make up the rest, all four scales in the mu_R average are central + ones and mu_R equals mu_F. The jet's own scale entering mu_R is exactly the + symptom of the swapped chain, whose kernel gave mu_R == mu_F on no point. + It also needs the root measured where cluster.f measures it: not boosted + after the last clustering, and in the current frame after an initial-state + one. Taking it in the lab left mu_R a few per cent below mu_F.""" + clustering = gg_ttbar_g_chain_clustering() + momenta = sample_momenta(gg_ttbar_g_chain_diagram(), batch_size=4000) + ren, fac1, fac2, outgoing, _ = run(clustering, momenta) + jet = momenta[:, 4, :] + jet_pt = np.sqrt(jet[:, 1] ** 2 + jet[:, 2] ** 2) + # the jet went into a beam: its clustering scale is its transverse momentum + jet_into_beam = np.abs(outgoing[:, 2] / jet_pt - 1.0) < 1e-9 + assert jet_into_beam.sum() > 500, "too few jet-into-beam histories to test" + # the root is measured in the frame the boost after the jet left behind, + # where t and t~ balance, so the equality holds up to rounding + same = np.abs(ren / fac1 - 1.0) < 1e-6 + assert np.mean(same[jet_into_beam]) > 0.5, ( + "mu_R == mu_F on only %.1f%% of jet-into-beam points" + % (100 * np.mean(same[jet_into_beam])) + ) + + +# -------------------------------------------------------------------------- +# clustering_measure: FxFx (NLO cluster_scale) against madevent's LO DJ +# -------------------------------------------------------------------------- + + +def w_plus_jet_two_diagrams(): + """g u > e+ ve d with both ways the jet can be clustered: + + s-channel: g u > u*, u* > d W - the jet pairs with the W (massless mother, + massive and massless daughters, the case the measures disagree + on) + t-channel: u emits the W and becomes d*, d* g > d - the jet goes into + beam 0 + + madevent scores the (W, d) pair by the d's transverse mass times 1 + 1e-6, + which never beats taking the d into a beam; the FxFx measure scores it by + sqrt(|p_d.(p_d+p_W)|)/2, which often does.""" + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0, 0.0], + "propagators": [(0.0, 0.0, 2), (M_W, W_W, 24)], + "vertices": [["i1", "i0", "p0"], ["o0", "o1", "p1"], ["p0", "o2", "p1"]], + "permutations": [[0, 1, 2, 3, 4]], + }, + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0, 0.0], + "propagators": [(0.0, 0.0, 1), (M_W, W_W, 24)], + "vertices": [["o0", "o1", "p1"], ["i1", "p1", "p0"], ["p0", "i0", "o2"]], + "permutations": [[0, 1, 2, 3, 4]], + }, + ] + + +def w_plus_jet_two_diagram_clustering(**kwargs): + diagrams = w_plus_jet_two_diagrams() + return ms.MLMClustering( + [ + ms.Topology( + ms.Diagram( + d["incoming_masses"], + d["outgoing_masses"], + [ms.Propagator(mass=m, width=w, pdg_id=i) + for m, w, i in d["propagators"]], + d["vertices"], + ) + ) + for d in diagrams + ], + [d["permutations"] for d in diagrams], + make_diagram_indices(diagrams), + cm_energy=CM_ENERGY, + external_pdg_ids=W_PLUS_JET_PDGS, + scale_scheme=ms.MLMClustering.ScaleScheme.madevent, + **kwargs, + ) + + +def jet_into_beam_fraction(clustering, momenta): + outgoing = run(clustering, momenta)[3] + jet = momenta[:, 4, :] + jet_pt = np.sqrt(jet[:, 1] ** 2 + jet[:, 2] ** 2) + return np.mean(np.abs(outgoing[:, 2] / jet_pt - 1.0) < 1e-5) + + +def test_fxfx_is_the_default_clustering_measure(): + clustering = w_plus_jet_two_diagram_clustering() + assert clustering.clustering_measure == ms.MLMClustering.ClusteringMeasure.fxfx + + +def test_the_madevent_measure_takes_the_jet_into_the_beam(): + """Under madevent's measure the jet never pairs with the W: its scale is its + transverse momentum on every point, as in madevent (99% there, the rest + being histories without this choice). Half of these jets go against the + beam, where only the LO ordering of the 1 + 1e-6 factors and madevent's + keep-the-first tie-break decide it.""" + momenta = sample_momenta(w_plus_jet_two_diagrams(), batch_size=4000) + clustering = w_plus_jet_two_diagram_clustering( + clustering_measure=ms.MLMClustering.ClusteringMeasure.madevent + ) + assert jet_into_beam_fraction(clustering, momenta) == 1.0 + + +def test_the_fxfx_measure_lets_the_jet_pair_with_the_w(): + """The FxFx score of the (W, d) pair is often below the d's pt, so some jets + pair with the W instead - which is exactly what made W + 1 jet (quark jets) + and h + 1 jet (gluon jets) low against madevent.""" + momenta = sample_momenta(w_plus_jet_two_diagrams(), batch_size=4000) + fxfx = jet_into_beam_fraction( + w_plus_jet_two_diagram_clustering( + clustering_measure=ms.MLMClustering.ClusteringMeasure.fxfx + ), + momenta, + ) + assert fxfx < 0.95, "the FxFx measure should pair a visible fraction with the W" + + +# -------------------------------------------------------------------------- +# clustering_history: over every diagram, or along one diagram +# -------------------------------------------------------------------------- + +HISTORY = ms.MLMClustering.ClusteringHistory + + +def history_clustering(diagrams, pdg_ids, history, **kwargs): + kwargs.setdefault("scale_scheme", ms.MLMClustering.ScaleScheme.madevent) + return make_clustering( + diagrams, external_pdg_ids=pdg_ids, clustering_history=history, **kwargs + ) + + +def along(clustering, momenta, diagram): + diagram = np.broadcast_to(np.asarray(diagram, dtype=np.int32), len(momenta)) + return [ + np.asarray(v) + for v in ms.MLMClusteringAlongDiagram(clustering)( + momenta, np.ascontiguousarray(diagram) + ) + ] + + +def test_all_diagrams_is_the_default_history(process): + _, diagrams, pdg_ids = process + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + assert clustering.clustering_history == HISTORY.all_diagrams + assert len(clustering.diagram_start_states) == 0 + + +def test_every_diagram_gets_a_state_machine_after_the_union_one(process): + _, diagrams, pdg_ids = process + union = history_clustering(diagrams, pdg_ids, HISTORY.all_diagrams) + per_diagram = history_clustering(diagrams, pdg_ids, HISTORY.diagram) + machine = per_diagram.cluster_state_machine + union_size = len(union.cluster_state_machine) + assert machine[:union_size] == union.cluster_state_machine + starts = np.asarray(per_diagram.diagram_start_states) + assert len(starts) == sum(len(d["permutations"]) for d in diagrams) + assert np.all(starts >= union_size) + assert np.all(starts < len(machine)) + assert len(set(starts)) == len(starts) + + +def test_the_history_setting_leaves_the_union_walk_alone(process): + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + reference = run_all(history_clustering(diagrams, pdg_ids, HISTORY.all_diagrams), momenta) + for history in (HISTORY.diagram, HISTORY.madevent): + other = run_all(history_clustering(diagrams, pdg_ids, history), momenta) + for a, b in zip(reference, other): + np.testing.assert_array_equal(a, b) + + +def test_the_union_walk_cannot_be_walked_along_a_diagram(process): + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams, batch_size=4) + clustering = history_clustering(diagrams, pdg_ids, HISTORY.all_diagrams) + with pytest.raises(Exception): + along(clustering, momenta, 0) + + +def test_a_history_along_a_diagram_reports_that_diagram(process): + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + clustering = history_clustering(diagrams, pdg_ids, HISTORY.diagram, xqcut=20.0) + count = len(clustering.diagram_start_states) + picked = np.random.default_rng(3).integers(0, count, len(momenta)) + out = along(clustering, momenta, picked) + np.testing.assert_array_equal(out[4], picked) + for value in out: + assert np.all(np.isfinite(value)) + + +def test_a_single_diagram_clusters_along_itself_as_its_union_does(process): + """A process made of one diagram has nothing to merge, so the history over + every diagram and the one along that diagram are the same walk.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + single = [dict(diagrams[0], permutations=diagrams[0]["permutations"][:1])] + union = run_all(history_clustering(single, pdg_ids, HISTORY.all_diagrams), momenta) + full = history_clustering(diagrams, pdg_ids, HISTORY.diagram) + out = along(full, momenta, 0) + for a, b in zip(union, out): + np.testing.assert_array_equal(a, b) + + +def test_the_madevent_history_is_either_the_union_or_the_diagram(process): + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + count = sum(len(d["permutations"]) for d in diagrams) + picked = np.random.default_rng(4).integers(0, count, len(momenta)) + union = run_all(history_clustering(diagrams, pdg_ids, HISTORY.madevent), momenta) + diagram = along(history_clustering(diagrams, pdg_ids, HISTORY.diagram), momenta, picked) + madevent = along(history_clustering(diagrams, pdg_ids, HISTORY.madevent), momenta, picked) + is_union = np.all([np.isclose(a, b).reshape(len(momenta), -1).all(axis=1) + for a, b in zip(union, madevent)], axis=0) + is_diagram = np.all([np.isclose(a, b).reshape(len(momenta), -1).all(axis=1) + for a, b in zip(diagram[:6], madevent[:6])], axis=0) + assert np.all(is_union | is_diagram) + # both outcomes occur for a process with this many diagrams + assert 0 < np.mean(is_union) < 1 + + +def test_a_diagram_history_uses_that_diagrams_own_line_flavours(): + """g u > e+ ve d. Along the t-channel diagram the jet goes into beam 0 and + its clustering is a QCD vertex that alpha_s is reweighted at. Along the + s-channel one (u* > d W) the only QCD vertex left is g u > u*, which the + union can also pick; the two diagrams must never hand back each other's + history.""" + diagrams = w_plus_jet_two_diagrams() + momenta = sample_momenta(diagrams, batch_size=2000) + clustering = w_plus_jet_two_diagram_clustering( + clustering_history=HISTORY.diagram, + clustering_measure=ms.MLMClustering.ClusteringMeasure.madevent, + ) + s_channel = along(clustering, momenta, 0) + t_channel = along(clustering, momenta, 1) + jet = momenta[:, 4, :] + jet_pt = np.sqrt(jet[:, 1] ** 2 + jet[:, 2] ** 2) + # the t-channel history always takes the jet into a beam at its pt + assert np.mean(np.abs(t_channel[3][:, 2] / jet_pt - 1.0) < 1e-5) == 1.0 + # the s-channel one never clusters the jet with beam 0 before the W + assert np.mean(np.abs(s_channel[3][:, 2] / jet_pt - 1.0) < 1e-5) < 0.5 + + +# -------------------------------------------------------------------------- +# jet scales of legs that are not merging jets, under scale_scheme = madevent +# -------------------------------------------------------------------------- + + +def vbf_like_fsr_diagram(): + """u d > u d g with a colourless t-channel Z between the two quark lines + and the gluon radiated off the outgoing u: + + u* > u g (final state, a jet vertex) + u Z > u* (beam 0 line, through the Z) + Z d > d (beam 1 line) + + Neither quark is a merging jet in madevent (the Z stops both beam lines + being parton lines), the gluon is.""" + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0, 0.0], + "propagators": [(0.0, 0.0, 2), (M_Z, 0.0, 23)], + "vertices": [["o0", "o2", "p0"], ["i0", "p1", "p0"], ["p1", "i1", "o1"]], + "permutations": [[0, 1, 2, 3, 4]], + }, + ] + + +def vbf_like_fsr_clustering(**kwargs): + diagrams = vbf_like_fsr_diagram() + return ms.MLMClustering( + [ + ms.Topology( + ms.Diagram( + d["incoming_masses"], + d["outgoing_masses"], + [ms.Propagator(mass=m, width=w, pdg_id=i) + for m, w, i in d["propagators"]], + d["vertices"], + ) + ) + for d in diagrams + ], + [d["permutations"] for d in diagrams], + make_diagram_indices(diagrams), + cm_energy=CM_ENERGY, + external_pdg_ids=[2, 1, 2, 1, 21], + scale_scheme=ms.MLMClustering.ScaleScheme.madevent, + **kwargs, + ) + + +def fsr_jet_scales(**kwargs): + momenta = sample_momenta(vbf_like_fsr_diagram(), batch_size=500) + return run(vbf_like_fsr_clustering(**kwargs), momenta)[3] + + +def test_a_quark_that_is_not_a_merging_jet_is_written_at_the_collider_energy(): + """madevent writes ptclus = sqrt(s) for every leg iqjets does not call a + merging jet, even one that radiated at a QCD vertex: the shower's MLM + matching must leave it alone. The gluon keeps the scale of the vertex it + was emitted at. (The kt measure of these unconstrained points can itself + exceed sqrt(s), so the scales are compared to each other, not to it.)""" + production = fsr_jet_scales() + emission = fsr_jet_scales(jet_scale_scheme=ms.MLMClustering.JetScaleScheme.emission) + np.testing.assert_array_equal(production[:, 0], CM_ENERGY) + np.testing.assert_array_equal(production[:, 1], CM_ENERGY) + np.testing.assert_array_equal(production[:, 2], emission[:, 2]) + assert np.all(production[:, 2] != CM_ENERGY) + + +def test_the_emission_scheme_still_books_the_radiating_quark(): + """The emission scheme is not madevent's and keeps booking the (u, g) + vertex onto both of its legs.""" + emission = fsr_jet_scales(jet_scale_scheme=ms.MLMClustering.JetScaleScheme.emission) + np.testing.assert_array_equal(emission[:, 0], emission[:, 2]) + np.testing.assert_array_equal(emission[:, 1], CM_ENERGY) + + +# -------------------------------------------------------------------------- +# clusterings that only exist for some flavours of a merged subprocess +# -------------------------------------------------------------------------- + +TRACE_SAME_FLAVOR = 1 << 16 + +# q q~ > q q~ with a merged quark, as clean_pids in launch.py represents it +QQ_TO_QQ_PDGS = [1, -1, 1, -1] +GLUON = [0.0, 0.0, 0, 0.0, 0.0, 21] + + +def qq_to_qq_diagrams(): + """The s-channel gluon (diagram 0: legs 2 and 3) and the t-channel gluon + (diagram 1: legs 0 and 2, 1 and 3) of q q~ > q q~. For a q q~ > q' q~' + flavour only the first exists, for q q~' > q q~' only the second.""" + common = { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0], + "propagators": [GLUON], + "permutations": [[0, 1, 2, 3]], + } + return [ + dict(common, vertices=[["o0", "o1", "p0"], ["i0", "i1", "p0"]]), + dict(common, vertices=[["i0", "o0", "p0"], ["p0", "o1", "i1"]]), + ] + + +def with_flavors(clustering, leg_flavors, momenta, flavor_index): + index = np.full(len(momenta), flavor_index, dtype=np.int32) + return [ + np.asarray(v) + for v in ms.MLMClusteringWithFlavors(clustering, leg_flavors)(momenta, index) + ] + + +def test_flavour_diagonal_clusterings_of_fermions_are_marked(): + """Every clustering of q q~ > q q~ joins two quarks through a gluon, so all + of them depend on the flavours of the event.""" + clustering = make_clustering(qq_to_qq_diagrams(), external_pdg_ids=QQ_TO_QQ_PDGS) + non_terminal, _ = walk(np.asarray(clustering.cluster_state_machine), 4) + marked, unmarked = set(), set() + for _, (_, transitions) in non_terminal.items(): + for data, _, trace in transitions: + pair = (field(data, BIT_PARTICLE1), field(data, BIT_PARTICLE2)) + (marked if trace & TRACE_SAME_FLAVOR else unmarked).add(pair) + assert marked == {(0, 2), (1, 3), (2, 3)} + assert unmarked == set() + + +def test_clustering_follows_the_diagrams_the_flavours_allow(): + """u u~ > d d~ only has the s-channel gluon and u d~ > u d~ only the + t-channel one, whichever of the two the kt measure prefers; a flavour with + every leg alike, or no flavour information, keeps the union. + + For a 2 -> 2 the initial-state measure (the pt of the jet) always beats the + final-state one of the back-to-back pair, so the union always takes the + t-channel gluon: the q-q' clustering a u u~ > d d~ event does not have.""" + diagrams = qq_to_qq_diagrams() + clustering = make_clustering(diagrams, external_pdg_ids=QQ_TO_QQ_PDGS) + momenta = sample_momenta(diagrams) + leg_flavors = [1, 1, 1, 1, 2, 2, 1, 1, 2, 1, 2, 1] + union = run(clustering, momenta) + assert np.all(union[4] == 1) + + same = with_flavors(clustering, leg_flavors, momenta, 0) + s_channel = with_flavors(clustering, leg_flavors, momenta, 1) + t_channel = with_flavors(clustering, leg_flavors, momenta, 2) + unknown = with_flavors(clustering, [0, 0, 0, 0], momenta, 0) + + assert np.all(s_channel[4] == 0) + assert np.all(t_channel[4] == 1) + for other in (same, unknown): + for a, b in zip(union, other[:5]): + np.testing.assert_array_equal(a, b) + + +# -------------------------------------------------------------------------- +# 2 -> 1: the 0-jet sample of p p > z + jets +# -------------------------------------------------------------------------- + + +def drell_yan_two_to_one_clustering(**kwargs): + """u u~ > z: nothing to cluster. cluster.f handles it as its "special + 2 -> 1 case", pt2ijcl = the invariant mass squared of the z, and + setclscales sets both scales from that.""" + kwargs.setdefault("cm_energy", CM_ENERGY) + kwargs.setdefault("external_pdg_ids", [2, -2, 23]) + return ms.MLMClustering( + [ms.Topology(ms.Diagram([0.0, 0.0], [M_Z], [], [["i0", "i1", "o0"]]))], + [[[0, 1, 2]]], + [[0]], + **kwargs, + ) + + +def two_to_one_momenta(batch_size=BATCH_SIZE, seed=SEED): + """Unequal beam energies, so that the event is not at rest in the lab.""" + rng = np.random.default_rng(seed) + e1 = rng.uniform(20.0, 500.0, batch_size) + e2 = rng.uniform(20.0, 500.0, batch_size) + p = np.zeros((batch_size, 3, 4)) + p[:, 0] = np.stack([e1, 0 * e1, 0 * e1, e1], axis=1) + p[:, 1] = np.stack([e2, 0 * e2, 0 * e2, -e2], axis=1) + p[:, 2] = p[:, 0] + p[:, 1] + return p, np.sqrt(4.0 * e1 * e2) + + +@pytest.mark.parametrize( + "scale_scheme", + [ms.MLMClustering.ScaleScheme.clustering_mean, ms.MLMClustering.ScaleScheme.madevent], +) +@pytest.mark.parametrize( + "history", + list(ms.MLMClustering.ClusteringHistory.__members__.values()), +) +def test_a_two_to_one_process_takes_its_scales_from_shat(scale_scheme, history): + """Used to throw "MLM clustering found no valid clustering for this + process", which killed the 0-jet sample of every p p > z @0 + jets run.""" + clustering = drell_yan_two_to_one_clustering( + scale_scheme=scale_scheme, clustering_history=history, xqcut=20.0 + ) + momenta, shat_root = two_to_one_momenta() + ren, fact1, fact2, outgoing, diagram, xqcut_weight = run_all(clustering, momenta) + np.testing.assert_allclose(ren, shat_root, rtol=1e-12) + np.testing.assert_allclose(fact1, shat_root, rtol=1e-12) + np.testing.assert_allclose(fact2, shat_root, rtol=1e-12) + # the z is no jet: written at the collider energy, where no veto trips + assert np.all(outgoing == CM_ENERGY) + assert np.all(diagram == 0) + # nothing was emitted, so nothing can fail the merging cut + assert np.all(xqcut_weight == 1.0) diff --git a/madspace/tests/test_systematics.py b/madspace/tests/test_systematics.py index bdc49e2476..39b55f4264 100644 --- a/madspace/tests/test_systematics.py +++ b/madspace/tests/test_systematics.py @@ -289,13 +289,14 @@ def test_lhe_event_rwgt_format(): def test_config_json_roundtrip(): grid_file, info_file = grid_files() - config = make_config(write_inputs=True, together=False) + config = make_config(write_inputs=True, together=False, alps=[0.5, 1.0, 2.0]) config.pdf_members = [ ms.PdfMemberSpec(PDF_SET, 338500, 1, grid_file, info_file, "replicas", "d") ] text = config.to_json() back = ms.SystematicsConfig.from_json(text) assert back.mur == config.mur and back.muf == config.muf + assert back.alps == [0.5, 1.0, 2.0] assert back.write_inputs and not back.together assert back.pdf_members[0].grid_file == grid_file args = ms.SubprocessSystArgs(qcd_power=2, beam_pdgs=[[2, 2], [21, 2]]) @@ -460,3 +461,127 @@ def test_event_histograms(grids, ref): for lo, nom, hi in zip(env["low"], data2[0]["bin_values"], env["high"]): assert lo <= nom + 1e-12 and hi >= nom - 1e-12 assert [w["id"] for w in data2[0]["weights"]] == [1, 2] + + +def test_mlm_vertices_follow_the_emission_scale(grids, ref): + """An MLM event carries alpha_s(mu_R)^(n - n_v) prod_v alpha_s(q_v). As in + madevent's systematics.py, a mu_R variation moves only the n - n_v powers, + and an emission scale variation (alpsfact) only the vertices. A vertex + carrying 0 is one the clustering did not reweight: it is one of the mu_R + powers. A reweighted vertex can sit exactly at mu_R (one clustering under + clustering_mean) and is still a vertex.""" + grid, alpha_s = grids + config = make_config(mur=[0.5, 1.0, 2.0], muf=[1.0], alps=[0.5, 1.0, 2.0]) + n = 4 + args = [ms.SubprocessSystArgs(qcd_power=n, beam_pdgs=[[2, 2]])] + calc = ms.SystematicsCalculator(config, args, grid, alpha_s) + variations = calc.variations + assert [(v.mur, v.alps) for v in variations] == [ + (0.5, 1.0), (2.0, 1.0), (1.0, 0.5), (1.0, 2.0) + ] + assert [v.is_emission for v in variations] == [False, False, True, True] + assert calc.emission_variation_indices == [2, 3] + assert 'name="Emission scale variation"' in calc.initrwgt() + assert 'ALPSFACT="0.5"' in calc.initrwgt() + + events = make_events(ref, n=30) + rng = np.random.default_rng(7) + vertices = [] + for i, mu in enumerate(events["ren_scale"]): + # two reweighted vertices, the second at mu_R on every other event, + # and one that is not reweighted + second = mu if i % 2 else mu * rng.uniform(0.05, 0.9) + vertices.append([mu * rng.uniform(0.05, 0.9), second, 0.0]) + weights = calc.weights(**events, alphas_scales=vertices) + for i, row in enumerate(weights): + w0 = events["event_weight"][i] + mu = events["ren_scale"][i] + q = np.array(vertices[i][:2]) + nominal = ref.alphas(mu) ** (n - 2) * np.prod(ref.alphas(q)) + for var, w in zip(variations, row): + varied = ref.alphas(var.mur * mu) ** (n - 2) * np.prod( + ref.alphas(var.alps * q) + ) + assert w == approx(w0 * varied / nominal, rel=1e-12) + + # without the vertex scales in the record: every power follows mu_R, and + # an emission variation is the nominal weight + for w0, mu, row in zip( + events["event_weight"], events["ren_scale"], calc.weights(**events) + ): + assert row[0] == approx(w0 * (ref.alphas(0.5 * mu) / ref.alphas(mu)) ** n, + rel=1e-12) + assert row[2] == approx(w0, rel=1e-15) + + +def test_mlm_pdf_ladder_follows_alpsfact_and_muf(grids, ref): + """The pdf reweighting ladder of an MLM event, varied as madevent's + systematics.py rebuilds it from the pdfrwt entries of each beam: the beam + parton at x_beam and its leading scale, then per step the line's flavour, + its momentum fraction before the step and the step's scale. With S the + last entry's scale, a variation is + f(x_N-1, k S) prod_i 1 + sample) keeps the plain beam ratio.""" + grid, alpha_s = grids + low = 2.0 + config = make_config( + mur=[1.0], muf=[0.5, 1.0, 2.0], alps=[0.5, 1.0, 2.0], + pdf_ladder=True, ladder_min_scale=low, + ) + # no alpha_s: the weights move through the PDFs alone + args = [ms.SubprocessSystArgs(qcd_power=0, beam_pdgs=[[2, 2]])] + calc = ms.SystematicsCalculator(config, args, grid, alpha_s) + variations = calc.variations + assert [(v.muf, v.alps) for v in variations] == [ + (0.5, 1.0), (2.0, 1.0), (1.0, 0.5), (1.0, 2.0) + ] + + events = make_events(ref, n=20) + rng = np.random.default_rng(11) + ladder, lead = [], [] + for i, (x1, mu) in enumerate(zip(events["x1"], events["ren_scale"])): + if i == 0: + ladder.append([]) + lead.append([0.0, 0.0]) # no ladder at all + continue + q1 = max(mu * rng.uniform(0.02, 0.8), low) + q2 = max(mu * rng.uniform(0.02, 0.8), q1) + z1, z2 = rng.uniform(0.3, 0.9, 2) + # beam 1 lowered to q1, with two steps (a gluon line from q1 to q2, + # then a quark line up to mu); beam 2 left at mu with no step. + # Step rows: [x before the step, pdg, scale, active + 2 beam] + lead.append([q1, mu]) + ladder.append([[0.0] * 4, [x1, 21, q2, 1.0], [x1 * z1, 2, mu, 1.0]]) + del z2 + weights = calc.weights(**events, ladder=ladder, ladder_lead=lead) + + def ladder_weight(i, k, a): + x1, x2, mu = events["x1"][i], events["x2"][i], events["ren_scale"][i] + clamp = lambda q: max(q, low) + # beam 1: entries (2, x1, q1), (21, x1, q2), (2, x1 z1, mu) + entries = [(2, x1, lead[i][0]), (21, x1, ladder[i][1][2]), + (2, ladder[i][2][0], ladder[i][2][2])] + top = k * entries[-1][2] + w = ref.pdf(entries[-1][0], entries[-1][1], clamp(top)) + for (pdg, x, q), (_, x_next, _) in zip(entries, entries[1:]): + scale = clamp(min(a * q, top)) + w *= ref.pdf(pdg, x, scale) / ref.pdf(pdg, x_next, scale) + # beam 2: its leading density alone, at k mu + return w * ref.pdf(2, x2, clamp(k * lead[i][1])) + + for i, row in enumerate(weights): + w0 = events["event_weight"][i] + x1, x2, mu = events["x1"][i], events["x2"][i], events["ren_scale"][i] + for var, w in zip(variations, row): + if i == 0: + if var.alps != 1.0: + assert w == approx(w0, rel=1e-15) + continue + expected = (ref.pdf(2, x1, var.muf * mu) * ref.pdf(2, x2, var.muf * mu) + / events["partial_weight_product"][i]) + else: + expected = ladder_weight(i, var.muf, var.alps) / ladder_weight(i, 1.0, 1.0) + assert w == approx(w0 * expected, rel=1e-12) diff --git a/tests/acceptance_tests/test_cmd_madevent.py b/tests/acceptance_tests/test_cmd_madevent.py index 1a807fde0b..4221b8e783 100755 --- a/tests/acceptance_tests/test_cmd_madevent.py +++ b/tests/acceptance_tests/test_cmd_madevent.py @@ -2083,6 +2083,76 @@ def test_flavor_grouping_consistency_mlm(self): ) + def test_mlm_pdfrwt_flavour(self): + """With MLM (ickkw=1) and use_syst, the information must carry + the event's flavours. A grouped + subprocess (q q~ > z with q = d s / u c) clusters with the + representative of its coupling class: s s~ > z was booked as 1 and + c c~ > z as 2, so the systematics of every s/c-initiated event used + the d/u PDF.""" + + import gzip + mg_cmd = MGCmd.MasterCmd() + mg_cmd.no_notification() + mg_cmd.exec_cmd('set automatic_html_opening False --no_save') + mg_cmd.exec_cmd('import model sm') + mg_cmd.exec_cmd('generate p p > z @0') + mg_cmd.exec_cmd('add process p p > z j @1') + # scalar mode only: vector_size>1 with grouped flavours does not run + # (DSIG_VEC reads the per-warp IFLAV_VEC per event) + for vector_size in [1]: + run_dir = pjoin(self.path, 'MGPROC_pdfrwt_%d' % vector_size) + mg_cmd.exec_cmd('output madevent %s' % run_dir) + + run_card = banner.RunCardLO(pjoin(run_dir, 'Cards', 'run_card.dat')) + run_card.set('nevents', 2000, user=True) + run_card.set('iseed', 7, user=True) + run_card.set('ickkw', 1, user=True) + run_card.set('xqcut', 20.0, user=True) + run_card.set('use_syst', True, user=True) + run_card.set('systematics_program', 'none', user=True) + if vector_size > 1: + run_card.set('vector_size', vector_size, user=True) + run_card.write(pjoin(run_dir, 'Cards', 'run_card.dat')) + + self.cmd_line = MECmd.MadEventCmdShell(me_dir=run_dir) + self.cmd_line.no_notification() + self.cmd_line.exec_cmd('set automatic_html_opening False') + self.do('generate_events -f') + + path = pjoin(run_dir, 'Events', 'run_01', 'unweighted_events.lhe.gz') + text = gzip.open(path, 'rt').read() + checked = collections.Counter() + for event in re.findall(r']*>(.*?)', text, re.S): + lines = [l.split() for l in event.split('<')[0].strip().splitlines()] + partons = [(int(l[0]), int(l[1]), float(l[8])) for l in lines[1:]] + # beam 1 moves along +z, beam 2 along -z + beam = dict((1 if pz > 0 else 2, pdg) + for pdg, status, pz in partons if status == -1) + flavours = set(abs(pdg) for pdg, status, pz in partons + if abs(pdg) <= 6) + pdfrwt = dict((int(b), [int(x) for x in body.split()[1:1+int(body.split()[0])]]) + for b, body in re.findall( + r'(.*?)', event)) + self.assertEqual(sorted(pdfrwt), [1, 2], event) + for b in (1, 2): + # leading entry: the incoming parton of this beam + self.assertEqual(pdfrwt[b][0], beam[b], + 'vector_size=%s: leading pdfrwt entry of beam %s is ' + 'not the incoming parton:\n%s' % (vector_size, b, event)) + # ladder: only quark flavours present in the event + for pdg in pdfrwt[b][1:]: + if abs(pdg) <= 6: + self.assertIn(abs(pdg), flavours, + 'vector_size=%s: pdfrwt ladder of beam %s has a ' + 'flavour absent from the event:\n%s' + % (vector_size, b, event)) + quarks = [abs(pdg) for pdg in beam.values() if pdg != 21] + checked[max(quarks) if quarks else 21] += 1 + # the check is only meaningful with s and c initiated events + self.assertGreater(checked[3], 0, checked) + self.assertGreater(checked[4], 0, checked) + def test_t_channel_survey_grid(self): """check that refining on the survey grid is not biased for u q > z u q (all channels pure t-channel chains). With the grid diff --git a/tests/unit_tests/interface/test_mg7_launch.py b/tests/unit_tests/interface/test_mg7_launch.py index 772d85652a..6e4af33ae2 100644 --- a/tests/unit_tests/interface/test_mg7_launch.py +++ b/tests/unit_tests/interface/test_mg7_launch.py @@ -1568,3 +1568,83 @@ def test_read_through_the_permutation(self): meta = self.meta([['i1', 'i0', 'p0'], ['o0', 'o1', 'p1'], ['p0', 'o2', 'p1']], [1], permutation=(0, 1, 4, 2, 3)) self.assertEqual(self.launch.decay_products(meta), {1, 2}) + + +@unittest.skipUnless(mg7_bootstrap.madspace_is_installed(), + 'madspace is not installed') +class MG7AutoPtjMjjCutNamesTest(unittest.TestCase): + """The cuts auto_ptj_mjj writes must be ones madspace can build. + + apply_auto_ptj_mjj names a cut after the observable it wants, and + parse_observable turns that name straight into an ms.Observable option. + Nothing else checks the two agree, so renaming an observable in madspace + (m_inv became pair_mass) left the launcher asking for a cut that no longer + existed, and every merged run with xqcut > 0 died building its cuts -- + before reaching the clustering the cut exists for. + + The two methods are called unbound, on a stand-in carrying just the run + card: building a real MadgraphProcess needs a process directory, which + would put this test out of reach of a unit run. + """ + + OUTGOING_PDGS = [21, 21, 25, 1, -1] + + def setUp(self): + from madgraph.iolibs.template_files.mg7 import launch as mg7_launch + self.launch = mg7_launch + + def make_process(self, cuts, xqcut=20.0, auto=True): + return types.SimpleNamespace(is_decay=False, run_card={ + 'beam': {'dynamical_scale_choice': 'mlm'}, + 'phasespace': {'xqcut': xqcut, 'auto_ptj_mjj': auto}, + 'multiparticles': {'jet': [21, 1, 2, -1, -2], + 'lepton': [11, 13, -11, -13]}, + 'cuts': dict(cuts), + }) + + def apply(self, cuts, **kwargs): + process = self.make_process(cuts, **kwargs) + out = {key: dict(values) for key, values in cuts.items()} + self.launch.MadgraphProcess.apply_auto_ptj_mjj(process, out) + return process, out + + def test_every_cut_it_writes_builds_an_observable(self): + import madspace as ms + + process, cuts = self.apply({'jet-pt': {'min': 10.0}, + 'jet-delta_r': {'min': 0.4}}) + for key in cuts: + kwargs = self.launch.MadgraphProcess.parse_observable( + process, key, 'pt') + # raises TypeError if the observable name is not one madspace knows + ms.Observable(self.OUTGOING_PDGS, **kwargs) + + def test_it_tightens_the_jet_pt_and_pair_mass_to_xqcut(self): + _, cuts = self.apply({'jet-pt': {'min': 10.0}}, xqcut=20.0) + self.assertEqual(cuts['jet-pt']['min'], 20.0) + self.assertEqual(cuts['jet-pair_mass']['min'], 20.0) + + def test_it_drops_the_jet_cone_cuts(self): + """A dR cut would carve a hole out of the region the shower fills.""" + _, cuts = self.apply({'jet-delta_r': {'min': 0.4}, + 'jet-lepton-delta_r': {'min': 0.4}}) + self.assertEqual(cuts['jet-delta_r']['min'], 0.0) + self.assertEqual(cuts['jet-lepton-delta_r']['min'], 0.0) + + def test_with_the_option_off_it_only_zeroes_cuts_above_xqcut(self): + _, cuts = self.apply({'jet-pt': {'min': 50.0}}, xqcut=20.0, auto=False) + self.assertEqual(cuts['jet-pt']['min'], 0.0) + self.assertNotIn('jet-pair_mass', cuts) + _, cuts = self.apply({'jet-pt': {'min': 10.0}}, xqcut=20.0, auto=False) + self.assertEqual(cuts['jet-pt']['min'], 10.0) + + def test_it_leaves_an_unmerged_run_alone(self): + for kwargs in ({'xqcut': 0.0}, {}): + process = self.make_process({'jet-pt': {'min': 10.0}}, **kwargs) + if not kwargs: + process.run_card['beam']['dynamical_scale_choice'] = \ + 'transverse_mass' + cuts = {'jet-pt': {'min': 10.0}, 'jet-delta_r': {'min': 0.4}} + self.launch.MadgraphProcess.apply_auto_ptj_mjj(process, cuts) + self.assertEqual(cuts, {'jet-pt': {'min': 10.0}, + 'jet-delta_r': {'min': 0.4}}) diff --git a/tests/unit_tests/interface/test_mg7_mlm_pdf_ladder.py b/tests/unit_tests/interface/test_mg7_mlm_pdf_ladder.py new file mode 100644 index 0000000000..414acbe915 --- /dev/null +++ b/tests/unit_tests/interface/test_mg7_mlm_pdf_ladder.py @@ -0,0 +1,67 @@ +################################################################################ +# +# Copyright (c) 2026 The MadGraph5_aMC@NLO Development team and Contributors +# +# This file is a part of the MadGraph5_aMC@NLO project, an application which +# automatically generates Feynman diagrams and matrix elements for arbitrary +# high-energy processes in the Standard Model and beyond. +# +# It is subject to the MadGraph5_aMC@NLO license which should accompany this +# distribution. +# +# For more information, visit madgraph.phys.ucl.ac.be and amcatnlo.web.cern.ch +# +################################################################################ +"""When the native systematics vary the MLM pdf ladder. + +The kernel walks the ladder under scale_scheme "madevent", and under +"clustering_mean" with clustering_mean_pdf_ladder (the default), both only with +pdf_reweighting. The systematics have to vary the ladder in exactly those +cases: without it, a ladder sample's mu_F variations would leave the ladder's +lowered leading density and its ratios untouched. +""" +from __future__ import absolute_import + +import unittest + +from madgraph.various.banner import RunCardMG7 + + +class TestMLMPdfLadder(unittest.TestCase): + + def setUp(self): + try: + import madgraph.iolibs.template_files.mg7.launch as launch + except Exception as error: # madspace not built or importable here + self.skipTest('mg7 launch not importable: %s' % error) + self.launch = launch + + def ladder(self, mlm=True, is_decay=False, leptonic=False, **beam): + rc = RunCardMG7() + for key, value in beam.items(): + rc.set('beam.%s' % key, value, user=True) + return self.launch.mlm_pdf_ladder(rc, mlm, is_decay, leptonic) + + def test_both_scale_schemes_walk_the_ladder_by_default(self): + self.assertTrue(self.ladder(scale_scheme='madevent')) + self.assertTrue(self.ladder(scale_scheme='clustering_mean')) + + def test_clustering_mean_without_the_ladder(self): + self.assertFalse(self.ladder(scale_scheme='clustering_mean', + clustering_mean_pdf_ladder=False)) + # the switch is only read under clustering_mean + self.assertTrue(self.ladder(scale_scheme='madevent', + clustering_mean_pdf_ladder=False)) + + def test_pdf_reweighting_off_turns_it_off_everywhere(self): + for scheme in ('madevent', 'clustering_mean'): + self.assertFalse(self.ladder(scale_scheme=scheme, pdf_reweighting=False)) + + def test_only_merged_hadronic_production(self): + self.assertFalse(self.ladder(mlm=False)) + self.assertFalse(self.ladder(is_decay=True)) + self.assertFalse(self.ladder(leptonic=True)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit_tests/various/test_banner.py b/tests/unit_tests/various/test_banner.py index 4d06e18ccc..d03e2e7ef7 100755 --- a/tests/unit_tests/various/test_banner.py +++ b/tests/unit_tests/various/test_banner.py @@ -1649,6 +1649,52 @@ def test_runcard_dispatches_to_mg7_for_toml(self): finally: os.remove(tmp.name) + def test_mlm_jet_radius_defaults_to_madevents_d(self): + """beam.jet_radius is madevent's hidden run_card d (common/to_dj/D), + which is 1 there and in FxFx; hidden in the TOML card too, so it is only + written once it differs from that, and reads back as 1 otherwise""" + import tomllib + legacy = bannermod.RunCardLO() + self.assertEqual(legacy['d'], 1.0) + rc = bannermod.RunCardMG7() + self.assertEqual(rc['beam']['jet_radius'], legacy['d']) + out = io.StringIO() + rc.write(out, template=self.template) + self.assertNotIn('jet_radius', tomllib.loads(out.getvalue())['beam']) + self.assertEqual( + bannermod.RunCardMG7(out.getvalue())['beam']['jet_radius'], 1.0) + rc.set('beam.jet_radius', 0.4, user=True) + out = io.StringIO() + rc.write(out, template=self.template) + self.assertEqual(tomllib.loads(out.getvalue())['beam']['jet_radius'], 0.4) + + def test_hidden_mlm_options_survive_a_rewrite(self): + """The MLM clustering options are hidden: absent from the default card, + and written at the end of [beam] once set, also when set without + user=True as from_LO does, so a rewrite of the card keeps them""" + import tomllib + hidden = ['jet_radius', 'jet_scale_scheme', 'scale_scheme', + 'parton_line_scheme', 'alphas_reweighting', 'pdf_reweighting', + 'clustering_mean_pdf_ladder', 'clustering_measure', + 'clustering_history'] + rc = bannermod.RunCardMG7() + out = io.StringIO() + rc.write(out, template=self.template) + beam = tomllib.loads(out.getvalue())['beam'] + self.assertEqual([k for k in hidden if k in beam], []) + self.assertIn('max_jet_flavor', beam) + rc.set('beam.scale_scheme', 'madevent') + rc.set('beam.clustering_history', 'diagram', user=True) + out = io.StringIO() + rc.write(out, template=self.template) + reread = bannermod.RunCardMG7(out.getvalue()) + self.assertEqual(reread['beam']['scale_scheme'], 'madevent') + self.assertEqual(reread['beam']['clustering_history'], 'diagram') + # and a second rewrite keeps them + out2 = io.StringIO() + reread.write(out2, template=self.template) + self.assertEqual(out2.getvalue(), out.getvalue()) + def test_numeric_suffix_like_legacy(self): """integer/float k/M suffixes are parsed as in the legacy RunCard""" rc = bannermod.RunCardMG7() @@ -2088,6 +2134,61 @@ def test_from_LO_conversion(self): mg7.write(buf, template=self.template) tomllib.loads(buf.getvalue()) + def test_legacy_keys_report_mlm_merging(self): + """The madevent shower interface picks MLM jet matching from ickkw + and sets JetMatching:qCut from xqcut and nQmatch from maxjetflavor. + An mg7 MLM sample has to report them, or Pythia8 showers it with no + matching and double counts the jets.""" + card = bannermod.RunCardMG7() + # not merged: the plain shower path, as before + self.assertEqual(card['ickkw'], 0) + self.assertEqual(card['xqcut'], 0.0) + self.assertEqual(card['maxjetflavor'], 5) + card.set('beam.dynamical_scale_choice', 'mlm') + card.set('phasespace.xqcut', 30.0) + card.set('beam.max_jet_flavor', 4) + self.assertTrue(card.is_mlm_merged()) + self.assertEqual(card['ickkw'], 1) + self.assertEqual(card['xqcut'], 30.0) + self.assertEqual(card['maxjetflavor'], 4) + self.assertEqual(card['ktdurham'], -1.0) + # no merging cut, or no beams to merge on: not a merged sample + card.set('phasespace.xqcut', 0.0) + self.assertEqual(card['ickkw'], 0) + card.set('phasespace.xqcut', 30.0) + card.set('beam.leptonic', True) + self.assertEqual(card['ickkw'], 0) + self.assertEqual(card['xqcut'], 0.0) + + def test_set_maxjetflavor_sets_the_mlm_jet_flavours(self): + rc = bannermod.RunCardMG7() + rc.set_lo_param('maxjetflavor', '5') + self.assertEqual(rc['beam']['max_jet_flavor'], 5) + + def test_from_LO_conversion_of_mlm_merging(self): + """ickkw = 1 with xqcut is madevent's MLM: mg7's "mlm" scale choice + with madevent's scale scheme, the same xqcut and jet flavours""" + lo = bannermod.RunCardLO() + lo['ickkw'] = 1 + lo['xqcut'] = 20 + lo['maxjetflavor'] = 5 + lo['auto_ptj_mjj'] = False + mg7, dropped = bannermod.RunCardMG7.from_LO(lo, warn=False) + self.assertEqual(mg7['beam']['dynamical_scale_choice'], 'mlm') + self.assertEqual(mg7['beam']['scale_scheme'], 'madevent') + self.assertEqual(mg7['phasespace']['xqcut'], 20.0) + self.assertFalse(mg7['phasespace']['auto_ptj_mjj']) + self.assertEqual(mg7['beam']['max_jet_flavor'], 5) + joined = ' '.join(dropped) + self.assertNotIn('ickkw', joined) + self.assertNotIn('xqcut', joined) + self.assertEqual(mg7['ickkw'], 1) + # without a merging cut there is nothing to merge + lo['xqcut'] = 0 + mg7, dropped = bannermod.RunCardMG7.from_LO(lo, warn=False) + self.assertNotEqual(mg7['beam']['dynamical_scale_choice'], 'mlm') + self.assertIn('ickkw=1', ' '.join(dropped)) + def test_from_LO_conversion_per_beam(self): """asymmetric beam energies and PDFs are kept per beam""" lo = bannermod.RunCardLO() @@ -2451,6 +2552,50 @@ def mg7_proc(initial, final, model=None): return {'legs': legs, 'legs_with_decays': [], 'model': model or FakeModel()} +class TestRunCardMG7AutoMLM(unittest.TestCase): + """A process generated with several jet multiplicities is set up as an MLM + merged sample at output time, as madevent's ickkw = 1 is.""" + + class PC(dict): + def __init__(self, ninitial=2): + super().__init__(ninitial=ninitial, loop_induced=False, + colored_pdgs=[]) + + def build(self, processes): + rc = bannermod.RunCardMG7() + rc.create_default_for_process(self.PC(), '', processes) + return rc + + def test_extra_jets_turn_mlm_on(self): + rc = self.build([[mg7_proc([2, -1], [-11, 12])], + [mg7_proc([2, -1], [-11, 12, 21])]]) + self.assertEqual(rc['beam']['dynamical_scale_choice'], 'mlm') + self.assertEqual(rc['phasespace']['xqcut'], 30.0) + self.assertTrue(rc.is_mlm_merged()) + self.assertEqual(rc['ickkw'], 1) + # drjj = drjl = 0, as madevent + self.assertNotIn('jet-delta_r', rc.dynamic_sections['cuts']) + self.assertNotIn('jet-lepton-delta_r', rc.dynamic_sections['cuts']) + self.assertIn('lepton-delta_r', rc.dynamic_sections['cuts']) + + def test_an_extra_photon_is_no_merging(self): + rc = self.build([[mg7_proc([2, -1], [-11, 12])], + [mg7_proc([2, -1], [-11, 12, 22])]]) + self.assertNotEqual(rc['beam']['dynamical_scale_choice'], 'mlm') + self.assertEqual(rc['phasespace']['xqcut'], 0.0) + self.assertIn('jet-delta_r', rc.dynamic_sections['cuts']) + + def test_one_multiplicity_is_no_merging(self): + rc = self.build([[mg7_proc([2, -1], [-11, 12, 21])]]) + self.assertFalse(rc.is_mlm_merged()) + + def test_lepton_collider_is_no_merging(self): + rc = self.build([[mg7_proc([11, -11], [2, -2])], + [mg7_proc([11, -11], [2, -2, 21])]]) + self.assertFalse(rc.is_mlm_merged()) + self.assertEqual(rc['phasespace']['xqcut'], 0.0) + + class TestRunCardMG7Histograms(unittest.TestCase): """[histograms]: the default plots written at output time.""" diff --git a/tests/unit_tests/various/test_histograms.py b/tests/unit_tests/various/test_histograms.py index c4d196c2f2..915f8c7738 100755 --- a/tests/unit_tests/various/test_histograms.py +++ b/tests/unit_tests/various/test_histograms.py @@ -882,3 +882,100 @@ def test_cli_accepts_run_id_and_rejects_incompatible_sums(self): self.assertNotEqual(result.returncode, 0) self.assertIn('are not compatible and cannot be combined', result.stdout+result.stderr) + + +class TestPythia8Main164DJRs(unittest.TestCase): + """The differential jet rates Pythia8's main164 writes with + Main:InternalAnalysis = on, which the DJR plots of an MLM run are made + from.""" + + MAIN164 = """ + + + 0.0000e+00 1.0000e-06 + 1.0000e+00 2.0000e-06 + 2.0000e+00 0.0000e+00 + + + 0.0000e+00 3.0000e-06 + 1.0000e+00 0.0000e+00 + 2.0000e+00 4.0000e-06 + + + +""" + + def write(self, text): + import tempfile + handle, path = tempfile.mkstemp(suffix='.dat') + with os.fdopen(handle, 'w') as stream: + stream.write(text) + self.addCleanup(os.remove, path) + return path + + def test_reads_the_main164_histfile(self): + """main164 writes (one quote too many), which no XML + parser takes; the bins are read with their edges and scaled""" + histos = histograms.HwUList.from_pythia8_main164( + self.write(self.MAIN164), scale=1e9) + self.assertEqual([h.title for h in histos], ['log10d01 []', 'log10d12 []']) + bins = histos[0].bins + self.assertEqual([b.boundaries for b in bins], + [(0.0, 1.0), (1.0, 2.0), (2.0, 3.0)]) + self.assertEqual([b.wgts['central'] for b in bins], [1000.0, 2000.0, 0.0]) + self.assertTrue(all(b.wgts['stat_error'] == 0.0 for b in bins)) + # and it writes out as a HwU file the plots can be made from + out = self.write('') + histos.output(out[:-len('.dat')], format='HwU') + self.addCleanup(os.remove, out[:-len('.dat')] + '.HwU') + reread = histograms.HwUList(out[:-len('.dat')] + '.HwU') + self.assertEqual(len(reread), 2) + + def test_the_former_interface_format_is_left_to_its_reader(self): + old = self.write('\n\n' + '\n\n' + '\n\n') + self.assertIsNone(histograms.HwUList.from_pythia8_main164(old)) + + def test_jet_samples_and_errors(self): + """A main164 that also writes the histograms per jet sample (number of + matrix-element partons) and the bin uncertainties: the per-sample + histograms are returned, titled |JETSAMPLE@n, with their errors""" + text = ('\n\n' + '\n' + ' 0.0 3.0e-06 1.0e-07\n 1.0 2.0e-06 1.0e-07\n\n' + '\n' + '\n' + ' 0.0 3.0e-06 1.0e-07\n 1.0 0.0 0.0\n\n' + '\n\n' + '\n' + ' 0.0 0.0 0.0\n 1.0 2.0e-06 1.0e-07\n\n' + '\n\n\n') + histos = histograms.HwUList.from_pythia8_main164(self.write(text), scale=1e9) + self.assertEqual([(h.title, h.jetsample) for h in histos], + [('log10d01 []', 0), ('log10d01 []', 1)]) + self.assertEqual([b.wgts['central'] for b in histos[1].bins], [0.0, 2000.0]) + self.assertEqual([b.wgts['stat_error'] for b in histos[1].bins], [0.0, 100.0]) + + def test_jetsample_blocks_of_pythia8(self): + """main164 with InternalAnalysis:jetSamples: + blocks after the totals, two columns""" + text = ('\n\n' + '\n' + ' 0.0 3.0e-06\n 1.0 2.0e-06\n\n' + '\n' + '\n' + ' 0.0 3.0e-06\n 1.0 0.0\n\n' + '\n\n' + '\n' + ' 0.0 0.0\n 1.0 2.0e-06\n\n' + '\n\n\n') + histos = histograms.HwUList.from_pythia8_main164(self.write(text), scale=1e9) + self.assertEqual([(h.title, h.jetsample) for h in histos], + [('log10d01 []', 0), ('log10d01 []', 1)]) + self.assertEqual([b.wgts['central'] for b in histos[0].bins], [3000.0, 0.0]) + self.assertTrue(all(b.wgts['stat_error'] == 0.0 for b in histos[1].bins)) + + def test_a_file_without_histograms(self): + self.assertIsNone(histograms.HwUList.from_pythia8_main164( + self.write('\n\n'))) diff --git a/tests/unit_tests/various/test_misc.py b/tests/unit_tests/various/test_misc.py index 24bb1445db..ccbc0cc2c4 100755 --- a/tests/unit_tests/various/test_misc.py +++ b/tests/unit_tests/various/test_misc.py @@ -274,9 +274,20 @@ def tearDown(self): os.chmod(self.examples, 0o755) shutil.rmtree(self.tmpdir) - def write_sources(self, mainMG=False, hepmc=2): + # the lines of Pythia8's main164.cc (8.316 to 8.318) that the + # per-jet-sample DJR patch anchors on + main164_djr_anchors = ( + ' pythia.settings.addWord("InternalAnalysis:output", "analysis.dat");\n' + ' vector d01Hists, d12Hists, d23Hists, d34Hists;\n' + ' d34Hists.push_back(Hist("d34", 100., 0., 3.));\n' + ' if (dij.size() > 3) d34Hists.back().fill(log10(dij[3]), w);\n' + ' output << "\\n";\n' + ' d34Hists[i].table(output, false, false);\n' + ' output << "\\n";\n') + + def write_sources(self, mainMG=False, hepmc=2, source=None): with open(os.path.join(self.examples, 'main164.cc'), 'w') as fsock: - fsock.write('#!/bin/sh\necho main164\n') + fsock.write(source if source is not None else '#!/bin/sh\necho main164\n') rule = "mainMG: main164.cc\n\techo mainMG > main164; chmod +x main164\n" \ if mainMG else '' with open(os.path.join(self.examples, 'Makefile'), 'w') as fsock: @@ -428,6 +439,67 @@ def test_find_hepmc(self): # a HepMC3 installation is not a HepMC2 one self.assertEqual(misc.find_hepmc(2, [prefix]), None) + def test_main164_djr_patch(self): + """the per-jet-sample split, the bin errors and the '', patched) + self.assertIn('jetMatchingPtr->nMEpartons().first', patched) + self.assertIn('pythia.mode("Merging:nRecluster")', patched) + self.assertIn('', patched) + self.assertTrue(misc.pythia8_main164_writes_jet_sample_djrs(patched)) + self.assertFalse(misc.pythia8_main164_writes_jet_sample_djrs( + self.main164_djr_anchors)) + self.assertIsNone(misc.patch_pythia8_main164_djrs('int main() {}\n')) + + def test_jet_sample_djrs_build_a_patched_main164(self): + self.write_sources(source=self.main164_djr_anchors) + executable = misc.get_pythia8_main164(self.py8, jet_sample_djrs=True) + shared = os.path.join(self.examples, 'main164_djr') + self.assertEqual(executable, os.path.join(shared, 'main164')) + # the stub make copies the source: it is the patched one, which knows + # InternalAnalysis:jetSamples (the stock one does not) + self.assertIn('nMEpartons', open(executable).read()) + self.assertTrue(misc.pythia8_main164_knows_jet_samples(executable)) + self.assertFalse(misc.pythia8_main164_knows_jet_samples( + misc.get_pythia8_main164(self.py8))) + # Pythia8's own main164.cc is never modified + self.assertEqual(open(os.path.join(self.examples, 'main164.cc')).read(), + self.main164_djr_anchors) + # built once + os.remove(os.path.join(shared, 'main164.cc')) + self.assertEqual(misc.get_pythia8_main164(self.py8, jet_sample_djrs=True), + executable) + # the default one is not affected + self.assertEqual(misc.get_pythia8_main164(self.py8), + os.path.join(self.examples, 'main164')) + + def test_jet_sample_djrs_not_patched_when_not_needed_or_possible(self): + # a main164 that splits per jet sample by itself + self.write_sources(source=self.main164_djr_anchors + + ' pythia.settings.addFlag("InternalAnalysis:jetSamples", false);\n') + self.assertEqual(misc.get_pythia8_main164(self.py8, jet_sample_djrs=True), + os.path.join(self.examples, 'main164')) + # a main164.cc the patch does not know: the stock one + os.remove(os.path.join(self.examples, 'main164')) + self.write_sources() + self.assertEqual(misc.get_pythia8_main164(self.py8, jet_sample_djrs=True), + os.path.join(self.examples, 'main164')) + self.assertFalse(os.path.exists(os.path.join(self.examples, 'main164_djr'))) + + def test_jet_sample_djrs_with_another_hepmc_version(self): + self.write_sources(hepmc=2, source=self.main164_djr_anchors) + prefix = self.make_hepmc(3) + executable = misc.get_pythia8_main164(self.py8, hepmc_version=3, + hepmc_paths=[prefix], jet_sample_djrs=True) + self.assertEqual(executable, os.path.join(self.examples, + 'main164_hepmc3_djr', 'main164')) + self.assertIn('nMEpartons', open(executable).read()) + def test_same_hepmc_version_uses_the_default_main164(self): self.write_sources(hepmc=2) executable = misc.get_pythia8_main164(self.py8, hepmc_version=2)