diff --git a/.github/workflows/acceptancetest.yml b/.github/workflows/acceptancetest.yml index 3c4fa29029..713485a2a9 100644 --- a/.github/workflows/acceptancetest.yml +++ b/.github/workflows/acceptancetest.yml @@ -1389,3 +1389,101 @@ jobs: cd $GITHUB_WORKSPACE ./tests/test_manager.py test_density_mode_vs_standalone_LI1 -pA -t0 -l INFO + + + # --------------------------------------------------------------------------- + # Crossing symmetry (tests/acceptance_tests/test_standalone_cross_symmetry.py) + # + # Split by toolchain and cost. NOTE: test_manager.py exits 1 when a test is + # SKIPPED (not only when it fails), and the crossing tests self-skip when + # gfortran / f2py / g++ are missing -- so the toolchain steps below are not + # optional, they are what keeps these jobs green. + # --------------------------------------------------------------------------- + acceptancetest_crossing_static: + # The parts that only inspect generated code / python objects: the crossing + # partition of a multi-flavor process, the multi-channel config map that + # pairs a routed subprocess's channels with the base's diagrams, the + # canonical colour-flow code, and the outputs that cannot decode a crossing + # (they get the folded crossings expanded back instead). No compilation, so + # this is the fast smoke test. + # + # TestCrossingConfigMap earns its place here rather than in an integration + # job because what it guards is invisible downstream: a mis-paired config map + # leaves every matrix element and the cross section itself correct, and only + # degrades the multi-channel sampling, so no xsec comparison catches it + # reliably -- it shows up as a slow, unstable integration behind an error + # estimate that no longer means anything. + runs-on: ubuntu-24.04 + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork == true + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/checkout_mg5 + - uses: ./.github/actions/restore-pip-cache + + - name: crossing partition / config map / colour-flow code / unsupported outputs + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py -pA TestCrossingPartition TestCrossingConfigMap TestColorFlowCode TestCrossingUnsupportedOutput -t0 -l INFO + + + acceptancetest_crossing_fortran: + # The fortran standalone reference suite: a crossed SMATRIX call must + # reproduce the process it crosses into (2->2 and 2->3, every merged flavor, + # split orders), the density matrix must follow the crossing, an s-channel + # constraint must disable the machinery, and the good-helicity sets must obey + # the crossing's row permutation. TestGoodHelCParityDedup rides along (same + # backend, ~10s): the C-parity de-duplication of the helicity sum must hold + # where it engages and refuse itself where the pairs do not match. + # Needs gfortran AND f2py (restore-pip-cache provides meson/ninja and sets + # f2py_compiler); the f2py-backed tests skip without it, which fails the job. + runs-on: ubuntu-24.04 + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork == true + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/checkout_mg5 + - uses: ./.github/actions/restore-pip-cache + + - name: crossing symmetry + C-parity good-helicity dedup, fortran standalone + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py -pA TestStandaloneCrossSymmetry TestGoodHelCParityDedup -t0 -l INFO + + + acceptancetest_crossing_cpp: + # The two C++ backends (standalone_cpp and standalone_mg7/madmatrix, incl. + # the per-event mixed-crossing SIMD page) plus the `check crossing` + # subcommand end to end over all three exporters. + runs-on: ubuntu-24.04 + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork == true + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/checkout_mg5 + - uses: ./.github/actions/restore-pip-cache + + - name: crossing symmetry, C++ / madmatrix backends and `check crossing` + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py -pA TestStandaloneCppCrossSymmetry TestStandaloneMg7CrossSymmetry TestCheckCrossingCommand -t0 -l INFO + + + acceptancetest_standalone_mg7_openmp: + # The CPU sigmaKin loop of the madmatrix backend is an + # 'omp parallel for default( none )', so every variable it uses must be + # listed in its shared() clause or the generated CPPProcess.cc does not + # compile at all. Three of them were missing. Nothing noticed, because no + # build ever turns OpenMP on: it is opt-in (USEOPENMP=1, #758) and + # madmatrix.mk force-disables it on Darwin. This job compiles the generated + # file with OpenMP explicitly, so the shared() clause stays complete. + # Needs only a C++ compiler (no madspace, no heptools). + runs-on: ubuntu-24.04 + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork == true + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/checkout_mg5 + - uses: ./.github/actions/restore-pip-cache + + - name: test one of the test test_standalone_mg7_openmp + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py test_standalone_mg7_openmp -pA -t0 -l INFO diff --git a/.github/workflows/acceptancetest_madevent.yml b/.github/workflows/acceptancetest_madevent.yml index d173796936..8b1075d4df 100644 --- a/.github/workflows/acceptancetest_madevent.yml +++ b/.github/workflows/acceptancetest_madevent.yml @@ -996,3 +996,94 @@ jobs: run: | cd $GITHUB_WORKSPACE ./tests/test_manager.py test_madevent_mssm_gogo -pA -t0 -l INFO + + + # --------------------------------------------------------------------------- + # Crossing symmetry, madevent end-to-end. Each of these runs one or more full + # (small) madevent generations, hence one job apiece. + # --------------------------------------------------------------------------- + acceptancetest_crossing_madevent_labels: + # The event LABELS a crossed subprocess writes to the LHE: the W+ helicity of + # p p > w+ j (the crossed leg is a massive vector, so a bad relabel scrambles + # it), the colour flow of u u~ > u u~ (98/2 asymmetric, so a swapped flow + # label is detectable), and the colour flow a within-group router CHOOSES + # (which mask it selects with, not just which label it writes). + runs-on: ubuntu-24.04 + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork == true + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/checkout_mg5 + - uses: ./.github/actions/restore-pip-cache + + - name: crossed helicity and colour-flow labels written to the LHE + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py -pA TestMadeventCrossingHelicity TestMadeventColorFlowRatio -t0 -l INFO + + # Separate step (and ~4 minutes of its own): a within-group router must + # RESELECT the colour flow with its own colour-config mask rather than + # relabel the one its base picked. Only an event-level comparison sees + # this -- the cross section agreed to 0.02% while ~10% of the affected + # flavour class carried a flow the --use_crossing=False build never picks + # -- so this integrates the process twice and compares the colour-topology + # distribution class by class. + - name: within-group router colour selection + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py -pA TestMadeventRouterColorSelection -t0 -l INFO + + + acceptancetest_crossing_madevent_xsec: + # The cross-section-level guards on the crossing router: each process is + # integrated twice (crossing-routed vs --use_crossing=False, same seed) and + # the two results must agree. + # p p > w+ j, w+ > j j decay chain riding on a crossed production + # p p > t t~ j j inclusive, and the only test that integrates the + # cross-GROUP (Track B) router + runs-on: ubuntu-24.04 + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork == true + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/checkout_mg5 + - uses: ./.github/actions/restore-pip-cache + + - name: crossing cross-section regressions (decay chain + inclusive) + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py -pA TestMadeventDecayChainCrossing TestMadeventInclusiveCrossingXsec -t0 -l INFO + + + acceptancetest_crossing_reweight: + # The folded-subprocess layout of the default (crossing on) standalone output, + # and the reweighting of a sample whose events sit in a grouped subprocess + # where every merged leg is an anti-particle (the -81 label regression). + runs-on: ubuntu-24.04 + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork == true + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/checkout_mg5 + - uses: ./.github/actions/restore-pip-cache + + - name: folded subprocesses + merged anti-particle labels in reweight + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py test_standalone_crossing_folds_qqx_subprocess test_reweight_merged_antiparticle_labels -pA -t0 -l INFO + + + acceptancetest_density_multicore: + # do_reweight splits the event file across jobs in multicore mode; each job + # writes the average density matrix of its own chunk and the mother interface + # has to recombine them into the canonical Average_density_matrix_*.txt. + # Generates 3000 events (> nevt_job) so the file is really split, then + # reweights twice (1 core vs 2) -- the slowest of the new jobs. + runs-on: ubuntu-24.04 + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork == true + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/checkout_mg5 + - uses: ./.github/actions/restore-pip-cache + + - name: test one of the test test_density_mode_multicore + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py test_density_mode_multicore -pA -t0 -l INFO diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index 7712ca4eda..8c79c2ba7d 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -733,3 +733,25 @@ jobs: run: | cd $GITHUB_WORKSPACE ./tests/test_manager.py test_generate_ewsud_ttbar test_generate_ewsud_ww test_generate_ewsud_zz -t0 + + + unittest_34: + # Unit tests added with the crossing-symmetry feature: + # tests/unit_tests/interface/test_reweight_density.py + # (TestAverageDensityMatrix: the average-density-matrix helpers shared by + # DensityInterface and CommonRunCmd.do_reweight's multicore recombination) + # tests/unit_tests/various/test_reweight_interface.py + # (TestPdgForMeCall: merged-particle labels, both signs, resolved to the + # event's concrete PDGs before the fortran call) + # Pure python, no toolchain: the whole job is a couple of seconds. + runs-on: ubuntu-24.04 + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork == true + + steps: + - uses: actions/checkout@v5 + - uses: ./.github/actions/restore-pip-cache + + - name: reweight density / merged-label unit tests + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py TestAverageDensityMatrix TestPdgForMeCall -t0 diff --git a/.gitignore b/.gitignore index e601b64224..dc672ea0b4 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,6 @@ nsqso_born.inc docs/build/ tests/input_files/IOTestsComparison_BackUp/ tests/input_files/IOTestsComparison/**/*.BackUp + +# output of ./tests/test_manager.py +UNITTEST_proc/ diff --git a/MadSpin/decay.py b/MadSpin/decay.py index ca9ab1edc0..93c1969e77 100755 --- a/MadSpin/decay.py +++ b/MadSpin/decay.py @@ -66,6 +66,30 @@ logger = logging.getLogger('decay.stdout') # -> stdout logger_stderr = logging.getLogger('decay.stderr') # ->stderr +import contextlib + +@contextlib.contextmanager +def _no_merge_crossing(): + """Temporarily disable crossing-symmetry folding (merge_crossing='record') + for the enclosed MG5 generation. + + MadSpin's legacy full_decay_chain (madspin_v1) and onshell_v1 paths evaluate + the production matrix element through a PDG-dispatch interface (the + standalone_msP fortran driver / smatrixhel) that cannot reach a crossing- + folded subprocess, so their generation must keep every subprocess on its own + (as before the crossing feature). The density path is crossing-aware and + keeps crossing on. MG_MERGE_CROSSING=off is the documented escape hatch + (see madgraph_interface.do_add).""" + saved = os.environ.get('MG_MERGE_CROSSING') + os.environ['MG_MERGE_CROSSING'] = 'off' + try: + yield + finally: + if saved is None: + os.environ.pop('MG_MERGE_CROSSING', None) + else: + os.environ['MG_MERGE_CROSSING'] = saved + import random import math from madgraph import MG5DIR, MadGraph5Error @@ -3097,11 +3121,12 @@ def generate_all_matrix_element(self): commandline = commandline.replace('add process', 'generate',1) logger.info(commandline) - - mgcmd.exec_cmd(commandline, precmd=True) + + with _no_merge_crossing(): + mgcmd.exec_cmd(commandline, precmd=True) commandline = 'output standalone_msP %s %s' % \ - (pjoin(path_me,'production_me'), ' '.join(list(self.list_branches.keys()))) - mgcmd.exec_cmd(commandline, precmd=True) + (pjoin(path_me,'production_me'), ' '.join(list(self.list_branches.keys()))) + mgcmd.exec_cmd(commandline, precmd=True) logger.info('Done %.4g' % (time.time()-start)) # 3. Create all_ME + topology objects ---------------------------------- @@ -3181,7 +3206,8 @@ def generate_all_matrix_element(self): commandline += self.get_proc_with_decay(proc, one_decay, mgcmd._curr_model, self.options) commandline = commandline.replace('add process', 'generate',1) logger.info(commandline) - mgcmd.exec_cmd(commandline, precmd=True) + with _no_merge_crossing(): + mgcmd.exec_cmd(commandline, precmd=True) # remove decay with 0 branching ratio. mgcmd.remove_pointless_decay(self.banner.param_card) commandline = 'output standalone_msF %s %s' % (pjoin(path_me,'full_me'), @@ -5123,7 +5149,17 @@ def generate_all_matrix_element(self): commandline = commandline.replace('add process', 'generate',1) mgcmd = self.mgcmd - mgcmd.exec_cmd(commandline, precmd=True) + # The legacy onshell_v1 path (mode=='onshell') evaluates the production + # ME through smatrixhel, which dispatches on concrete PDGs and returns 0 + # for a crossing-folded subprocess (-> production_me==0 -> ZeroDivision). + # Keep every subprocess on its own for it, exactly like full_decay_chain. + # The density path (mode=='density') is crossing-aware (GET_DENSITY_IDX + # via _resolve_crossed) and keeps crossing on. + if self.mode == 'onshell': + with _no_merge_crossing(): + mgcmd.exec_cmd(commandline, precmd=True) + else: + mgcmd.exec_cmd(commandline, precmd=True) # remove decay with 0 branching ratio. #mgcmd.remove_pointless_decay(self.banner.param_card) # @@ -5135,16 +5171,52 @@ def generate_all_matrix_element(self): # store information about matrix element for matrix_element in mgcmd._curr_matrix_elements.get_matrix_elements(): me_string = matrix_element.get('processes')[0].shell_string() - for me in matrix_element.get('processes'): - dirpath = pjoin(path_me, ms_me_subdir, 'SubProcesses', "P%s" % me_string) - # get the orignal order: - initial = [] - final = [l.get('id') for l in me.get_legs_with_decays()\ - if l.get('state') or initial.append(l.get('id'))] + + def register(leg_ids, _pdir="P%s" % me_string): + # ``leg_ids`` is the ordered list of external PDG ids (initial + # then final, in leg order). Build the (sorted) lookup tag and + # keep the natural leg order for momentum extraction. A base + # process always wins over a crossed one, so never overwrite. + initial = [i for i, l in zip(leg_ids, order_state) if not l] + final = [i for i, l in zip(leg_ids, order_state) if l] order = (tuple(initial), tuple(final)) - initial.sort(), final.sort() - tag = (tuple(initial), tuple(final)) - self.all_me[tag] = {'pdir': "P%s" % me_string, 'order': order} + tag = (tuple(sorted(initial)), tuple(sorted(final))) + self.all_me.setdefault(tag, {'pdir': _pdir, 'order': order}) + + for me in matrix_element.get('processes'): + legs = me.get_legs_with_decays() + order_state = [l.get('state') for l in legs] + register([l.get('id') for l in legs]) + + # Crossed subprocesses folded into this matrix element with crossing + # symmetry on (merge_crossing='record') were NOT generated as their + # own directory: only the representative partner is on disk. Its + # crossing-aware SMATRIX (smatrixhel dispatches on the signed PDGs, + # see interface_madspin.calculate_matrix_element) can still compute + # them, so register each crossed process against the SAME pdir. + # Without this the onshell/density lookup misses e.g. the recorded + # q q~ > t t~ g (partner of the kept q g > t t~ q) and raises + # KeyError. The decays never cross (they ride on their production + # leg), so re-attach the base decay chains before expanding, exactly + # as the exporter does for the check_sa crossing demo. + try: + crossed_processes = matrix_element.get('crossed_processes') + except Exception: + crossed_processes = [] + base_decays = matrix_element.get('processes')[0].get('decay_chains') + for cross_entry in crossed_processes: + proc = cross_entry[0] + if base_decays: + proc = copy.copy(proc) + proc.set('decay_chains', base_decays) + # empty LegList (same class as proc's legs) forces + # get_legs_with_decays to recompute with the re-attached decays + proc.set('legs_with_decays', proc.get('legs').__class__()) + legs = proc.get_legs_with_decays() + else: + legs = proc.get('legs') + order_state = [l.get('state') for l in legs] + register([l.get('id') for l in legs]) return self.all_me diff --git a/MadSpin/interface_madspin.py b/MadSpin/interface_madspin.py index 426251540f..b6bae2416e 100755 --- a/MadSpin/interface_madspin.py +++ b/MadSpin/interface_madspin.py @@ -2311,6 +2311,7 @@ def calculate_matrix_element_from_density(self, production, decays, decay_dict, for i, pdg in enumerate(all_pdg): pdg = tuple([x for x in pdg if x != 0]) self.pdg2prefix[pdg] = (str(all_prefix[i].decode()).strip(), i) + self._build_cross_resolve() if self.model_init: self.model_init = False @@ -2559,12 +2560,12 @@ def _decay_signature(dec_evt): #print(f"production = {production}") #print(f"decays = {decays}") if MEdenom_prod is None: - prod_diag = density_prod.trace().real - else: + prod_diag = density_prod.trace().real + else: prod_diag = MEdenom_prod prod_diag /= (iden_p * sym_factor_prod_ident) if MEdenom_decay is not None: - dec_diag *= MEdenom_decay + dec_diag *= MEdenom_decay return me, density_prod, prod_diag, dec_diag @@ -2603,20 +2604,32 @@ def get_allowed_hel(self, list_hels): def get_density(self, event, position, allow_hel, ncomb, dimension): orig_order = getattr(event, '_ms_orig_order_for_density', None) + cross_info = getattr(event, '_ms_cross_info', None) if orig_order is None: - _, orig_order, _, _ = self.get_pdir(event) + _, orig_order, prefix, pos = self.get_pdir(event) event._ms_orig_order_for_density = orig_order + # Crossed subprocess: remember (prefix, FLAV_IDX) so the density is + # evaluated through the crossing-aware GET_DENSITY_IDX rather than + # the base py_get_density (which dispatches on the base PDGs only). + if isinstance(pos, tuple) and pos and pos[0] == 'CROSS': + cross_info = (prefix, pos[1]) + else: + cross_info = False + event._ms_cross_info = cross_info # Fast path: single-point momentum extraction without permutation # construction. With apply_flavor_grouping, orig_order contains the # merged-particle PDGs (e.g. 81) while the event carries the raw # PDGs (e.g. 1, 2, 3): get_momenta must be told about the merge so - # that final.index(pdg) succeeds. + # that final.index(pdg) succeeds. A crossed orig_order is already in + # signed *physical* PDGs (from PY_GET_PDG_FOR_FLAVOR), so it matches the + # event PDGs directly and must NOT be merge-reverted. + merged_map = None if cross_info else (self._revert_merged or None) try: - p = event.get_momenta(orig_order, merged_map=self._revert_merged or None) + p = event.get_momenta(orig_order, merged_map=merged_map) except Exception: # Safety fallback for unusual event structures. - all_p = event.get_all_momenta(orig_order, merged_map=self._revert_merged or None) + all_p = event.get_all_momenta(orig_order, merged_map=merged_map) assert len(all_p) == 1, "Error: get_density can only be called for a single phase-space point" p = all_p[0] P = rwgt_interface.ReweightInterface.invert_momenta(p) @@ -2638,18 +2651,36 @@ def get_density(self, event, position, allow_hel, ncomb, dimension): raise ValueError("Error in get_density: 'position' must contain at least one position index") if len(allow_hel) % n_changing != 0: raise ValueError("Error in get_density: inconsistent 'allow_hel' and 'position' lengths") - # PY_GET_DENSITY(PDGS, PROCID, P, POS, ALLOW_HEL, ALPHAS, SCALE2) - density_array = self.f2py_module.py_get_density(pdgs=pdgs, - procid=-1, - p=P, - pos=position, - allow_hel=allow_hel, - alphas=event.aqcd, - scale2=event.scale**2) - #print(f"density_array = {density_array}") - density_matrix = madspin.DensityMatrix(density_array, - n_changing, - allow_hel, + if cross_info: + # Crossed subprocess: PY_GET_DENSITY_IDX(P, POS, N_CHANGING, + # ALLOW_HEL, N_COMB, FLAV_IDX, ALPHAS, SCALE2) -> INTER. Momenta P + # are already in the crossed leg order (orig_order == the order + # PY_GET_PDG_FOR_FLAVOR reports for this FLAV_IDX); the routine + # applies the crossing permutation/conjugation internally. + prefix, flav_idx = cross_info + density_array = getattr( + self.f2py_module, 'py_%sget_density_idx' % prefix.lower())( + p=P, + pos=position, + n_changing=n_changing, + allow_hel=allow_hel, + n_comb=ncomb, + flav_idx=flav_idx, + alphas=event.aqcd, + scale2=event.scale**2) + else: + # PY_GET_DENSITY(PDGS, PROCID, P, POS, ALLOW_HEL, ALPHAS, SCALE2) + density_array = self.f2py_module.py_get_density(pdgs=pdgs, + procid=-1, + p=P, + pos=position, + allow_hel=allow_hel, + alphas=event.aqcd, + scale2=event.scale**2) + #print(f"density_array = {density_array}") + density_matrix = madspin.DensityMatrix(density_array, + n_changing, + allow_hel, dimension) return density_matrix @@ -2727,7 +2758,15 @@ def get_iden(self, event): # END REMOVE # get_pdir returns (pdir, orig_order, prefix, pos) - _, _, _, pos = self.get_pdir(event) + _, _, prefix, pos = self.get_pdir(event) + if isinstance(pos, tuple) and pos and pos[0] == 'CROSS': + # Crossed subprocess: the static IDEN (get_idens) is the averaging + # denominator of the uncrossed representative only. GET_NHEL_IDX + # reports the denominator SMATRIX actually applies for this + # (crossed) FLAV_IDX -- e.g. colour 1/9 for q q~ vs 1/24 for q g. + iden_star, _nhel = getattr( + self.f2py_module, 'py_%sget_nhel_idx' % prefix.lower())(pos[1]) + return int(iden_star) idens = self.f2py_module.get_idens() #print(f"idens = {idens} , pos = {pos}") return idens[pos] @@ -2741,6 +2780,61 @@ def get_mymod(self,pdir,MODE): + def _build_cross_resolve(self): + """Physical-PDG resolver for CROSSED subprocesses folded in with crossing + symmetry (merge_crossing='record'). + + The base combined wrapper (pdg2prefix / py_get_density / get_idens) only + enumerates the generated representative processes, so a crossed event + (e.g. q q~ > t t~ g, kept only as its partner q g > t t~ q) is not found + there. The per-process crossing-aware f2py entry points + PY_GET_FLAVOR_LAYOUT / GET_PDG_FOR_FLAVOR / GET_NHEL_IDX let us + enumerate every (crossing, flavor) the representative can evaluate; we key + them by the sorted signed-physical-PDG multiset so get_pdir can resolve a + crossed event to (representative prefix, extended FLAV_IDX) and reach the + *_IDX routines (which apply the correct crossed averaging denominator).""" + mod = self.f2py_module + self.cross_resolve = {} + prefixes = set() + for b in mod.get_prefix(): + p = b.decode().strip() if isinstance(b, bytes) else str(b).strip() + if p: + prefixes.add(p) + for pfx in prefixes: + lp = pfx.lower() + layout = getattr(mod, 'py_%sget_flavor_layout' % lp, None) + get_pdg = getattr(mod, 'py_%sget_pdg_for_flavor' % lp, None) + if layout is None or get_pdg is None: + continue # non-crossing template: no extended-FLAV_IDX entries + nflav, nexternal, ncross = (int(x) for x in layout()) + for cross in range(ncross): + for flav in range(1, nflav + 1): + flav_idx = cross * nflav + flav + pdgs = tuple(int(x) for x in get_pdg(flav_idx)) + if not any(pdgs): + continue # index names no valid flavor/crossing + key = tuple(sorted(pdgs)) + self.cross_resolve.setdefault(key, []).append( + (pfx, flav_idx, pdgs)) + + def _resolve_crossed(self, event, pdir): + """Resolve a crossed production event to + (pdir, crossed_order, prefix, ('CROSS', flav_idx)) via cross_resolve, or + None when the event is not a recorded crossing. ``crossed_order`` is the + signed-physical leg order the momenta must be supplied in (the order + PY_GET_PDG_FOR_FLAVOR reports for this flav_idx).""" + if not getattr(self, 'cross_resolve', None): + return None + phys_tag, _ = event.get_tag_and_order(None) + ninit = len(phys_tag[0]) + key = tuple(sorted(list(phys_tag[0]) + list(phys_tag[1]))) + for (pfx, flav_idx, pdgs) in self.cross_resolve.get(key, []): + cand = (tuple(sorted(pdgs[:ninit])), tuple(sorted(pdgs[ninit:]))) + if cand == phys_tag: + crossed_order = (tuple(pdgs[:ninit]), tuple(pdgs[ninit:])) + return pdir, crossed_order, pfx, ('CROSS', flav_idx) + return None + def get_pdir(self,event): # Use the merged-PDG tag (same as calculate_matrix_element). MadSpin's # all_me is keyed by the merged-particle representation when @@ -2760,7 +2854,17 @@ def get_pdir(self,event): tag = (init, final) orig_order = self.all_me[tag]['order'] pdir = self.all_me[tag]['pdir'] - prefix, pos = self.pdg2prefix[tuple(list(orig_order[0]) + list(orig_order[1]))] + try: + prefix, pos = self.pdg2prefix[tuple(list(orig_order[0]) + list(orig_order[1]))] + except KeyError: + # The signature is a crossed subprocess that the base pdg2prefix does + # not enumerate (crossing symmetry folded it into a representative). + # Resolve it to the representative prefix + extended FLAV_IDX so the + # crossing-aware *_IDX f2py routines can evaluate it. + resolved = self._resolve_crossed(event, pdir) + if resolved is None: + raise + return resolved #misc.sprint(f"get_pdir: pdir = {pdir} , orig_order = {orig_order} , prefix = {prefix}") return pdir,orig_order, prefix, pos @@ -2781,6 +2885,22 @@ def calculate_matrix_element(self, event): tag = (init, final) orig_order = self.all_me[tag]['order'] pdir = self.all_me[tag]['pdir'] + # A crossing-folded subprocess (merge_crossing='record') is absent from + # the base pdg2prefix, so the combined smatrixhel dispatches on its + # concrete PDGs and returns 0. Resolve it to (representative prefix, + # extended FLAV_IDX) plus the physical crossed leg order, so a zero + # smatrixhel result can fall back to the crossing-aware + # PY_SMATRIX_IDX -- the same folded channel the density path + # reaches through GET_DENSITY_IDX. Base events also resolve here but + # keep their non-zero smatrixhel value, so the fallback never fires for + # them. Without this, MEdenom_prod (the reshuffle denominator in the + # density path) is 0 for a crossed production event -> divide-by-zero. + cross_resolved = self._resolve_crossed(event, pdir) + cross_info = None + if cross_resolved is not None: + _, cross_order, cross_prefix, cross_pos = cross_resolved + if isinstance(cross_pos, tuple) and cross_pos and cross_pos[0] == 'CROSS': + cross_info = (cross_prefix.lower(), cross_pos[1], cross_order) if pdir in self.all_f2py: all_p = event.get_all_momenta(orig_order, merged_map=self._revert_merged or None) if self.options['identical_particle_in_prod_and_decay'] == "crash" and\ @@ -2804,6 +2924,18 @@ def calculate_matrix_element(self, event): new_value = self.all_f2py[pdir](pdg_for_call, p_inv, 0.113, 0) else: new_value = self.all_f2py[pdir](pdg_for_call, p_inv, event.aqcd, event.scale, -1) + if new_value == 0 and cross_info: + # Folded crossed subprocess: evaluate through the + # crossing-aware SMATRIX_IDX. Momenta go in the physical + # crossed leg order (no merge revert), matching the order + # PY_GET_PDG_FOR_FLAVOR reports for this FLAV_IDX; the + # smatrixhel call above already applied the event's alphas + # (UPDATE_AS_PARAM), which SMATRIX_IDX reuses. + cross_prefix, flav_idx, cross_order = cross_info + cross_p = event.get_momenta(cross_order, merged_map=None) + cross_P = rwgt_interface.ReweightInterface.invert_momenta(cross_p) + new_value = float(getattr(self.f2py_module, + 'py_%ssmatrix_idx' % cross_prefix)(p=cross_P, flav_idx=flav_idx)) if self.options['identical_particle_in_prod_and_decay'] == "average": out += new_value else: @@ -2838,6 +2970,7 @@ def calculate_matrix_element(self, event): for i, pdg in enumerate(all_pdg): pdg = tuple([x for x in pdg if x != 0]) self.pdg2prefix[tuple(pdg)] = (str(all_prefix[i].decode()).strip(), i) + self._build_cross_resolve() if self.model_init: self.model_init = False diff --git a/Template/LO/Source/.make_opts b/Template/LO/Source/.make_opts index 38ad3a74fc..e141d00f20 100644 --- a/Template/LO/Source/.make_opts +++ b/Template/LO/Source/.make_opts @@ -6,6 +6,7 @@ MG5AMC_VERSION=SpecifiedByMG5aMCAtRunTime STDLIB=-lstdc++ PYTHIA8_PATH=NotInstalled STDLIB_FLAG= +AMP_FLAG= #end_of_make_opts_variables BIASLIBDIR=../../../lib/ diff --git a/Template/LO/Source/run.inc b/Template/LO/Source/run.inc index e5b46fa245..f659622fc6 100644 --- a/Template/LO/Source/run.inc +++ b/Template/LO/Source/run.inc @@ -113,3 +113,8 @@ c 2 means approximation by the denominator of the propa c double precision limhel common/to_limhel/limhel +c +c whether the color sum may go through the batched BLAS-3 call, where +c the matrix element was generated with one + logical blas_color_sum + common/to_blas_color_sum/blas_color_sum diff --git a/Template/LO/SubProcesses/makefile b/Template/LO/SubProcesses/makefile index fbce3dfade..516f91fab0 100644 --- a/Template/LO/SubProcesses/makefile +++ b/Template/LO/SubProcesses/makefile @@ -26,6 +26,11 @@ endif LINKLIBS = $(LINK_MADLOOP_LIB) $(LINK_LOOP_LIBS) -L../../lib/ -ldhelas -ldsample -lmodel -lgeneric -lpdf -lgammaUPC -lcernlib $(llhapdf) -lbias +# what the batched color sum is linked against, empty unless a matrix +# element was generated with one +BLASLIBS = +LINKLIBS += $(BLASLIBS) + LIBS = $(LIBDIR)libbias.$(libext) $(LIBDIR)libdhelas.$(libext) $(LIBDIR)libdsample.$(libext) $(LIBDIR)libgeneric.$(libext) $(LIBDIR)libpdf.$(libext) $(LIBDIR)libgammaUPC.$(libext) $(LIBDIR)libmodel.$(libext) $(LIBDIR)libcernlib.$(libext) $(MADLOOP_LIB) $(LOOP_LIBS) ifneq ("$(wildcard ../../Source/RUNNING)","") @@ -38,9 +43,29 @@ endif MATRIX_HEL = $(patsubst %.f,%.o,$(wildcard matrix*_orig.f)) MATRIX = $(patsubst %.f,%.o,$(wildcard matrix*_optim.f)) +# Crossing-symmetry routers: matrix*_router.f share a base subprocess's matrix +# element and are never recycled, so they are compiled into both the forhel and +# the optimized binaries alongside the recycled bases. When recycling is off the +# matrix*.f glob below already covers them. +ROUTER = $(patsubst %.f,%.o,$(wildcard matrix*_router.f)) +# Amplitude chunks: at high multiplicity the HELAS call sequence of a matrix +# element is emitted as its own set of files, one subroutine each, so that it +# is not one enormous basic block (gfortran's cost on which grows faster than +# linearly) and so that it can carry its own optimisation flag -- see AMP_FLAG +# below. matrix_origamp.f goes into both binaries, because a matrix +# element that has no helicity to recycle is reused as its own optimized copy. +ORIGAMP = $(patsubst %.f,%.o,$(wildcard matrix*_origamp*.f)) +OPTIMAMP = $(patsubst %.f,%.o,$(wildcard matrix*_optimamp*.f)) +AMPCHUNK = $(ORIGAMP) $(OPTIMAMP) ifeq ($(strip $(MATRIX_HEL)),) MATRIX = $(patsubst %.f,%.o,$(wildcard matrix*.f)) + AMPCHUNK = +else + MATRIX += $(ROUTER) $(AMPCHUNK) + MATRIX_HEL += $(ROUTER) $(ORIGAMP) endif +# every matrix object except the amplitude chunks, which get a rule of their own +MATRIX_CORE = $(filter-out $(AMPCHUNK),$(sort $(MATRIX) $(MATRIX_HEL))) PROCESS= driver.o myamp.o genps.o unwgt.o setcuts.o \ @@ -77,8 +102,15 @@ $(LIBDIR)libgammaUPC.$(libext): cd ../../Source/PDF/gammaUPC; make # Add source so that the compiler finds the DiscreteSampler module. -$(MATRIX): %.o: %.f +$(MATRIX_CORE): %.o: %.f $(FC) $(FFLAGS) $(MATRIX_FLAG) -c $< -I../../Source/ -I../../Source/PDF/gammaUPC +# The amplitude chunks carry their own flag, which lands after FFLAGS and so +# wins over the GLOBAL_FLAG. It is empty by default: splitting the sequence up +# is what makes it compilable, and dropping it to -O0 on top buys about 1.5x +# more on the compile but costs 19% of the run time at g g > t t~ 3g and 61% at +# g g > 5g. Set amp_flag in the run_card when the compile is the problem. +$(AMPCHUNK): %.o: %.f + $(FC) $(FFLAGS) $(MATRIX_FLAG) $(AMP_FLAG) -c $< -I../../Source/ -I../../Source/PDF/gammaUPC %.o: %.f $(FC) $(FFLAGS) -c $< -I../../Source/ -I../../Source/PDF/gammaUPC @@ -102,3 +134,10 @@ initcluster.o: message.inc clean: $(RM) *.o gensym madevent madevent_forhel + +# Track B cross-group crossing: present only in a dependent P directory, it makes +# the base group's matrix object(s) be symlinked from the base directory +# instead of recompiled here (see write_crossgroup_mk). Included LAST so its +# specific rules override the makefile's %.o pattern / $(MATRIX) static-pattern +# rules. Absent (a no-op) elsewhere. +-include crossgroup.mk diff --git a/aloha/__init__.py b/aloha/__init__.py index c9605e375e..90d934a277 100755 --- a/aloha/__init__.py +++ b/aloha/__init__.py @@ -1,4 +1,12 @@ complex_mass = False # Tag for activating the complex mass scheme +t_channel_width = False # Whether to keep the width i*M*Gamma in the propagator + # denominator for spacelike (t-channel, P^2<0) momenta. + # False (default): drop it there -- the correct tree-level + # treatment outside the complex-mass scheme (a t-channel + # propagator has no pole to regulate, and the spurious + # width breaks gauge cancellations). True: keep the width + # in every propagator (legacy behaviour). Ignored when + # complex_mass is True (the width lives in the mass then). unitary_gauge = True # Tag choosing between Feynman Gauge or unitary gauge # 0/False: Feynman # 1/True: unitary diff --git a/aloha/aloha_writers.py b/aloha/aloha_writers.py index c9ec6da6c6..1081d45558 100755 --- a/aloha/aloha_writers.py +++ b/aloha/aloha_writers.py @@ -956,8 +956,25 @@ def sort_fct(a, b): out.write(' denom = %(COUP)s/(%(denom)s)\n' % {'COUP': coup_name,\ 'denom':self.write_obj(self.routine.denominator)}) else: - out.write(' denom = %(COUP)s/(P%(i)s(0)**2-P%(i)s(1)**2-P%(i)s(2)**2-P%(i)s(3)**2 - M%(i)s * (M%(i)s -CI* W%(i)s))\n' % \ - {'i': self.outgoing, 'COUP': coup_name}) + p2 = 'P%(i)s(0)**2-P%(i)s(1)**2-P%(i)s(2)**2-P%(i)s(3)**2' \ + % {'i': self.outgoing} + wdenom = '%(p2)s - M%(i)s * (M%(i)s -CI* W%(i)s)' \ + % {'i': self.outgoing, 'p2': p2} + if aloha.t_channel_width: + out.write(' denom = %(COUP)s/(%(wd)s)\n' % + {'COUP': coup_name, 'wd': wdenom}) + else: + # spacelike (t-channel) propagator: no pole to regulate, + # so drop the width unless in the complex-mass scheme. + out.write(' if (dble(%(p2)s).gt.0d0) then\n' + % {'p2': p2}) + out.write(' denom = %(COUP)s/(%(wd)s)\n' % + {'COUP': coup_name, 'wd': wdenom}) + out.write(' else\n') + out.write(' denom = %(COUP)s/(%(p2)s - M%(i)s**2)\n' + % {'i': self.outgoing, 'COUP': coup_name, + 'p2': p2}) + out.write(' endif\n') else: if self.routine.denominator: if 'P1N' not in self.tag: @@ -1533,8 +1550,10 @@ def myHash(target_string): addon = '' else: name = short_name - if unknown_tag: + if unknown_tag and outgoing: addon += '%(propa)s' + elif unknown_tag: + addon += '%(tags)s' # if outgoing is not None: # return '_'.join((name,) + tuple(other_names)) + addon + '_%s' % outgoing @@ -2050,8 +2069,19 @@ def sort_fct(a, b): out.write(' denom = %(pre_coup)s%(coup)s%(post_coup)s/(%(denom)s);\n' % \ mydict) else: - out.write(' denom = %(pre_coup)s%(coup)s%(post_coup)s/((P%(i)s[0]*P%(i)s[0])-(P%(i)s[1]*P%(i)s[1])-(P%(i)s[2]*P%(i)s[2])-(P%(i)s[3]*P%(i)s[3]) - M%(i)s * (M%(i)s -cI* W%(i)s));\n' % \ - mydict) + p2 = '(P%(i)s[0]*P%(i)s[0])-(P%(i)s[1]*P%(i)s[1])-(P%(i)s[2]*P%(i)s[2])-(P%(i)s[3]*P%(i)s[3])' % mydict + wd = '%(p2)s - M%(i)s * (M%(i)s -cI* W%(i)s)' % dict(mydict, p2=p2) + if aloha.t_channel_width: + out.write(' denom = %(pre_coup)s%(coup)s%(post_coup)s/(%(wd)s);\n' % dict(mydict, wd=wd)) + else: + # spacelike (t-channel) propagator: no pole to regulate, + # so drop the width unless in the complex-mass scheme. + p2sign = '(%s)%s' % (p2, self.realoperator) if aloha.loop_mode else p2 + out.write(' if (%s > 0.){\n' % p2sign) + out.write(' denom = %(pre_coup)s%(coup)s%(post_coup)s/(%(wd)s);\n' % dict(mydict, wd=wd)) + out.write(' } else {\n') + out.write(' denom = %(pre_coup)s%(coup)s%(post_coup)s/(%(p2)s - M%(i)s*M%(i)s);\n' % dict(mydict, p2=p2)) + out.write(' }\n') else: if self.routine.denominator: raise Exception('modify denominator are not compatible with complex mass scheme') @@ -2583,8 +2613,17 @@ def sort_fct(a, b): out.write(' denom = %(COUP)s/(%(denom)s)\n' % {'COUP': coup_name,\ 'denom':self.write_obj(self.routine.denominator)}) else: - out.write(' denom = %(coup)s/(P%(i)s[0]**2-P%(i)s[1]**2-P%(i)s[2]**2-P%(i)s[3]**2 - M%(i)s * (M%(i)s -1j* W%(i)s))\n' % - {'i': self.outgoing,'coup':coup_name}) + p2 = 'P%(i)s[0]**2-P%(i)s[1]**2-P%(i)s[2]**2-P%(i)s[3]**2' % {'i': self.outgoing} + wd = '%(p2)s - M%(i)s * (M%(i)s -1j* W%(i)s)' % {'i': self.outgoing, 'p2': p2} + if aloha.t_channel_width: + out.write(' denom = %(coup)s/(%(wd)s)\n' % {'coup': coup_name, 'wd': wd}) + else: + # spacelike (t-channel) propagator: no pole to regulate, + # so drop the width unless in the complex-mass scheme. + out.write(' if (%s).real > 0:\n' % p2) + out.write(' denom = %(coup)s/(%(wd)s)\n' % {'coup': coup_name, 'wd': wd}) + out.write(' else:\n') + out.write(' denom = %(coup)s/(%(p2)s - M%(i)s**2)\n' % {'i': self.outgoing, 'coup': coup_name, 'p2': p2}) else: if self.routine.denominator: raise Exception('modify denominator are not compatible with complex mass scheme') diff --git a/aloha/template_files/Makefile_F b/aloha/template_files/Makefile_F index 6858c11c5f..6af13888c5 100644 --- a/aloha/template_files/Makefile_F +++ b/aloha/template_files/Makefile_F @@ -36,5 +36,14 @@ shared: $(LIBDIR)$(LIBRARY_SHARED) clean: $(RM) *.o $(LIBDIR)$(LIBRARY) - + all: $(LIBDIR)$(LIBRARY) + +# aloha_functions.o provides the aloha_object F90 module (module ALOHA_OBJECT) +# that every ALOHA routine does "use aloha_object". Build it first so a parallel +# (-j) sub-make cannot compile a routine before aloha_object.mod exists. +# Order-only (|) so the routines are not recompiled when it merely rebuilds. +# NB: keep this AFTER the first (default-goal) target above -- a bare rule for +# $(ALOHARoutine) placed first would hijack the default goal and libdhelas would +# never be built. +$(ALOHARoutine): | $(BASIC_OBJS) diff --git a/aloha/template_files/aloha_functions.f b/aloha/template_files/aloha_functions.f index 01aa9d8ad3..09dbd86a33 100644 --- a/aloha/template_files/aloha_functions.f +++ b/aloha/template_files/aloha_functions.f @@ -1923,3 +1923,43 @@ subroutine CombineAmpS(nb, ihels, iwfcts, W1, Wall, Amp) enddo return end + + subroutine sumw_1(w1, w2, wout) +c +c Sum two currents standing for the same off shell line: the four +c gluon current and the pair of three gluon vertices it factorises +c into carry the same colour factor, so the amplitude reading the sum +c gets both contributions from a single call. See +c HelasMatrixElement.get_quartic_current_sums. +c +c The two share their momentum, so only the wavefunction is added and +c everything else is taken over from the first one. +c + use ALOHA_OBJECT + implicit none + type(aloha) w1 + type(aloha) w2 + type(aloha) wout + + wout = w1 + wout%W(:) = w1%W(:) + w2%W(:) + + return + end + + + subroutine subw_1(w1, w2, wout) +c +c As sumw_1, for the contributions which enter with a minus sign. +c + use ALOHA_OBJECT + implicit none + type(aloha) w1 + type(aloha) w2 + type(aloha) wout + + wout = w1 + wout%W(:) = w1%W(:) - w2%W(:) + + return + end diff --git a/aloha/template_files/aloha_functions_fd.f b/aloha/template_files/aloha_functions_fd.f index 2696c80d84..fcac1ac465 100644 --- a/aloha/template_files/aloha_functions_fd.f +++ b/aloha/template_files/aloha_functions_fd.f @@ -2240,3 +2240,43 @@ subroutine define_gauge_dir(q, n) end + + subroutine sumw_1(w1, w2, wout) +c +c Sum two currents standing for the same off shell line: the four +c gluon current and the pair of three gluon vertices it factorises +c into carry the same colour factor, so the amplitude reading the sum +c gets both contributions from a single call. See +c HelasMatrixElement.get_quartic_current_sums. +c +c The two share their momentum, so only the wavefunction is added and +c everything else is taken over from the first one. +c + use ALOHA_OBJECT + implicit none + type(aloha) w1 + type(aloha) w2 + type(aloha) wout + + wout = w1 + wout%W(:) = w1%W(:) + w2%W(:) + + return + end + + + subroutine subw_1(w1, w2, wout) +c +c As sumw_1, for the contributions which enter with a minus sign. +c + use ALOHA_OBJECT + implicit none + type(aloha) w1 + type(aloha) w2 + type(aloha) wout + + wout = w1 + wout%W(:) = w1%W(:) - w2%W(:) + + return + end diff --git a/aloha/template_files/aloha_functions_loop.f b/aloha/template_files/aloha_functions_loop.f index 46561ce7d9..8d558bbe0b 100644 --- a/aloha/template_files/aloha_functions_loop.f +++ b/aloha/template_files/aloha_functions_loop.f @@ -3042,3 +3042,43 @@ subroutine olxxxx(p,ffmass,nhel,nsf,fo) c return end + + subroutine sumw_1(w1, w2, wout) +c +c Sum two currents standing for the same off shell line: the four +c gluon current and the pair of three gluon vertices it factorises +c into carry the same colour factor, so the amplitude reading the sum +c gets both contributions from a single call. See +c HelasMatrixElement.get_quartic_current_sums. +c +c The two share their momentum, so only the wavefunction is added and +c everything else is taken over from the first one. +c + use ALOHA_OBJECT + implicit none + type(aloha) w1 + type(aloha) w2 + type(aloha) wout + + wout = w1 + wout%W(:) = w1%W(:) + w2%W(:) + + return + end + + + subroutine subw_1(w1, w2, wout) +c +c As sumw_1, for the contributions which enter with a minus sign. +c + use ALOHA_OBJECT + implicit none + type(aloha) w1 + type(aloha) w2 + type(aloha) wout + + wout = w1 + wout%W(:) = w1%W(:) - w2%W(:) + + return + end diff --git a/docs/gluon-quartic-plan.md b/docs/gluon-quartic-plan.md new file mode 100644 index 0000000000..7372da3ae5 --- /dev/null +++ b/docs/gluon-quartic-plan.md @@ -0,0 +1,814 @@ +# Pure-gluon amplitude optimisation — plan + +Branch `claude/gluon-amplitude-optimization-8706f5`. Everything is behind +`set merge_quartic_vertices` (off by default), so nothing changes until it is +set. It takes four values: + +| | | +|---|---| +| `False` | off, the default | +| `speed` | the current sums, and the diagram order which allows them | +| `slots` | the order which keeps fewest currents alive; no current sums | +| `auto` | `slots` when the matrix elements go to a gpu backend, `speed` otherwise, decided per output | + +It has to be a `set` option and not an `output` one, because the diagram order +is fixed while the diagrams are generated -- by `output` time it is already +too late. The interface pushes it onto `madgraph.merge_quartic_vertices`, +which is what the generation and the exporters read. + +`auto` is the exception, and only because `slots` is the `speed` order +reversed: it generates the `speed` order and +`MadGraphCmd.apply_quartic_diagram_order` reverses it at `output` time, once +the backend about to be handed the matrix elements is known. The choice is +made from the *matrix element* exporter rather than the output format, so +`output madevent --me_exporter=` -- one output feeding two +backends off a single `_curr_matrix_elements` -- follows the gpu. + +**speed against slots.** The wavefunction store is a stack frame on cpu and is +per thread on gpu, where at seven gluons it is about 24 kB a thread and caps +occupancy. So the trade goes opposite ways: + +| `g g > 5 g` | amplitude calls | slots | per thread | +|---|---|---|---| +| off | 7245 | 268 | 25.1 kB | +| `speed` | 6813 | 259 | 24.3 kB | +| `slots` | 7245 | **199** | **18.7 kB** | + +6% more arithmetic for 23% less memory. Measured on cpu `speed` wins; which +one a gpu wants has *not* been measured -- there is no device here. + +## Goal + +Each colour structure of the 4-gluon vertex carries the *same colour factor* +as the diagram obtained by splitting that vertex into two cubic ones. So the +quartic current and the cubic current carrying that colour factor can be +summed into one current, and the whole subtree below it emitted once instead +of twice — a Berends-Giele style recursion, without colour ordering. + +``` +TMP = VVVVk_1(W_a,W_b,W_c) + VVV1P0_1(VVV1P0_1(W_a,W_b), W_c) +``` +Both carry the same `1/P^2` through the same propagator, so this is a plain +sum. Same for amplitudes. + +## Established facts (measured, not assumed) + +**1. The colour identity holds exactly.** Grouping every amplitude piece by +its colour vector over the ColorBasis: + +| process | amplitude pieces | colour groups | cubic diagrams per group | +|---|---|---|---| +| `g g > g g` | 6 | 3 | exactly 1 | +| `g g > g g g` | 45 | 15 | exactly 1 | +| `g g > g g g g` | 510 | 105 | exactly 1 | + +Every quartic piece is colour-proportional (±1) to exactly one cubic diagram. +Merged count == pure-cubic diagram count == `(2n-5)!!`. `g g > 4g` also +verified in generated Fortran: folding 405 of 510 amplitudes and zeroing the +sources leaves `|M|^2` bit-identical. + +**2. The seed rule — forbid two 3-gluon vertices from sharing a line.** + +| process | full | seed | seed % | reconstructed by unrolling | missing | +|---|---|---|---|---|---| +| `g g > g g` | 4 | 1 | 25% | 4 | 0 | +| `g g > g g g` | 25 | 10 | 40% | 25 | 0 | +| `g g > g g g g` | 220 | 55 | 25% | 220 | 0 | +| `g g > g g g g g` | 2485 | 385 | 15.5% | 2485 | 0 | + +This is forced, not tuned. Unrolling a quartic vertex always yields two +**adjacent** cubic vertices (joined by the line that replaced the vertex), so +a diagram is reachable iff it has an adjacent cubic pair to contract back. +The diagrams with no such pair are exactly the ones that must be in the seed, +and every diagram either has such a pair or is in the seed — necessary and +sufficient, hence exact coverage. + +Note "no 3-gluon vertices **at all**" is the over-restrictive special case: it +loses the diagrams whose cubic vertices are non-adjacent (60 of 220 at six +gluons). + +**3. Reconstruction gives matched rootings for free.** A reconstructed diagram +and its quartic partner come from the *same* seed diagram, so they share a +decomposition and every current except the one being summed. This is the +property the whole optimisation needs. + +## Already committed + +| commit | what | +|---|---| +| `fcd8218b6` | link map (`unroll_quartic_vertices`, verified vs colour algebra, N=2..5) + amplitude sums | +| `5c9cdb301` | why the current sums fail on the un-rewired DAG | +| `1c1722ae8` | revert of the auxiliary-particle generation | +| `98d288f41` | `reroot_diagram`, validated 755/755 — probably NOT needed under the seed rule | +| `25fc6d1cc` | step 1, the seed rule inside `reduce_leglist` | +| `7da06bac7` | step 2+3, `expand_seed_diagrams` and the recorded links | +| `8e634cf9a` | step 4, `TMP = W1 + c*W4` at the amplitudes | + +Useful pieces to keep: `get_unrollable_quartic_vertices`, +`unroll_quartic_vertices`, `diagram_colour_signature`, `UnrollDiagramTag`, +`split_quartic_vertex`, `unrolled_diagram`, `get_quartic_amplitude_merges`, +`get_amplitude_merge_lines`. + +## Plan + +**Step 1 — enforce the seed rule in generation.** DONE, `25fc6d1cc`. Rejected +inside `reduce_leglist`, tracking by leg number which lines a cubic vertex +produced. Measured against the full generation filtered by an independently +written adjacency detector, comparing the diagrams and not only the counts: +1 / 10 / 55 / 385, and 4165 of 34300 at eight gluons. + +The closing vertex needs two cases, not one. A real n->0 interaction takes +the legs left over as lines coming in, but a 2->2 like reduction ends on the +*identity* vertex, which only states that its two legs are the two ends of +one line — and that line joins the two vertices which produced them. Missing +that left `g g > g g` with all four of its diagrams. + +**Step 2 — reconstruct the full set by unrolling the seed.** DONE, +`1b9474f69`, `expand_seed_diagrams`. Diagram count exactly the baseline +(4/25/220/2485), same diagrams by tag, `|M|^2` bit-identical at four and five +gluons and to 1e-14 at six and seven. + +The dedup has to run on the *glued* form. While the identity vertex is still +there the same diagram has several spellings, and a quartic vertex sitting +just in front of it is not yet the last one — which is what decides how ALOHA +indexes its colour structures. Without gluing first, the dedup caught nothing +(40 diagrams for `g g > g g g`) and 8 of 30 recorded colour chains disagreed +with the colour algebra. + +**Step 3 — record the link during reconstruction.** DONE, same commit, +`get_quartic_unroll_links`. 3 / 30 / 405 / 6300 links, every one agreeing +target for target with `unroll_quartic_vertices`, which stays as the +independent colour-algebra cross-check. + +**Step 4 — the current sum.** DONE at the amplitudes, `8e634cf9a`, and +blocked deeper. `TMP = W1 + c*W4` where a quartic current and its cubic +partner feed the same vertex; the amplitude reads the sum and the quartic +amplitude is never computed: + +``` +W(20) = W(19) +W(20)%W(:) = W(19)%W(:) + W(12)%W(:) +CALL VVV1_0(W(4),W(5),W(20),GC_10,AMP(33)) +``` + +The sum is shared by every amplitude reading it, so it pays for as many calls +as it has users: 60 amplitude calls for 30 sums at six gluons, 432 for 60 at +seven. Substituting several mothers of one amplitude also produces the +amplitude with all of them substituted, so every subset has to be a merge into +that same target weighing the product of the coefficients — that check is what +keeps the count honest. + +Two things it costs. `reuse_outdated_wavefunctions` works out when a slot is +free from the diagrams alone and cannot see the extra read, so it has to be +told, or the two currents get handed the same slot (`W(11) + W(11)`). And the +sums take a slot each, never reused: `NWAVEFUNCS` 51 -> 91 at six gluons. + +| | `g g > g g g` | `g g > g g g g` | `g g > 5 g` | +|---|---|---|---| +| helas calls | 94 -> 93 + 7 sums | 637 -> 612 + 30 | 8159 -> 7784 + 60 | +| JAMP lines | 131 -> 101 | 1082 -> 688 | 23672 -> 7864 | +| per call | 34.88 -> 35.12 s | 47.77 -> 46.02 s | 42.16 -> 39.80 s | + +**The sum stays at the amplitudes.** Measured +on the reconstructed matrix element (`g g > g g g g`): of the 275 places where +a cubic current is fed by another cubic current *and* the quartic partner +taking the same four lines exists, 225 are the last vertex — the amplitude +sum, which pitfall 7 says buys nothing — and 50 are genuine currents. **None +of the 50 pass the 1:1 consumer test.** At seven gluons, none of 135. Only at +five gluons do all 7 pass. + +Why, from a failing pair: quartic current 30 has consumers +`{Wav(3,30), Amp(3,4,6,30)x3, Amp(6,8,30), Amp(4,10,30)}` while its cubic +partner 53 has `{Amp(6,8,53), Amp(4,10,53), Amp(3,12,53)}`. Two correspond; +the rest do not, because the same diagram is rooted differently on the two +sides. + +That is forced, not a bug in the reconstruction. Expanding the seed produces +more (seed, choice) instances than there are diagrams, so some diagrams are +reached from several seeds: + +| process | seeds | (seed, choice) instances | diagrams | +|---|---|---|---| +| `g g > g g` | 1 | 4 | 4 | +| `g g > g g g` | 10 | 40 | 25 | +| `g g > g g g g` | 55 | 340 | 220 | +| `g g > 5 g` | 385 | 4900 | 2485 | + +A diagram carries one decomposition, so it can be rooted to match at most one +of its quartic partners, while the current sum needs the match at *every* +node. Fact 3 above holds per (seed, choice) and breaks under the dedup that +fact 2 requires. Not fixable by choosing the spelling more cleverly: the +counting alone rules it out, and `g g > g g` — the one row with no collision +— is also the one process where the seed rule reaches every partner. + +That is why the sum is taken only where the vertex reading it is an +*amplitude*: there is nothing above it, so no consumer can be handed a term +it must not have and the substitution is a plain swap of one argument. A sum +at a current would have to be carried through every current above it, and +those are shared. What would work there is a partial rewrite — hand the sum +only to the consumers which do correspond and leave W1 and W4 serving the +rest — which splits shared consumers and cascades upward. That is a DAG +rewriting problem, not this plan. + +The same counting is what leaves the two-substitution cases at seven gluons +on the table: their target has a source with both mothers substituted, but +the source with only the *other* one substituted is spelled with a different +rooting, so the subset check refuses it. 432 of the 864 amplitude calls the +structure allows — and the rest are not simply waiting to be picked up, see +"where to go next". + +**Step 5 — validate and time.** DONE. `|M|^2` for `g g > N g`, N=2..5, and +per-call timing from the shipped `check` driver, which already loops +`SMATRIX` when given a second argument (`./check 1000 20000`). + +| | `g g > g g g g` | `g g > 5 g` | +|---|---|---| +| flag off | 47.73 / 47.77 / 47.80 s | 42.15 / 42.16 s | +| flag on, before steps 1-4 | 47.76 s | 40.86 s | +| flag on, steps 1-3 only | 48.03 / 48.07 s | 40.41 s | +| flag on, at HEAD | 46.14 / 45.90 s | 39.80 s | + +and the code that produces it: + +| | helas calls | JAMP lines | +|---|---|---| +| flag off | 637 / 8159 | 1082 / 23672 | +| flag on, before steps 1-4 | 637 / 8159 | 688 / 8012 | +| flag on, steps 1-3 only | 672 / 8216 | 688 / 7864 | +| flag on, at HEAD | 612 + 30 / 7784 + 60 | 688 / 7864 | + +So the flag is worth **+3.8% at six gluons and +5.6% at seven**. The JAMP +shrink from `fcd8218b6` carries seven gluons on its own; six gluons only +turns positive with the current sum, because the reconstruction deviates from +the canonical decomposition — which is the whole point — and thereby weakens +the wavefunction CSE, 35 calls at six gluons and 57 at seven. Five gluons is +a wash (34.88 -> 35.12 s): 7 sums against 7 calls is too little to pay for +the 14 extra slots. + +`|M|^2` bit-identical at four and five gluons, 1e-15 at six and seven, and +unchanged for `g g > t t~ g g` and `u u~ > g g g`. + +With the flag off, `matrix.f` is byte-identical to `3b3ed9e85` for N=2..5. + +## Step 6 — madevent, and what it does to AMP2 + +`29b0c670e`. Two things had to give before the optimisation survived the +madevent path, neither of them about AMP2: + +1. **The sum has to be a `CALL`.** Helicity recycling rebuilds the whole DAG + from the calls alone, so a bare assignment was invisible to it — the summed + current never entered the graph and the amplitude reading it died on a + `KeyError`. Written as `CALL SUMW_1(W(a),W(b),W(c))` it is an ordinary + internal wavefunction with two mothers and everything downstream handles + it. `sumw_1`/`subw_1` live in `aloha_functions.f`; the coefficient is + restricted to ±1, which is all it has ever been. +2. **`hel_recycle.add_indices` could not index a statement-initial `AMP(`.** + The pattern ate the character in front of it and there is none at the + start of a line, so `AMP(31) = AMP(31) + AMP(1)` came out as + `AMP(31) = AMP( K,31) + AMP( K,1)`. Latent until now: nothing had ever + emitted a line beginning with `AMP(`. + +**AMP2 is left alone and picks up the merged amplitude**, which is the right +thing and turns out to matter more than the matrix element speedup. The fold +lines run before the AMP2 block, so the channel weight is +`|AMP_cubic + AMP_quartic|^2`. Nothing else was needed: `get_amp2_lines` +already skips any diagram with a four point vertex, so the folded amplitudes +were never referenced. + +That skip is the point. With the flag off, at six gluons: + +| | amplitudes computed | distinct AMP reaching AMP2 | +|---|---|---| +| flag off | 510 | **105** | +| flag on | 450 (+30 sums) | 105, now carrying all 510 | + +so four fifths of the amplitude — every quartic contribution — used to enter +**no** channel weight at all. Folding puts each of them in the channel whose +colour factor it shares, which is exactly where it belongs. + +**The integration is not measurably better or worse.** That is the answer, and +it took some care to get to, because `generate_events` wall time is a bad +metric here: the refine stage adapts, and the *same* configuration swings by a +factor four between seeds (217 s to 877 s with the flag off). Every number +below is mean +- standard error over independent seeds. + +`g g > g g g`, `generate_events`, 10000 events, three seeds: + +| | cross section | rel. error | ME cpu | +|---|---|---|---| +| flag off | 3.680-3.694e+07 pb | 0.327% | 52.7 s | +| flag on | 3.684-3.694e+07 pb | 0.300% | 49.0 s | + +`g g > g g g g`, `generate_events`, 2000 events, four seeds, and a survey-only +run — the same fixed number of points both ways, so the error measures the +channel weights and nothing else — over six seeds: + +| | rel. error (survey) | rel. error (full) | cpu (full) | +|---|---|---|---| +| flag off | 2.14% | 0.498% | 583 s | +| flag on | 1.93% | 0.473% | 580 s | + +Every difference is favourable and none of them is significant: + +| | off - on | | +|---|---|---| +| `g g > g g g` error | -8% | 1.8 sigma | +| `g g > g g g` cpu | -7% | 1.1 sigma | +| `g g > g g g g` survey error | -10% | 1.1 sigma | +| `g g > g g g g` full error | -5% | 0.8 sigma | +| `g g > g g g g` full cpu | -1% | 0.0 sigma | + +Cross sections agree everywhere. So the honest reading is that handing the +quartic contributions to the channel that shares their colour factor does not +hurt the integration, and may help it slightly — but the seed to seed scatter +is far larger than the effect, and nothing here is established at more than +two sigma. A campaign of tens of seeds would be needed to call it either way. + +## Step 7 — madmatrix + +`e93dfa1b1`. `SUMW_1`/`SUBW_1` as C++ templates next to `ALOHAOBJ` in +`cpp_hel_amps_h.inc` (they cannot use the `INLINE` macro, which the ALOHA +generated block defines further down the header), and the madmatrix writer +emits them exactly as the Fortran one does. + +**The colour amplitudes had to be sorted out first, and that was a live bug.** +`get_color_amplitudes` dropped every merge source from the JAMPs on the +assumption that the caller writes the amplitude sums to put them back. Only +the Fortran writer does, so C++ and python output with the flag set +was quietly losing four fifths of the amplitude. It now takes +`merge_quartic_amplitudes`; a backend which writes no sums keeps those +amplitudes in the JAMPs, where their own colour coefficients give the +identical result. So madmatrix gets the current sums, which really do remove +work, and leaves the amplitude level merges alone — there is no `AMP` array +there to fold into anyway, each amplitude going straight into the JAMPs. + +One bug in the shared writer surfaced here: a wavefunction number can be +listed by more than one diagram in the madmatrix matrix element (two objects +for the same current with the mothers ordered differently), so a sum was +written twice — 50 lines for 30 sums at six gluons. Harmless numerically, both +write the same value to the same slot, but wasted. Both writers now write each +sum once. + +|M|^2 to 1e-14 at five and six gluons (`FPTYPE=d`; the default mixed +precision build rounds the two to the same value), `CPPProcess.cc` +byte-identical with the flag off. + +**Speed is mixed, and worse than Fortran** (after step 8): + +| | amp calls | nwf | evt/s (sse4, FPTYPE=d) | | +|---|---|---|---|---| +| `g g > g g g` | 45 -> 38 | 12 -> 19 | 72820 -> 68010 | **-6.6%** | +| `g g > g g g g` | 510 -> 450 | 51 -> 86 | 2696 -> 2748 | **+1.9%** | + +There is no JAMP fold here to pay for the extra wavefunctions, so five gluons +loses outright: 7 sums against 7 saved amplitude calls does not cover a +wavefunction array half again as large. Note the slot count with the flag on +is worse in madmatrix than in Fortran (86 against 66 at six gluons), because +its matrix element carries duplicate wavefunctions -- two objects for the same +current with the mothers ordered differently, which MG5 does not merge. + +## Step 8 — recycle the slots the sums take + +`5b421bc80`. A sum used to get a slot of its own at the end, never reused, +which more than doubled `NWAVEFUNCS`. But a sum is an ordinary producer -- +written as soon as the later of its two currents is made, dead after the last +amplitude reading it -- so it goes through `reuse_outdated_wavefunctions` with +everything else. That also lets the cubic current die at the sum rather than +at the amplitude, since the amplitude no longer reads it. + +| | flag off | own slots | recycled | +|---|---|---|---| +| `g g > g g g` | 12 | 26 | **19** | +| `g g > g g g g` | 51 | 91 | **66** | +| `g g > g g g g` (madmatrix) | 51 | 111 | **86** | + +**A bug in `reuse_outdated_wavefunctions` had to be fixed first, and it was +not introduced here.** The same wavefunction can be listed by more than one +diagram in the madmatrix matrix element, and the allocator handed it a slot +again on the second listing, leaking the first. Harmless while the sums had +their own slots; once they shared the pool, `g g > g g g g` came out 0.2% +wrong. A wavefunction now takes one slot at its first appearance and keeps it +until its last use. Nothing moves with the flag off -- `matrix.f` and +`CPPProcess.cc` are byte-identical for N=2..4 in both backends. + +Worth it for madmatrix (-8.4% -> -6.6% at five gluons, +0.9% -> +1.9% at six) +and neutral for Fortran (+3.8% -> +3.9%): there the slot count was never the +bottleneck. The madevent run is unchanged, same cross section and error. + +## Results + +Everything below is `g g > N g` with the flag off against `speed`, on the +same machine. Standalone Fortran is the shipped `check` driver looping +`SMATRIX`; madmatrix is `check_sa.exe perf` built `FPTYPE=d` on `cppsse4` +(the default mixed precision build rounds the two to the same value and would +hide any difference). Two runs each, reproducible to about 0.1%. + +**Speed** + +| | standalone | | madmatrix | | +|---|---|---|---|---| +| | per call | | evt/s | | +| `g g > g g` | 11.00 -> 11.04 s | -0.4% | 875150 -> 878724 | +0.4% | +| `g g > g g g` | 34.90 -> 34.99 s | -0.3% | 72359 -> 66757 | **-7.7%** | +| `g g > g g g g` | 47.35 -> 45.61 s | **+3.7%** | 2699 -> 2784 | **+3.1%** | +| `g g > 5 g` | 43.05 -> 39.98 s | **+7.1%** | 41.75 -> 43.33 | **+3.8%** | + +Four gluons is a wash on both (there is nothing to sum: the only quartic +vertex is the whole amplitude). Five gluons loses on madmatrix, where the +wavefunction store grows by half and there is no JAMP fold to pay for it. Six +and seven gluons win on both, and the gain grows with the multiplicity. + +**Where it came from.** Three states: the flag off (what an unoptimised build +gives), the branch as it stood before this work (`3b3ed9e85`, only the +amplitude merges of `fcd8218b6`), and the flag on today. + +standalone Fortran, `slots / wavefunction calls + sums / amplitude calls`: + +| | flag off | before this work | at PR open | now | +|---|---|---|---|---| +| `g g > g g` | 5 / 7 / 6 | 5 / 7 / 6 | 5 / 7 / 6 | 5 / 7 / 6 | +| `g g > g g g` | 12 / 33 / 45 | 12 / 33 / 45 | 19 / 39+7 / 38 | 19 / 39+7 / 38 | +| `g g > g g g g` | 51 / 111 / 510 | 51 / 111 / 510 | 66 / 146+30 / 450 | **78 / 126+30 / 450** | +| `g g > 5 g` | 268 / 898 / 7245 | 268 / 898 / 7245 | 290 / 955+60 / 6813 | **259 / 925+60 / 6813** | + +madmatrix, `slots / amplitude calls`: + +| | flag off | before this work | at PR open | now | +|---|---|---|---|---| +| `g g > g g` | 5 / 6 | *wrong* | 5 / 6 | 5 / 6 | +| `g g > g g g` | 12 / 45 | *wrong* | 19 / 38 | 19 / 38 | +| `g g > g g g g` | 51 / 510 | *wrong* | 86 / 450 | **78 / 450** | +| `g g > 5 g` | 268 / 7245 | *wrong* | 320 / 6813 | **259 / 6813** | + +"wrong" is not a figure of speech: at `3b3ed9e85` `get_color_amplitudes` dropped +every merge source from the JAMPs unconditionally, and only the Fortran writer +wrote the sums putting them back, so with the flag set any C++ or python output +lost 405 of its 510 amplitudes at six gluons, silently. That is fixed here. + +Per-call time, standalone Fortran (lower is better) and madmatrix in evt/s +(higher is better): + +| | off | before | at PR open | now | +|---|---|---|---|---| +| fortran `g g > g g g g` | 47.77 s | 48.12 s | 45.61 s | **45.25 s (+5.3%)** | +| fortran `g g > 5 g` | 42.19 s | 40.88 s | 39.98 s | **40.10 s (+4.9%)** | +| madmatrix `g g > g g g` | 74229 | *wrong* | 66757 | 67566 (-9.0%) | +| madmatrix `g g > g g g g` | 2651 | *wrong* | 2784 | **2880 (+8.6%)** | + +Timings taken in batches, and there is about 1% of drift between batches, so +each column should be read against the `off` measured with it. + +**Memory — the wavefunction store**, which is what the optimisation costs. +`NWAVEFUNCS` in Fortran, `nwf` in madmatrix; bytes are 100 per wavefunction in +Fortran (4 complex, 4 reals, one int) and 192 in madmatrix on sse4 in double +(4 complex plus a 4-momentum, over a 2 event vector). + +| | off | on, slot each | on, recycled | | +|---|---|---|---|---| +| `g g > g g` | 5 | 5 | 5 | 500 B | +| `g g > g g g` | 12 | 26 | **19** | 1900 B (+58%) | +| `g g > g g g g` | 51 | 91 | **66** | 6600 B (+29%) | +| `g g > 5 g` | 268 | 321 | **290** | 29000 B (+8%) | + +madmatrix carries duplicate wavefunctions of its own, so its count with the +flag on is higher: 19 / 86 / 320 at five, six and seven gluons, against +19 / 66 / 290 in Fortran. The relative cost falls as the multiplicity rises +(+58% at five gluons, +19% at seven), which is why the trade turns positive +from six gluons on. + +**Work done per call** + +| | standalone helas calls | JAMP lines | madmatrix amplitude calls | jamp lines | +|---|---|---|---|---| +| `g g > g g` | 29 -> 29 | 23 -> 20 | 6 -> 6 | 34 -> 34 | +| `g g > g g g` | 94 -> 100 (+7 sums) | 131 -> 101 | 45 -> 38 | 370 -> 314 | +| `g g > g g g g` | 637 -> 642 (+30) | 1082 -> 688 | 510 -> 450 | 8170 -> 7210 | +| `g g > 5 g` | 8159 -> 7844 (+60) | 23672 -> 7864 | 7245 -> 6813 | 231850 -> 218026 | + +`|M|^2` is bit-identical at four and five gluons and agrees to 1e-14 at six +and seven, in both backends. With the flag off, `matrix.f` and `CPPProcess.cc` +are byte-identical to before any of this. + +## The diagram order — measured, and worth a lot + +`reuse_outdated_wavefunctions` is a linear scan allocator over lifetimes taken +in **emission order**, so `NWAVEFUNCS` depends on the order the diagrams are +written in. (The wavefunction *set* does not — that is content addressed, +`wavefunctions[wavefunctions.index(new_wf)]` — but the number of slots very +much does.) The shipped expansion emits every seed first and then the +unrollings, which is the worst case for it: a quartic current is made early +and its sum is not consumed until much later. + +Four orders were built and run. A sum can only be formed when its quartic +current is emitted before the target amplitude, which is what makes this a +trade rather than a free win: + +| `NWAVEFUNCS` / sums | off | seeds first (shipped) | by quartic count | seed then its own unrollings | last discovery | +|---|---|---|---|---|---| +| `g g > g g g` | 12 | 19 / 7 | 19 / 7 | 11 / 3 | **15 / 7** | +| `g g > g g g g` | 51 | 66 / 30 | 64 / 30 | 33 / **0** | **27 / 30** | +| `g g > 5 g` | 268 | 290 / 60 | 314 / 60 | 245 / 60 | **219 / 60** | + +Emitting a seed followed by its own unrollings gives the locality but loses +the sums: the fully cubic target is usually claimed by an earlier seed, so the +quartic current arrives after it. **Placing each diagram at its *last* +discovery instead of its first fixes that** — the target then sits after every +seed which can reach it — and takes both: all the sums, and a slot count +*below* the flag off baseline (27 against 51 at six gluons, 219 against 268 at +seven). + +**Shipped, once the reason it broke madmatrix was found.** The blocker was not +the order at all: + +1. The reconstruction could build the same cubic current with its two mothers + in either order. `sorted_mothers` leaves them alone, because for two + identical gluons its key ties and the sort is stable, so they stay two + objects. +2. **`VVV1P0_1` is antisymmetric under exchanging its two inputs.** Measured: + `VVV1P0_1(a,b) + VVV1P0_1(b,a) = 0` exactly. So the two are *negatives* of + each other and write out calls differing by a sign. +3. **`HelasWavefunction.__eq__` compares mothers by sorted number** and calls + them equal -- "the number for this wavefunction, the pdg code, and the + interaction id are irrelevant". +4. `export_cpp` renumbers wavefunctions by that equality, so the two landed on + one number and one slot inside a single matrix element and one of them + silently carried the wrong sign. The Fortran writer never hits it because + it does not renumber by equality. + +Taking the legs of both unrolled vertices in a canonical order removes every +such pair -- 20 of the 35 extra wavefunctions at six gluons, 30 of 57 at seven +-- so there is nothing left to collide, and the order goes in. + +| | wavefunctions | order-flipped twins | +|---|---|---| +| `g g > g g g` | 33 -> 39 | 0 | +| `g g > g g g g` | 111 -> 126 (was 146) | 20 -> 0 | +| `g g > 5 g` | 898 -> 925 (was 955) | 30 -> 0 | + +Placing each diagram at its last discovery then puts it after every seed which +can reach it, hence after every quartic current summable into it, so all the +sums survive. `NWAVEFUNCS` at seven gluons ends up **below** the unoptimised +build: 259 against 268. + +## The slot ordering, searched + +Once the twins are gone the slot count is the same in both backends (78 and +259 at six and seven gluons), so the order is one shared problem rather than a +per-backend one. Six orders were built and measured. The wavefunction count is +the same in all of them -- reordering the diagrams never changes *which* +currents exist, only how long each stays alive: + +| order | `g g > g g g` | `g g > g g g g` | `g g > 5 g` | sums kept | +|---|---|---|---|---| +| last discovery (shipped) | 19 | 78 | 259 | yes | +| first discovery | 19 | 81 | 314 | yes | +| by quartic count | 19 | 81 | 314 | yes | +| by quartic count, then last | 19 | 81 | 259 | yes | +| last discovery, then quartic count | 19 | 78 | 259 | yes | +| **reversed last discovery** | **12** | **54** | **199** | **no -- all lost** | + +The last row is the interesting one: it is far the best on slots and useless, +because reversing puts each target ahead of the quartic currents which feed +it, so no sum can be built. That is the trade in one line -- the constraint +that makes the sums possible is what costs the slots. + +A proper register-pressure greedy was also written: order the diagrams under +the precedence "everything which unrolls to a diagram comes before it", and at +each step take the one leaving fewest currents alive. It buys 19 -> 18 and +78 -> 76 and **nothing at all** at seven gluons, for an O(n^2) pass which +takes generation from 0.88 s to 3.24 s at seven gluons and would cost minutes +at eight. Not worth it; the shipped order is within a couple of slots of what +the search finds. + +## The backend-chosen order (`auto`) + +`speed` suits a cpu and `slots` suits a gpu, but one generation can feed both, +so `auto` defers the choice to `output`. It works only because the two modes +differ in nothing but the diagram order, and `slots` is `speed` reversed -- +verified byte for byte: generating in the `speed` order and reversing at +output time reproduces a native `slots` generation exactly, in both backends, +and a session going standalone -> standalone_mg7 -> standalone reproduces its +first output for the third. + +**The choice comes from the matrix element exporter, not the output format.** +`output madevent --me_exporter=` hands one +`self._curr_matrix_elements` to both exporters in a single `export_processes` +call, so the fortran driver and the gpu matrix elements *cannot* carry +different orders. Keying on the format would give that output the cpu order, +which is the one case the whole thing exists for; keying on the me exporter +gives it `slots` (measured: `NWAVEFUNCS=54` rather than 78 at six gluons). + +Two things had to be worked around, both pre-existing and neither specific to +this option: + +* **An export mutates the diagrams it is given** -- 345 of 757 vertex leg + records change on `g g > g g g g`. Reversing mutated diagrams gives an + equivalent but differently numbered result, so `apply_quartic_diagram_order` + reverses a copy taken before the first export. `copy.deepcopy` of the + *amplitudes* is not an option: it drags the model along and trips + `assert type(col_obj) != array.array` in `color_algebra.create_copy`. The + diagrams alone copy cleanly and cost 0.011 s at seven gluons. +* **An export also drops the marks saying the diagrams came from a seed** -- + after it, `seed_forbidden_cubic_ids` is empty and `quartic_unroll_tags` has + 0 entries instead of 405. So whether an amplitude may be reordered has to be + read before the first export, not at the output which wants to reorder. + +Two dead ends, both measured: reversing the `HelasMatrixElement` diagrams +instead of the amplitude's trips the lifetime assert in +`reuse_outdated_wavefunctions`, since a wavefunction number has to be first +seen in emission order; and restoring only `from_group` on the base diagrams +is not enough to undo an export. + +## Why this is not the default + +Defaulting `merge_quartic_vertices` to `auto` was tried and **reverted**. It is +safe on the paths it was built for -- UFO Fortran, madmatrix, the python +exporter -- and each of those is validated on `|M|^2`. Turning it on for +everything found three consumers which read the diagram or amplitude +*structure* rather than the result: + +1. **FKS born/real linking.** `link_rb_configs` finds the vertex splitting + `ij` into `i` and `j` and takes it out. The unrolling re-roots the real + diagrams and can put that pair in the closing vertex, where there is + nothing to take out, so the remainder is malformed and no born + configuration matches it. Same 3 diagrams selected, same tag set, different + decomposition: + + ``` + off ((1,2>1),(4,5>4),(1,3,4)) the 4-5 vertex is internal + auto ((1,2>1),(1,3>1),(1,4,5)) the 4-5 pair closes the diagram + ``` + + `p p > j j [QCD]` raised `FKSProcessError`. **Fixed** by generating an NLO + process with the merging off, in `FKSMultiProcess.__init__`, so the option + is now safe for an NLO user rather than only for the default. `g g > g g + [QCD]` generates byte-identically with the option set and unset. + +2. **The legacy `FortranHelasCallWriter`.** Only `FortranUFOHelasCallWriter` + emits the amplitude folds which put the merged contributions back. The + MG4-style writer computes `AMP(1..3)` from `GGGGXX` and then leaves them out + of the JAMPs -- a **silently wrong** `|M|^2`, not a crash. Not fixed: like + `export_cpp` and `export_python` it needs `merge_quartic_amplitudes=False`, + but `get_JAMP_lines` is on the exporter and does not know which writer it + is paired with. + +3. **Anything pinning the diagram order.** Cosmetic but wide: `colorize` and + `DiagramTag` tests select diagrams by position, and the sextet colour basis + goes 13 -> 15 because folding amplitudes decomposes the same `|M|^2` over + more colour structures (`|M|^2` bit-identical, checked with + `MatrixElementEvaluator`). + +### The scan for a fourth + +Looked for one, did not find one in the shipped tree, and the search bounds +the remaining risk. + +**Every merged-JAMP consumer, against every writer.** `get_color_amplitudes` +has four call sites: `export_cpp` (x2), `export_python` and `madmatrix` all +pass `merge_quartic_amplitudes=False`; only `export_v4`'s three +`get_JAMP_lines*` take the merged default, and every Fortran exporter pairs +with `FortranUFOHelasCallWriter`, which emits the folds. The base +`get_amplitude_merge_lines` returns `[]` and `FortranHelasCallWriter` does not +override `get_matrix_element_calls`, so it is the one writer that silently +drops them -- and it is selected exactly when `self._model_v4_path` is set, +i.e. under `import model_v4`. No other combination in the tree reaches merged +JAMPs without folds. + +The property which keeps `merge_quartic_amplitudes=False` safe is that +`get_color_amplitudes` drops the current-sum folded amplitudes +*unconditionally* and only the amplitude merges conditionally -- so a writer +which emits the sums but not the folds still gets consistent JAMPs. + +**A mechanical audit of the generated code.** For each `AMP(n)` in a generated +matrix element, whether it is written and whether it is read. Read-never- +written is garbage; written-never-read is a contribution dropped on the floor, +which is the legacy writer's signature. Run with the flag off as a control on +every output -- standalone, matchbox, madevent grouped and not, a decay chain, +helicity-recycled files, `u u~ > g g g`, `g g > t t~ g g`, `u u~ > u u~ g g`, +four to six gluons -- and clean everywhere. The split order path is included: +`ProcessExporterFortranSA` and `ProcessExporterFortranME` both always go +through `get_JAMP_lines_split_order`, whose `amp_orders` lists folded +amplitude numbers, but they never reach the code because the colour amplitudes +no longer mention them. + +**The two places which weight or group results.** Subprocess grouping for +`p p > j j` gives the same five directories and byte-identical `configs.inc` +and `coloramps.inc`; only the `g g > g g` matrix element differs. +`find_symmetry`, which feeds the multiplicative `symfact.dat`, keeps the same +equivalence classes and multiplicities -- `[3, 3, 3, 6]` at five gluons and +`[3, 6, 12, 12, 12, 12, 12, 12, 24]` at six, in both settings, with the same +number of channels and the same total weight. Only the representative diagram +indices renumber, which is the renumbering itself. + +So the residual risk of this class is two named things: `import model_v4`, and +a third-party plugin supplying its own `helas_exporter` paired with +`export_v4`'s merged JAMPs. + +The pattern is that the optimisation changes the *representation* -- diagram +order, rooting, which amplitudes survive into the JAMPs -- and every consumer +which reads representation rather than result has to be checked. Three turned +up in one pass, so defaulting it on wants an audit of those consumers, not +another round of patching outward. + +Also fixed on the way, and independent of all this: `link_rb_configs` built +`real_tags` deduplicated but left `good_diags` as it was, then walked the two +in lockstep -- `real_tags.remove(btag)` beside `good_diags.pop(ir)` -- so they +only stayed aligned while the dedup dropped nothing. A no-op on every process +in the test suite, but it made the result order dependent for no reason. + +## Where to go next + +**Give madmatrix the amplitude sums too.** It is the only backend without +them, because there is no `AMP` array to fold into — each amplitude goes +straight into the JAMPs. That is why it gains 1.9% where Fortran gains 3.9%, +and why five gluons still loses. The sources for one target could be +accumulated into a second `amp_sv` slot before the JAMP lines are written, +which the seed ordering makes possible (the quartic diagrams come first), at +the cost of one accumulator per open target. + +Then there are the sums which do not sit at an amplitude, and they need a +node to have exactly one rooting *per merge*, which a diagram list cannot +give. Three ways on, in increasing size: + +1. **The second substitution.** Cheapest of the three, ceiling 432 amplitude + calls at seven gluons, but measured to be at most 282 of them and possibly + much less. Two things stand in the way and only the first is bookkeeping: + + *Identification.* All 432 targets which have a two-substitution source have + exactly one of the two singles present; the other is the same diagram + rooted differently, so it is a different amplitude object with different + mothers and `match_quartic_mothers` cannot see it. Finding it by its + diagram and colour chain — the frame `compute_quartic_amplitude_merges` + already works in — rather than by its mothers would take it. + + *The coefficients have to multiply.* Substituting two mothers also produces + the amplitude with both substituted, weighing the product of the two + coefficients, so the merge map has to agree. It does not always: taking the + double's coefficient over the known single's, the missing single would have + to weigh -1 for 360 of the 432 targets and +1 for 72, and for **150 of them + no merge source into that target weighs that at all**. The sign from + `diagram_colour_signature` does not factorise over two contractions in + general. `subset_is_merged` already refuses those, which is what keeps the + present code sound; extending the identification does not remove the check. +2. **Partial CSE.** Keep the DAG, add `TMP = W1 + W4` alongside W1 and W4, + and split the consumers. Bounded gain: at six gluons only 2 of the 6 + quartic consumers correspond, so it saves 2 subtrees per node out of 50 + nodes. +3. **Drop the diagram list for the currents.** Build the wavefunctions by a + Berends-Giele recursion over subsets — at each node, cubic pair plus + quartic, which generates exactly the matchings and never double counts — + and keep the 220 diagrams only for what they are actually needed for + (multichannel, `matrix.ps`). + +Anything that fragments the diagram list to get a per-rooting copy runs into +pitfall 1, and anything that expands each seed independently double counts — +the fully cubic diagrams get reached once per matching of their adjacency +graph (225 instead of 105 at six gluons). + +## Pitfalls — all of these cost real time in the previous session + +1. **Do not fragment the diagram list.** Generating one diagram per colour + structure (220 -> 510) works numerically but changes a user-visible number + and the MadEvent multichannel. It was reverted for that reason. +2. **`copy.deepcopy` on a `Model` silently breaks colour.** `ColorObject` + derives from `array.array` and deepcopy degrades it to a plain `array`, so + the structures stop being recognisable. Use `create_copy`. +3. **Two colour-chain conventions exist.** `helas_objects` colorizes the + *reconstructed* amplitude (`get_base_amplitude`), whose vertex order can + differ from the generated diagrams. They agree up to six gluons and diverge + at seven. Anything mapping chains to amplitudes must use the reconstructed + one. +4. **Pinning a single colour structure needs colour and Lorentz in the same + frame.** The sum over the three structures is permutation invariant but the + individual terms are not, so the structure must be chosen against + `sorted_mothers` (what ALOHA receives), not the vertex leg order. +5. **The `get_color_amplitudes` filter and the writer emitting the folds must + land together.** Filtering without emitting silently drops the quartic + contributions from the JAMPs — this produced a wrong `|M|^2` that survived + three rounds of debugging because the helas calls looked byte-identical to + baseline. When a number is wrong, diff the JAMP block first. +6. **A current sum is only legitimate if the consumers correspond 1:1.** + Summing into a shared current hands the contribution to *every* consumer. + Measured counter-example on the fragmented structure: quartic current 11 + had consumers {7,9,36,38,60,62} mapping to {1,31,55}, while its partner had + {1,5,31,34,55,58} — three consumers would have gained a term they must not + have. +7. **The amplitude sums buy nothing at runtime.** The JAMP CSE already finds + those pairs (`TMP_JAMP(2) = AMP(1) + AMP(4)`). They do shrink the JAMP block + (1091 -> 697 lines at six gluons), which helps the optimiser and compile + time. Expect the speedup to come from the currents, not from these. + *Measured since:* wrong at seven gluons, where the JAMP block goes 23672 -> + 7864 lines and that alone is +3.1%. Right at six, where it is a wash. +8. **A wavefunction number is not a slot.** `reuse_outdated_wavefunctions` + hands the same `me_id` to wavefunctions whose lifetimes do not overlap, and + works those lifetimes out from the diagrams alone. Anything emitting an + extra read of a wavefunction has to extend the lifetime there, or it reads + whatever else has since been written into that slot -- which showed up as + `W(11)%W(:) = W(11)%W(:) + W(11)%W(:)`. + +## Measuring + +Generation: `set merge_quartic_vertices speed` then `generate g g > g g g g`. +Compare `matrix.f` against a run without the variable. Tests: +`./tests/test_manager.py -p U test_diagram_generation test_color_amp +test_helas_objects test_base_objects` (199, must stay green with the flag off). diff --git a/madgraph/__init__.py b/madgraph/__init__.py index 0d17042f0d..0c2646e14a 100755 --- a/madgraph/__init__.py +++ b/madgraph/__init__.py @@ -60,4 +60,24 @@ class aMCatNLOError(MadGraph5Error): ordering = True else: ordering = False + +# Sum the quartic gluon contributions into the cubic amplitude carrying the +# same colour factor, see HelasMatrixElement.get_quartic_amplitude_merges. +# Set through the interface, "set merge_quartic_vertices ", and read +# here because it is wanted while the diagrams are generated, long before any +# exporter exists. False, or one of: +# 'speed' -- the current sums, and the diagram order which allows them. Wins +# on cpu, where the amplitude calls dominate. +# 'slots' -- no current sums, and the order which keeps fewest currents +# alive. Trades 6% more amplitude calls for 23% fewer +# wavefunction slots at seven gluons, which is the trade a gpu +# wants when occupancy is the limit. +# 'auto' -- generate as for 'speed', and let each output pick: 'slots' when +# the matrix elements go to a gpu backend, 'speed' otherwise. The +# interface resolves it to one of the two above before anything +# reads it again, so only the generation ever sees 'auto'. +# Off by default: several consumers read the diagram or amplitude structure +# rather than the result, see "Why this is not the default" in +# docs/gluon-quartic-plan.md. +merge_quartic_vertices = False diff --git a/madgraph/core/base_objects.py b/madgraph/core/base_objects.py index a43e58fad7..5ba2de06ad 100755 --- a/madgraph/core/base_objects.py +++ b/madgraph/core/base_objects.py @@ -1108,12 +1108,18 @@ def check_flavor(self, map_flavor, model): """ pdgs = [p.get_pdg_code() for p in self.get('particles')] + # 'merged_particles' is keyed by the positive merged code only + # ({81: [1,2,3,4], 82: [11,13], ...}) while pdgs holds the *signed* code: + # a leg that is an antiparticle instance reports -82, not 82. Membership + # must therefore always be tested through abs() -- a bare `pdg in ...` + # silently misses an interaction whose merged legs are all antiparticles + # (e.g. the l+ pair of a lepton-number-violating H-- l+ l+ vertex). + positions = [i for i in range(len(pdgs)) if abs(pdgs[i]) in model.get('merged_particles')] flavor = [map_flavor[pdg].pop() if abs(pdg) in model.get('merged_particles') else 0 for pdg in pdgs] for coupling in self.get('couplings').values(): if isinstance(coupling, str): # if no PDG in merge range -> return True - if any([pdg in model['merged_particles'] for pdg in pdgs]): - positions = [i for i in range(len(pdgs)) if abs(pdgs[i]) in model['merged_particles']] + if positions: if len(positions) != 2: raise Exception elif flavor[positions[0]] == flavor[positions[1]]: @@ -2980,7 +2986,7 @@ class Vertex(PhysicsObject): """ sorted_keys = ['id', 'legs'] - + # This sets what are the ID's of the vertices that must be ignored for the # purpose of the multi-channeling. 0 and -1 are ID's of various technical # vertices which have no relevance from the perspective of the diagram @@ -3014,7 +3020,6 @@ def default_setup(self): # that it can be easily identified when constructing the DiagramChainLinks. self['id'] = 0 self['legs'] = LegList() - def filter(self, name, value): """Filter for valid vertex property values.""" diff --git a/madgraph/core/color_algebra.py b/madgraph/core/color_algebra.py index 8b3ac0db06..662f124f28 100755 --- a/madgraph/core/color_algebra.py +++ b/madgraph/core/color_algebra.py @@ -332,6 +332,13 @@ def simplify(self): return ColorFactor([col_str1, col_str2]) + def complex_conjugate(self): + """f (and d) are real, so complex conjugation leaves them untouched. + The default behaviour of reversing the indices would introduce a + spurious sign, since f is totally antisymmetric.""" + + return self + #=============================================================================== # d #=============================================================================== @@ -1128,18 +1135,39 @@ def extend_str(self, new_col_fact): for col_str in new_col_fact: self.append_str(col_str) + @staticmethod + def similarity_key(col_str): + """Hashable key which is equal for two color strings exactly when + ColorString.is_similar says they are, i.e. same Nc power, same + imaginary character and same canonical representation (both the + canonical structure and the index replacement dictionary).""" + + canonical, repl_dict = col_str.to_canonical() + return (col_str.Nc_power, col_str.is_imaginary, canonical, + tuple(sorted(repl_dict.items()))) + def simplify(self): """Returns a new color factor where each color string has been - simplified once and similar strings have been added.""" + simplified once and similar strings have been added. + + Similar strings are looked up through a dictionary rather than by + scanning the strings accumulated so far: the color factors appearing + for high multiplicity processes have thousands of terms, and the linear + scan of append_str made this quadratic. Insertion order is preserved, + so the resulting color factor is identical to the one a scan produces.""" new_col_factor = ColorFactor() + similar = {} # Simplify for col_str in self: res = col_str.simplify() - if res: - new_col_factor.extend_str(res) - else: - new_col_factor.append_str(col_str) + for new_str in (res if res else [col_str]): + key = self.similarity_key(new_str) + try: + similar[key].add(new_str) + except KeyError: + similar[key] = new_str + new_col_factor.append(new_str) # Only returns non zero elements return ColorFactor([col_str for col_str in \ diff --git a/madgraph/core/color_amp.py b/madgraph/core/color_amp.py index 55d5731fef..0fbac06389 100755 --- a/madgraph/core/color_amp.py +++ b/madgraph/core/color_amp.py @@ -21,6 +21,9 @@ import collections import copy import fractions +import itertools +import itertools +import logging import operator import re import array @@ -35,6 +38,242 @@ if madgraph.ordering: set = misc.OrderedSet +logger = logging.getLogger('madgraph.color_amp') + +#=============================================================================== +# Del Duca-Dixon-Maltoni (adjoint) color basis +#=============================================================================== +# For a process whose color structure is purely adjoint (all colored external +# legs are octets) the historical trace basis Tr(1,sigma(2),...,sigma(n)) has +# (n-1)! elements while, thanks to the Jacobi identity, the color factor of any +# such amplitude can be written on the (n-2)! "half-ladder" (multi-peripheral) +# structures +# F(sigma) = f(1,sigma(2),x1) f(x1,sigma(3),x2) ... f(x(n-3),sigma(n-1),n) +# where legs 1 and n are kept fixed at the two ends of the ladder. This is the +# Del Duca-Dixon-Maltoni basis. Using it divides the number of JAMPs by (n-1) +# and the size of the color matrix by (n-1)^2. +# +# Module level switch selecting the color basis used for fully adjoint +# processes. Set through 'set color_basis' in the MG5 interface (the exporters +# which need a color flow decomposition, i.e. anything writing leshouche +# information, must keep the trace basis). +ddm_basis = False +# Whether the trace basis must be built next to the DDM one. Needed by the +# output formats which have to assign a color flow to an event: the color sum +# then runs over the (n-2)! DDM structures while the color flow probabilities +# keep using the (n-1)! trace ones, obtained from the DDM JAMPs through the +# Kleiss-Kuijf relations. +ddm_flow_basis = False + + +def set_ddm_basis(value, with_flow=False): + """Set the module wide switch selecting the DDM color basis.""" + + global ddm_basis, ddm_flow_basis + ddm_basis = bool(value) + ddm_flow_basis = ddm_basis and bool(with_flow) + + +class DDMError(Exception): + """Raised when a color string cannot be mapped onto the DDM basis. Always + caught by ColorBasis.build, which then falls back on the trace basis.""" + + +def ddm_half_ladder(perm, first, last): + """Return the immutable color string of the DDM half-ladder structure + f(first,perm[0],-1) f(-1,perm[1],-2) ... f(-(m-1),perm[m-1],last) + for the ordered tuple perm of the (n-2) legs sitting between the two fixed + ends first and last.""" + + if len(perm) == 1: + col_objs = [color_algebra.f(first, perm[0], last)] + else: + col_objs = [color_algebra.f(first, perm[0], -1)] + col_objs.extend([color_algebra.f(-(i + 1), leg, -(i + 2)) \ + for i, leg in enumerate(perm[1:-1])]) + col_objs.append(color_algebra.f(-(len(perm) - 1), perm[-1], last)) + + return color_algebra.ColorString(col_objs).to_immutable() + + +def _reorder_sign(stored, wanted): + """Signature of the permutation bringing the three indices of an f object + from the order 'stored' to the order 'wanted'. f is totally antisymmetric, + so f(stored) = _reorder_sign(stored,wanted) * f(wanted).""" + + perm = [stored.index(index) for index in wanted] + sign = 1 + for i in range(len(perm)): + for j in range(i + 1, len(perm)): + if perm[i] > perm[j]: + sign = -sign + + return sign + + +class _ColorTree(object): + """A product of f objects seen as a tree: the f's are the nodes, the summed + (negative) indices the internal edges and the external (positive) indices + the leaves. Provides the reduction onto the DDM half-ladder basis.""" + + def __init__(self, col_str): + """Build the tree from a ColorString made of f objects only. Raise + DDMError as soon as the string is not a fully adjoint color tree.""" + + self.nodes = [] + for col_obj in col_str: + if col_obj.__class__.__name__ == 'ColorOne': + continue + if type(col_obj) is not color_algebra.f: + raise DDMError("%s is not an f object" % str(col_obj)) + self.nodes.append(tuple(col_obj)) + + if not self.nodes: + raise DDMError("empty color string") + + # Locate each index. External indices must appear once, summed ones + # exactly twice and in two different nodes. + self.where = collections.defaultdict(list) + for i, node in enumerate(self.nodes): + if node[0] == node[1] or node[1] == node[2] or node[0] == node[2]: + raise DDMError("f object %s has repeated indices" % str(node)) + for index in node: + self.where[index].append(i) + + self.externals = [] + nb_internal = 0 + for index, nodes in self.where.items(): + if index > 0: + if len(nodes) != 1: + raise DDMError("external index %i appears %i times" % \ + (index, len(nodes))) + self.externals.append(index) + else: + if len(nodes) != 2: + raise DDMError("summed index %i appears %i times" % \ + (index, len(nodes))) + nb_internal += 1 + + # A connected graph with V nodes and V-1 edges is a tree + if nb_internal != len(self.nodes) - 1: + raise DDMError("color structure is not a tree") + + def _neighbour(self, index, node): + """The node sharing the summed index 'index' with node 'node'.""" + + first, second = self.where[index] + return second if first == node else first + + def _spine(self, first, last): + """The list of nodes on the path going from the leaf 'first' to the + leaf 'last'.""" + + if first not in self.externals or last not in self.externals: + raise DDMError("legs %s and %s are not both external here" % \ + (first, last)) + start = self.where[first][0] + end = self.where[last][0] + + # Depth first search on the node tree, keeping track of the path + stack = [(start, None, [start])] + while stack: + node, from_index, path = stack.pop() + if node == end: + return path + for index in self.nodes[node]: + if index > 0 or index == from_index: + continue + stack.append((self._neighbour(index, node), index, + path + [self._neighbour(index, node)])) + + raise DDMError("color structure is not connected") + + def _subtree(self, index, from_node): + """Expansion of the adjoint matrix associated to the subtree hanging on + the edge 'index' of node 'from_node', as a list of + (sign, ordered tuple of legs). A single leaf gives the generator + itself, and a node with two children A and B gives the commutator + [M_B, M_A] (which is where the (n-2)! counting comes from).""" + + if index > 0: + return [(1, (index,))] + + node_i = self._neighbour(index, from_node) + node = self.nodes[node_i] + children = list(node) + children.remove(index) + alpha, beta = children + sign = _reorder_sign(node, (index, alpha, beta)) + + exp_a = self._subtree(alpha, node_i) + exp_b = self._subtree(beta, node_i) + + result = [] + for (sa, wa), (sb, wb) in itertools.product(exp_a, exp_b): + result.append((sign * sa * sb, wb + wa)) + result.append((-sign * sa * sb, wa + wb)) + + return result + + def reduce_to_ddm(self, first, last): + """Decompose the tree onto the DDM half-ladder basis with the legs + 'first' and 'last' at the two ends. Returns {ordered legs: coefficient} + where the keys are the (n-2) other external legs in ladder order.""" + + spine = self._spine(first, last) + + # Split each node of the spine into (incoming, hanging, outgoing) + global_sign = 1 + hanging = [] + for pos, node_i in enumerate(spine): + node = self.nodes[node_i] + if pos == 0: + in_index = first + else: + in_index = [i for i in node if i in self.nodes[spine[pos - 1]]][0] + if pos == len(spine) - 1: + out_index = last + else: + out_index = [i for i in node if i in self.nodes[spine[pos + 1]]][0] + off_index = [i for i in node if i not in (in_index, out_index)][0] + global_sign *= _reorder_sign(node, (in_index, off_index, out_index)) + hanging.append(self._subtree(off_index, node_i)) + + # The whole structure is the matrix product M_m ... M_1 between the + # ends, and (M_i1 ... M_ik) contracted between 'last' and 'first' is the + # half-ladder with the legs in the reversed order. + result = collections.defaultdict(int) + for combination in itertools.product(*reversed(hanging)): + sign = global_sign + word = [] + for term_sign, term_word in combination: + sign *= term_sign + word.extend(term_word) + result[tuple(reversed(word))] += sign + + return dict((perm, coeff) for perm, coeff in result.items() if coeff) + + +def reduce_to_ddm(col_str, first, last): + """Decompose the ColorString col_str (a product of f objects) onto the DDM + half-ladder basis, returning a ColorFactor whose strings are the basis + elements. Raise DDMError if col_str is not a fully adjoint color tree.""" + + decomposition = _ColorTree(col_str).reduce_to_ddm(first, last) + + col_fact = color_algebra.ColorFactor() + for perm, coeff in decomposition.items(): + new_str = color_algebra.ColorString() + new_str.from_immutable(ddm_half_ladder(perm, first, last)) + new_str.coeff = col_str.coeff * coeff + new_str.is_imaginary = col_str.is_imaginary + new_str.Nc_power = col_str.Nc_power + new_str.loop_Nc_power = col_str.loop_Nc_power + col_fact.append(new_str) + + return col_fact + + #=============================================================================== # ColorBasis #=============================================================================== @@ -53,6 +292,22 @@ class ColorBasis(dict): # Dictionary store the raw colorize information _list_color_dict = [] + # Whether relabel_canonical may take its shortcut, per canonical form + _fast_relabel_dict = {} + + # Color objects whose canonical form is fully determined by + # permute_immutable (Tr is cyclic, T is an open chain, ColorOne is empty). + fast_relabel_objects = frozenset(['Tr', 'T', 'ColorOne']) + + # Legs at the two ends of the DDM half-ladders (None for the trace basis) + _ddm_ends = None + + # Trace basis built next to a DDM one, carrying the color flows + _flow_basis = None + + # Dictionary to save the DDM decompositions already done + _ddm_dict = {} + class ColorBasisError(Exception): """Exception raised if an error occurs in the definition @@ -197,7 +452,7 @@ def add_vertex(self, vertex, diagram, model, new_res_dict = {} for i, col_str in \ enumerate(inter_color): - + # Ignore color string if it doesn't correspond to any coupling if i not in inter_indices: continue @@ -237,8 +492,188 @@ def add_vertex(self, vertex, diagram, model, return (min_index, new_res_dict) + def _fast_relabel_possible(self, col_fact): + """The shortcut is only attempted on color factors made of objects + whose canonical form is known, and whose indices are all distinct + within a string so that no contraction identity can fire.""" + + for col_str in col_fact: + indices = [] + for name, idx in col_str.to_immutable(): + if name not in self.fast_relabel_objects: + return False + indices.extend(idx) + if len(indices) != len(dict.fromkeys(indices)): + return False + return True + + @staticmethod + def _canonicalize_strings(col_fact): + """Put every color string of col_fact back in canonical form in place + and drop the vanishing ones, mirroring what ColorFactor.simplify does + for an expression which is already simplified.""" + + for col_str in col_fact: + immutable = col_str.to_immutable() + canonical = permute_immutable(immutable, {}) + if canonical != immutable: + col_str.from_immutable(canonical) + col_str.immutable = None + col_str.canonical = None + return color_algebra.ColorFactor([col_str for col_str in col_fact \ + if col_str.coeff != 0]) + + def relabel_canonical(self, col_fact, canonical_rep): + """Return col_fact, which is an already simplified color factor with + relabelled indices, put back in canonical form. Equivalent to + col_fact.simplify().simplify(); which of the two is used is decided + once per canonical representation by running both and comparing.""" + + verdict = self._fast_relabel_dict.get(canonical_rep) + if verdict is True: + return self._canonicalize_strings(col_fact) + if verdict is False: + return col_fact.simplify().simplify() + + # First time this color structure is recycled: check the shortcut + # against the full simplification before trusting it. + slow = col_fact.create_copy().simplify().simplify() + if not self._fast_relabel_possible(col_fact): + self._fast_relabel_dict[canonical_rep] = False + return slow + fast = self._canonicalize_strings(col_fact) + verdict = len(fast) == len(slow) and \ + all(f.to_immutable() == s.to_immutable() and + f.coeff == s.coeff and + f.is_imaginary == s.is_imaginary and + f.Nc_power == s.Nc_power and + f.loop_Nc_power == s.loop_Nc_power + for f, s in zip(fast, slow)) + self._fast_relabel_dict[canonical_rep] = verdict + return fast if verdict else slow + + def get_ddm_ends(self): + """If every color structure of this basis is a fully adjoint color tree + over one and the same set of external legs -- i.e. if the process is a + pure multi-gluon one -- return the two legs to be put at the ends of the + DDM half-ladders. Return None otherwise.""" + + legs = None + for colorize_dict in self._list_color_dict: + for col_str in colorize_dict.values(): + externals = [] + for col_obj in col_str: + if col_obj.__class__.__name__ == 'ColorOne': + continue + if type(col_obj) is not color_algebra.f: + return None + externals.extend([i for i in col_obj if i > 0]) + if len(externals) != len(set(externals)): + return None + externals = sorted(externals) + if legs is None: + legs = externals + if len(legs) < 3: + return None + elif legs != externals: + return None + + if legs is None: + return None + + return (legs[0], legs[-1]) + + def update_color_basis_ddm(self, colorize_dict, index): + """Same as update_color_basis, but decomposing the color structures on + the (n-2)! DDM half-ladder basis instead of the (n-1)! trace one.""" + + first, last = self._ddm_ends + + for col_chain, col_str in colorize_dict.items(): + # The decomposition only depends on the tree structure, so + # normalize the summed indices to make the cache hit as often as + # possible. + repl_dict = {} + for col_obj in col_str: + for i in col_obj: + if i < 0 and i not in repl_dict: + repl_dict[i] = -len(repl_dict) - 1 + canonical_str = col_str.create_copy() + canonical_str.replace_indices(repl_dict) + canonical_rep = canonical_str.to_immutable() + + try: + decomposition = self._ddm_dict[canonical_rep] + except KeyError: + decomposition = _ColorTree(canonical_str).reduce_to_ddm(first, + last) + self._ddm_dict[canonical_rep] = decomposition + + for perm, coeff in decomposition.items(): + basis_entry = (index, + col_chain, + col_str.coeff * coeff, + col_str.is_imaginary, + col_str.Nc_power, + col_str.loop_Nc_power) + immutable_col_str = ddm_half_ladder(perm, first, last) + try: + self[immutable_col_str].append(basis_entry) + except KeyError: + self[immutable_col_str] = [basis_entry] + + def build_flow_basis(self): + """Build, next to the DDM basis, the trace basis which is the one + carrying the color flow information. Only the basis is built, not its + (n-1)!^2 color matrix, since the color sum stays in the DDM basis.""" + + flow_basis = ColorBasis() + flow_basis._list_color_dict = self._list_color_dict + for index, color_dict in enumerate(self._list_color_dict): + flow_basis.update_color_basis(color_dict, index) + + self._flow_basis = flow_basis + + def get_flow_basis(self): + """The color basis carrying the color flow information: the trace basis + built next to the DDM one, or simply self for a trace basis.""" + + return self._flow_basis if self._flow_basis else self + + def get_flow_projection(self): + """Return the Kleiss-Kuijf relations giving each trace JAMP as a linear + combination of the DDM ones, i.e. the coefficients of the expansion of + the half-ladders on the trace basis, transposed. The format is the one + of get_color_amplitudes, so that the same writers can be used: a list + (one entry per element of the flow basis) of + ((1, coefficient, is_imaginary, Nc power), DDM basis index+1).""" + + if not self._flow_basis: + raise ColorBasis.ColorBasisError( + "No flow basis attached to this color basis") + + flow_index = dict((struct, i) for i, struct in \ + enumerate(sorted(self._flow_basis.keys()))) + projection = [[] for i in range(len(flow_index))] + + for i, struct in enumerate(sorted(self.keys())): + col_str = color_algebra.ColorString() + col_str.from_immutable(struct) + for cs in color_algebra.ColorFactor([col_str]).full_simplify(): + try: + row = flow_index[cs.to_immutable()] + except KeyError: + raise ColorBasis.ColorBasisError( + "The half-ladder %s expands on the trace structure %s " + "which is not part of the flow basis" % \ + (str(col_str), str(cs))) + projection[row].append(((1, cs.coeff, cs.is_imaginary, + cs.Nc_power), i + 1)) + + return projection + def update_color_basis(self, colorize_dict, index): - """Update the current color basis by adding information from + """Update the current color basis by adding information from the colorize dictionary (produced by the colorize routine) associated to diagram with index index. Keep track of simplification results for maximal optimization.""" @@ -283,8 +718,18 @@ def update_color_basis(self, colorize_dict, index): # can appear with a loop) to put traces in a canonical ordering. # If it still causes issue, just do a full_simplify(), it would # not bring any heavy additional computational load. - col_fact = col_fact.simplify().simplify() - + # + # What is recycled here is an already simplified color factor + # to which nothing but a relabelling of the indices has been + # applied. A relabelled simplified expression is still + # simplified, so this only has to put every color string back + # in canonical form, which relabel_canonical does directly + # instead of running the full simplification machinery over + # every term. The equivalence of the two is checked once per + # canonical representation, and the slow path is kept for any + # color structure where it does not hold. + col_fact = self.relabel_canonical(col_fact, canonical_rep) + # Here we need to force a specific order for the summed indices # in case we have K6 or K6bar Clebsch Gordan coefficients for colstr in col_fact: colstr.order_summation() @@ -322,17 +767,33 @@ def create_color_dict_list(self, amplitude): def build(self, amplitude=None): """Build the a color basis object using information contained in - amplitude (otherwise use info from _list_color_dict). + amplitude (otherwise use info from _list_color_dict). Returns a list of color """ if amplitude: self.create_color_dict_list(amplitude) + + if ddm_basis: + self._ddm_ends = self.get_ddm_ends() + if self._ddm_ends: + try: + for index, color_dict in enumerate(self._list_color_dict): + self.update_color_basis_ddm(color_dict, index) + if ddm_flow_basis: + self.build_flow_basis() + return + except DDMError as error: + logger.debug('Falling back on the trace color basis: %s', error) + self.clear() + self._ddm_ends = None + self._flow_basis = None + for index, color_dict in enumerate(self._list_color_dict): self.update_color_basis(color_dict, index) def __init__(self, *args): """Initialize a new color basis object, either empty or filled (0 - or 1 arguments). If one arguments is given, it's interpreted as + or 1 arguments). If one arguments is given, it's interpreted as an amplitude.""" assert len(args) < 2, "Object ColorBasis must be initialized with 0 or 1 arguments" @@ -346,6 +807,19 @@ def __init__(self, *args): # Dictionary store the raw colorize information self._list_color_dict = [] + # Whether relabel_canonical may take its shortcut, per canonical form + self._fast_relabel_dict = {} + + # Legs at the two ends of the DDM half-ladders, None when the basis is + # the standard trace one + self._ddm_ends = None + + # Trace basis built next to a DDM one, carrying the color flows + self._flow_basis = None + + # Dictionary to save the DDM decompositions already done + self._ddm_dict = {} + if args: assert isinstance(args[0], diagram_generation.Amplitude), \ @@ -448,6 +922,11 @@ def color_flow_decomposition(self, repr_dict, ninitial): here (an error is raised). Needs a dictionary with keys being external leg numbers, and value the corresponding color representation.""" + if self._ddm_ends: + raise ColorBasis.ColorBasisError( + "A DDM color basis has no single color flow per basis element." + " Use 'set color_basis trace' for this output format.") + # Offsets used to introduce fake quark indices for gluons offset1 = 1000 offset2 = 2000 @@ -529,113 +1008,698 @@ def color_flow_decomposition(self, repr_dict, ninitial): return res +#=============================================================================== +# Permutation symmetry of a color basis +#=============================================================================== +def permute_immutable(struct, perm): + """Apply the index permutation perm (a dict {old_index: new_index}) to the + immutable representation of a color structure, and bring the result back to + the canonical form used as a ColorBasis key: traces are cyclic, so they are + rotated to start on their smallest index, and the color objects are sorted + exactly as ColorString.to_immutable does.""" + + res = [] + for name, indices in struct: + new_indices = tuple([perm.get(i, i) for i in indices]) + if name == 'Tr' and len(new_indices) > 1: + # Tr is cyclic: rotate so that the smallest index comes first + start = min(range(len(new_indices)), key=new_indices.__getitem__) + new_indices = new_indices[start:] + new_indices[:start] + res.append((name, new_indices)) + res.sort() + return tuple(res) + + +def reverse_immutable(struct): + """Reverse every color object of an immutable color basis key, bringing the + result back to the canonical form. A trace is cyclic, so it is rotated onto + its smallest index afterwards. Returns None for anything which is not built + of traces alone, which is where the reversal has a meaning of its own.""" + + res = [] + for name, indices in struct: + if name != 'Tr': + return None + indices = tuple(reversed(indices)) + if len(indices) > 1: + start = min(range(len(indices)), key=indices.__getitem__) + indices = indices[start:] + indices[:start] + res.append((name, indices)) + res.sort() + return tuple(res) + + +class ColorBasisSymmetry(object): + """Permutations of the external color indices which map a color basis (or a + pair of color bases, for an asymmetric color matrix) onto itself. + + A color matrix entry is the full contraction of two color structures, so it + depends only on the *relative* labelling of the indices: relabelling the + indices consistently in both structures leaves the entry unchanged. Hence + for any such permutation P, + + C[P(i)][P(j)] = C[i][j] + + and only one row per orbit of P-action on the basis has to be computed. + + Note that the permutations found here are not required to be physical + permutations of identical particles: any index relabelling that maps the + basis onto itself is a symmetry of the color matrix. For g g > n g this + finds the full S_(n+2) rather than only the S_n of the final state, which + collapses the whole matrix to a single row.""" + + # Indices above this value are summed indices introduced internally + # (order_summation starts at 10000, colorize uses values below -1000); + # only genuine external indices are permuted. + max_external_index = 1000 + + def __init__(self, keys1, keys2=None): + """keys1/keys2 are the *sorted* lists of color basis keys, i.e. exactly + the ordering used to index the color matrix.""" + + self.keys1 = keys1 + self.keys2 = keys2 if keys2 is not None else keys1 + # permutation of the basis indices induced by each accepted generator + self.generators1 = [] + self.generators2 = [] + # representative of the orbit each row belongs to, and how to get there + # in one step: (parent row, index of the generator mapping it to here) + self.row_rep = list(range(len(keys1))) + self.row_parent = [None] * len(keys1) + self.representatives = list(range(len(keys1))) + + if not keys1 or not self.keys2: + return + + self._find_generators() + self._build_orbits() + + def _external_indices(self, keys): + """Return the sorted list of indices which may be permuted. A plain + list is used rather than a set since 'set' is shadowed by an ordered + variant in this module when reproducible ordering is requested.""" + + indices = {} + for struct in keys: + for _, idx in struct: + for i in idx: + if 0 < i < self.max_external_index: + indices[i] = True + return sorted(indices) + + def _index_signature(self, keys): + """Group indices by the way they appear in the basis: two indices can + only be exchanged if they occupy the same kind of slots. This is only + used to avoid testing hopeless candidates; every candidate is verified + explicitly afterwards.""" + + sig = collections.defaultdict(collections.Counter) + for struct in keys: + for name, idx in struct: + for pos, i in enumerate(idx): + sig[i][(name, len(idx), pos)] += 1 + return sig + + def _find_generators(self): + """Find transpositions of external indices mapping every basis onto + itself, and store the induced permutation of the basis indices.""" + + candidates = self._external_indices(self.keys1) + if self.keys2 is not self.keys1: + other = dict((i, True) for i in self._external_indices(self.keys2)) + candidates = [i for i in candidates if i in other] + if len(candidates) < 2: + return + + sig1 = self._index_signature(self.keys1) + sig2 = self._index_signature(self.keys2) \ + if self.keys2 is not self.keys1 else sig1 + + pos1 = dict((k, i) for i, k in enumerate(self.keys1)) + pos2 = pos1 if self.keys2 is self.keys1 else \ + dict((k, i) for i, k in enumerate(self.keys2)) + + for a, b in itertools.combinations(candidates, 2): + if sig1[a] != sig1[b] or sig2[a] != sig2[b]: + continue + perm = {a: b, b: a} + induced1 = self._induced_permutation(self.keys1, pos1, perm) + if induced1 is None: + continue + if self.keys2 is self.keys1: + induced2 = induced1 + else: + induced2 = self._induced_permutation(self.keys2, pos2, perm) + if induced2 is None: + continue + # A transposition is its own inverse, and so is the permutation it + # induces on the basis. Rows are gathered from their parent with + # the generator itself rather than with its inverse, so make sure + # of it instead of assuming it. + if any(induced1[induced1[i]] != i for i in range(len(induced1))) or \ + any(induced2[induced2[i]] != i for i in range(len(induced2))): + continue + self.generators1.append(induced1) + self.generators2.append(induced2) + + @staticmethod + def _induced_permutation(keys, positions, perm): + """Return the permutation of the basis indices induced by the index + permutation perm, or None if the basis is not mapped onto itself.""" + + induced = [0] * len(keys) + seen = [False] * len(keys) + for i, struct in enumerate(keys): + try: + j = positions[permute_immutable(struct, perm)] + except KeyError: + return None + if seen[j]: + return None + seen[j] = True + induced[i] = j + return induced + + def _build_orbits(self): + """Breadth-first exploration of each orbit, recording for every row the + representative it comes from and the generator that reaches it from its + parent, so that the row can be obtained by a single gather.""" + + if not self.generators1: + return + + n = len(self.keys1) + self.row_rep = [-1] * n + self.representatives = [] + for start in range(n): + if self.row_rep[start] != -1: + continue + self.representatives.append(start) + self.row_rep[start] = start + self.row_parent[start] = None + queue = collections.deque([start]) + while queue: + current = queue.popleft() + for gen_index, induced in enumerate(self.generators1): + image = induced[current] + if self.row_rep[image] == -1: + self.row_rep[image] = start + self.row_parent[image] = (current, gen_index) + queue.append(image) + + def has_symmetry(self): + """True if the symmetry actually reduces the number of rows.""" + + return bool(self.generators1) and \ + len(self.representatives) < len(self.keys1) + + def spanning_generators(self): + """Indices of a subset of the generators which still reaches every + line of every orbit. Anything which writes the permutations out has to + store one array of basis indices per generator, so dropping those which + connect nothing new makes a large difference.""" + + parent = list(range(len(self.keys1))) + + def find(x): + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + kept = [] + for index, induced in enumerate(self.generators1): + used = False + for i, j in enumerate(induced): + ri, rj = find(i), find(j) + if ri != rj: + parent[ri] = rj + used = True + if used: + kept.append(index) + return kept + + def spanning_tree(self, gen_indices=None): + """Describe every line as one generator applied to another line: + returns the orbit representatives, the representative of each line, the + (parent line, position in gen_indices) pair reaching each line, and the + generators actually used. Following the parents back to the + representative gives the permutation relating the two lines.""" + + if gen_indices is None: + gen_indices = self.spanning_generators() + gens = [self.generators1[i] for i in gen_indices] + + n = len(self.keys1) + representative = [-1] * n + parent = [None] * n + representatives = [] + for start in range(n): + if representative[start] != -1: + continue + representatives.append(start) + representative[start] = start + queue = collections.deque([start]) + while queue: + current = queue.popleft() + for local, induced in enumerate(gens): + image = induced[current] + if representative[image] == -1: + representative[image] = start + parent[image] = (current, local) + queue.append(image) + return representatives, representative, parent, gens #=============================================================================== # ColorMatrix #=============================================================================== +class _ColorMatrixView(object): + """Read-only mapping presenting one of the two representations stored by a + ColorMatrix (the ColorFactor one, or the fixed Nc one) as the dictionary + keyed by (i1, i2) that it used to be.""" + + def __init__(self, matrix, entry): + self._matrix = matrix + self._entry = entry + + def __getitem__(self, key): + return self._matrix._get_entry(key)[self._entry] + + def __len__(self): + return len(self._matrix) + + def __contains__(self, key): + try: + self[key] + except (KeyError, IndexError, TypeError): + return False + return True + + def __iter__(self): + return iter(self._matrix) + + def keys(self): + return list(self) + + def values(self): + return [self[key] for key in self] + + def items(self): + return [(key, self[key]) for key in self] + + def get(self, key, default=None): + try: + return self[key] + except (KeyError, IndexError, TypeError): + return default + + def __eq__(self, other): + if isinstance(other, _ColorMatrixView): + if len(self) != len(other): + return False + return all(self[key] == other[key] for key in self) + if isinstance(other, dict): + return dict(self.items()) == other + return NotImplemented + + def __ne__(self, other): + result = self.__eq__(other) + return result if result is NotImplemented else not result + + class ColorMatrix(dict): - """A color matrix, meaning a dictionary with pairs (i,j) as keys where i + """A color matrix, meaning a mapping with pairs (i,j) as keys where i and j refer to elements of color basis objects. Values are Color Factor - objects. Also contains two additional dictionaries, one with the fixed Nc - representation of the matrix, and the other one with the "inverted" matrix, - i.e. a dictionary where keys are values of the color matrix.""" + objects. The fixed Nc representation is available through the + col_matrix_fixed_Nc attribute. + + The matrix is not stored entry by entry. A color matrix entry is the full + contraction of two color structures and therefore only depends on the + relative labelling of the color indices, so entries repeat massively: the + distinct values are stored once and the (i,j) grid only keeps an index into + them. On top of that, index permutations mapping the color basis onto + itself (see ColorBasisSymmetry) relate whole rows to each other, so only + one row per orbit is actually computed; the others are a gather away.""" _col_basis1 = None _col_basis2 = None col_matrix_fixed_Nc = {} - inverted_col_matrix = {} + _ddm_expansions = None def __init__(self, col_basis, col_basis2=None, Nc=3, Nc_power_min=None, Nc_power_max=None): """Initialize a color matrix with one or two color basis objects. If only one color basis is given, the other one is assumed to be equal. - As options, any value of Nc and minimal/maximal power of Nc can also be + As options, any value of Nc and minimal/maximal power of Nc can also be provided. Note that the min/max power constraint is applied only at the end, so that it does NOT speed up the calculation.""" - self.col_matrix_fixed_Nc = {} - self.inverted_col_matrix = {} - + # Distinct entries, as (result, result_fixed_Nc) pairs, and the (i1,i2) + # grid of indices into that list, stored row-major in a compact array. + self._values = [] + self._val_index = array.array('i') + self._sorted_keys1 = [] + self._sorted_keys2 = [] + # Set by setup_ddm_entries for a DDM (half-ladder) color basis + self._ddm_expansions = None + self.col_matrix_fixed_Nc = _ColorMatrixView(self, 1) + self._col_basis1 = col_basis if col_basis2: self._col_basis2 = col_basis2 self.build_matrix(Nc, Nc_power_min, Nc_power_max) else: self._col_basis2 = col_basis - # If the two color basis are equal, assumes the color matrix is + # If the two color basis are equal, assumes the color matrix is # symmetric self.build_matrix(Nc, Nc_power_min, Nc_power_max, is_symmetric=True) + #=========================================================================== + # Mapping interface + #=========================================================================== + def _get_entry(self, key): + """Return the (result, result_fixed_Nc) pair for the (i1, i2) key.""" + + i1, i2 = key + n1, n2 = len(self._sorted_keys1), len(self._sorted_keys2) + if not 0 <= i1 < n1 or not 0 <= i2 < n2: + raise KeyError(key) + return self._values[self._val_index[i1 * n2 + i2]] + + def __getitem__(self, key): + return self._get_entry(key)[0] + + def __len__(self): + return len(self._sorted_keys1) * len(self._sorted_keys2) + + def __bool__(self): + return bool(self._sorted_keys1) and bool(self._sorted_keys2) + + __nonzero__ = __bool__ + + def __contains__(self, key): + try: + self._get_entry(key) + except (KeyError, IndexError, TypeError): + return False + return True + + def __iter__(self): + for i1 in range(len(self._sorted_keys1)): + for i2 in range(len(self._sorted_keys2)): + yield (i1, i2) + + def keys(self): + return list(self) + + def values(self): + return [self[key] for key in self] + + def items(self): + return [(key, self[key]) for key in self] + + def get(self, key, default=None): + try: + return self[key] + except (KeyError, IndexError, TypeError): + return default + + def __eq__(self, other): + if isinstance(other, ColorMatrix): + if self._sorted_keys1 != other._sorted_keys1 or \ + self._sorted_keys2 != other._sorted_keys2: + return False + return all(self._get_entry(key) == other._get_entry(key) + for key in self) + if isinstance(other, dict): + return dict(self.items()) == other + return NotImplemented + + def __ne__(self, other): + result = self.__eq__(other) + return result if result is NotImplemented else not result + + __hash__ = None + + @property + def inverted_col_matrix(self): + """Dictionary mapping each fixed Nc value to the list of (i1,i2) it + appears at. Kept for backward compatibility, built on demand.""" + + inverted = {} + for key in self: + inverted.setdefault(self._get_entry(key)[1], []).append(key) + return inverted + + #=========================================================================== + # Construction + #=========================================================================== + def _value_index(self, struct1, struct2, canonical_dict, + Nc, Nc_power_min, Nc_power_max): + """Return the index in self._values of the entry for the two given + color structures, computing it if it is seen for the first time.""" + + # Fix indices in struct2 knowing summed indices in struct1 + # to avoid duplicates + new_struct2 = self.fix_summed_indices(struct1, struct2) + + # Build a canonical representation of the two immutable struct + canonical_entry, dummy = \ + color_algebra.ColorString().to_canonical(struct1 + \ + new_struct2) + + try: + # If this has already been calculated, use the result + return canonical_dict[canonical_entry] + except KeyError: + pass + + # Otherwise calculate the result + result, result_fixed_Nc = self.create_new_entry(struct1, + new_struct2, + Nc_power_min, + Nc_power_max, + Nc) + index = len(self._values) + self._values.append((result, result_fixed_Nc)) + canonical_dict[canonical_entry] = index + return index + def build_matrix(self, Nc=3, Nc_power_min=None, Nc_power_max=None, is_symmetric=False): """Create the matrix using internal color basis objects. Use the stored color basis objects and takes Nc and Nc_min/max parameters as __init__. - If is_isymmetric is True, build only half of the matrix which is assumed - to be symmetric.""" + If is_symmetric is True, the matrix is assumed to be symmetric so that + only half of it needs to be computed.""" + + self._sorted_keys1 = sorted(self._col_basis1.keys()) + if self._col_basis2 is self._col_basis1: + self._sorted_keys2 = self._sorted_keys1 + else: + self._sorted_keys2 = sorted(self._col_basis2.keys()) + + keys1, keys2 = self._sorted_keys1, self._sorted_keys2 + n1, n2 = len(keys1), len(keys2) + self._values = [] + self._val_index = array.array('i', [0]) * (n1 * n2) if n1 * n2 else \ + array.array('i') + if not n1 or not n2: + return + + if getattr(self._col_basis1, '_ddm_ends', None) and \ + getattr(self._col_basis2, '_ddm_ends', None): + self.setup_ddm_entries() canonical_dict = {} - - for i1, struct1 in \ - enumerate(sorted(self._col_basis1.keys())): - for i2, struct2 in \ - enumerate(sorted(self._col_basis2.keys())): - # Only scan upper right triangle if symmetric - if is_symmetric and i2 < i1: + symmetry = ColorBasisSymmetry(keys1, + None if keys2 is keys1 else keys2) + + if not symmetry.has_symmetry(): + # No index permutation maps the basis onto itself: fall back to the + # plain scan, using the symmetry of the matrix itself if available. + for i1, struct1 in enumerate(keys1): + for i2, struct2 in enumerate(keys2): + if is_symmetric and i2 < i1: + continue + index = self._value_index(struct1, struct2, canonical_dict, + Nc, Nc_power_min, Nc_power_max) + self._val_index[i1 * n2 + i2] = index + if is_symmetric: + self._val_index[i2 * n2 + i1] = index + return + + # One row per orbit is computed explicitly; every other row is the + # image of an already known one under a single generator. + done = [False] * n1 + for rep in symmetry.representatives: + struct1 = keys1[rep] + offset = rep * n2 + for i2, struct2 in enumerate(keys2): + self._val_index[offset + i2] = \ + self._value_index(struct1, struct2, canonical_dict, + Nc, Nc_power_min, Nc_power_max) + done[rep] = True + + # Breadth-first replay of the orbit exploration: a row whose parent is + # already filled is obtained by permuting the parent's columns. + remaining = [i for i in range(n1) if not done[i]] + while remaining: + progressed = False + still_missing = [] + for row in remaining: + parent, gen_index = symmetry.row_parent[row] + if not done[parent]: + still_missing.append(row) continue + induced2 = symmetry.generators2[gen_index] + src = parent * n2 + dest = row * n2 + val_index = self._val_index + for i2 in range(n2): + val_index[dest + i2] = val_index[src + induced2[i2]] + done[row] = True + progressed = True + assert progressed, "Color matrix orbit exploration made no progress" + remaining = still_missing + + def setup_ddm_entries(self): + """Switch create_new_entry over to the assembly used for a DDM + (half-ladder) color basis. + + Contracting two half-ladders head on is exponentially expensive, since + the simplification rules turn every one of the 2(n-2) f objects into a + pair of traces. Each ladder is instead expanded once on the trace basis + (2^(n-2) traces) and the entry is assembled from trace-trace products, + which are recycled between all the entries. Everything else, including + the orbit symmetry of the basis, is left to build_matrix.""" + + self._ddm_expansions = {} + self._ddm_half_dict = {} + self._ddm_trace_dict = {} + + def get_ddm_trace_expansion(self, struct): + """Expansion of one half-ladder on the trace basis, as a list of + (immutable trace, coefficient, is_imaginary, Nc power).""" + + try: + return self._ddm_expansions[struct] + except KeyError: + pass - # Fix indices in struct2 knowing summed indices in struct1 - # to avoid duplicates - new_struct2 = self.fix_summed_indices(struct1, struct2) + col_str = color_algebra.ColorString() + col_str.from_immutable(struct) + expansion = [(cs.to_immutable(), cs.coeff, cs.is_imaginary, + cs.Nc_power) for cs in \ + color_algebra.ColorFactor([col_str]).full_simplify()] + + self._ddm_expansions[struct] = expansion + return expansion + + def create_new_entry_ddm(self, struct1, struct2, + Nc_power_min, Nc_power_max, Nc): + """create_new_entry for two half-ladders, through their trace + expansions.""" + + contraction = collections.defaultdict(fractions.Fraction) + for trace, coeff, is_imaginary, Nc_power in \ + self.get_ddm_trace_expansion(struct1): + self.accumulate_number(contraction, + (coeff, is_imaginary, Nc_power), + self.get_half_ladder_contraction(trace, + struct2)) + + result = color_algebra.ColorFactor() + for (is_imaginary, Nc_power), coeff in contraction.items(): + if not coeff: + continue + if Nc_power_min is not None and Nc_power < Nc_power_min: + continue + if Nc_power_max is not None and Nc_power > Nc_power_max: + continue + result.append(color_algebra.ColorString([], coeff, is_imaginary, + Nc_power)) - # Build a canonical representation of the two immutable struct - canonical_entry, dummy = \ - color_algebra.ColorString().to_canonical(struct1 + \ - new_struct2) + return result, result.set_Nc(Nc) + + @staticmethod + def accumulate_number(target, factor, numbers): + """Add factor*numbers to target, where a number is a dictionary + {(is_imaginary, Nc power): coefficient} and factor a single + (coefficient, is_imaginary, Nc power) triplet.""" + + coeff, is_imaginary, Nc_power = factor + for (other_imaginary, other_power), other_coeff in numbers.items(): + new_coeff = coeff * other_coeff + if is_imaginary and other_imaginary: + new_coeff = -new_coeff + new_imaginary = False + else: + new_imaginary = is_imaginary or other_imaginary + target[(new_imaginary, Nc_power + other_power)] += new_coeff + + def get_half_ladder_contraction(self, trace, struct2): + """Contraction of the single trace \'trace\' with the complex conjugate + of the half-ladder \'struct2\'.""" + + canonical_rep, dummy = \ + color_algebra.ColorString().to_canonical(trace + struct2) + try: + return self._ddm_half_dict[canonical_rep] + except KeyError: + pass + + result = collections.defaultdict(fractions.Fraction) + for trace2, coeff, is_imaginary, Nc_power in \ + self.get_ddm_trace_expansion(struct2): + # complex conjugation of the coefficient of the second ladder + if is_imaginary: + coeff = -coeff + self.accumulate_number(result, (coeff, is_imaginary, Nc_power), + self.get_trace_contraction(trace, trace2)) + + self._ddm_half_dict[canonical_rep] = result + return result + + def get_trace_contraction(self, trace1, trace2): + """Contraction of two single traces, as a dictionary + {(is_imaginary, Nc power): coefficient}.""" + + canonical_rep, dummy = \ + color_algebra.ColorString().to_canonical(trace1 + trace2) + try: + return self._ddm_trace_dict[canonical_rep] + except KeyError: + pass + + col_str = color_algebra.ColorString() + col_str.from_immutable(trace1) + col_str2 = color_algebra.ColorString() + col_str2.from_immutable(trace2) + col_str.product(col_str2.complex_conjugate()) + + result = collections.defaultdict(fractions.Fraction) + for cs in color_algebra.ColorFactor([col_str]).full_simplify(): + assert not len(cs), \ + "Trace contraction %s did not simplify to a number" % str(cs) + result[(cs.is_imaginary, cs.Nc_power)] += cs.coeff + + self._ddm_trace_dict[canonical_rep] = result + return result - try: - # If this has already been calculated, use the result - result, result_fixed_Nc = canonical_dict[canonical_entry] - except KeyError: - # Otherwise calculate the result - result, result_fixed_Nc = \ - self.create_new_entry(struct1, - new_struct2, - Nc_power_min, - Nc_power_max, - Nc) - # Store both results - canonical_dict[canonical_entry] = (result, result_fixed_Nc) - - # Store the full result... - self[(i1, i2)] = result - if is_symmetric: - self[(i2, i1)] = result - - # the fixed Nc one ... - self.col_matrix_fixed_Nc[(i1, i2)] = result_fixed_Nc - if is_symmetric: - self.col_matrix_fixed_Nc[(i2, i1)] = result_fixed_Nc - # and update the inverted dict - if result_fixed_Nc in list(self.inverted_col_matrix.keys()): - self.inverted_col_matrix[result_fixed_Nc].append((i1, - i2)) - if is_symmetric: - self.inverted_col_matrix[result_fixed_Nc].append((i2, - i1)) - else: - self.inverted_col_matrix[result_fixed_Nc] = [(i1, i2)] - if is_symmetric: - self.inverted_col_matrix[result_fixed_Nc] = [(i2, i1)] def create_new_entry(self, struct1, struct2, Nc_power_min, Nc_power_max, Nc): """ Create a new product result, and result with fixed Nc for two color basis entries. Implement Nc power limits.""" - # Create color string objects corresponding to color basis + if self._ddm_expansions is not None: + return self.create_new_entry_ddm(struct1, struct2, + Nc_power_min, Nc_power_max, Nc) + + # Create color string objects corresponding to color basis # keys col_str = color_algebra.ColorString() col_str.from_immutable(struct1) @@ -690,25 +1754,32 @@ def __str__(self): return mystr + def _fixed_Nc_row(self, line_index): + """Return the fixed Nc entries of one line of the matrix.""" + + n2 = len(self._sorted_keys2) + offset = line_index * n2 + values = self._values + val_index = self._val_index + return [values[val_index[offset + i2]][1] for i2 in range(n2)] + def get_line_denominators(self): """Get a list with the denominators for the different lines in the color matrix""" den_list = [] - for i1 in range(len(self._col_basis1)): - den_list.append(self.lcmm(*[\ - self.col_matrix_fixed_Nc[(i1, i2)][0].denominator for \ - i2 in range(len(self._col_basis2))])) - + for i1 in range(len(self._sorted_keys1)): + den_list.append(self.lcmm(*[entry[0].denominator for entry in \ + self._fixed_Nc_row(i1)])) + return den_list def get_line_numerators(self, line_index, den): """Returns a list of numerator for line line_index, assuming a common denominator den.""" - return [self.col_matrix_fixed_Nc[(line_index, i2)][0].numerator * \ - den / self.col_matrix_fixed_Nc[(line_index, i2)][0].denominator \ - for i2 in range(len(self._col_basis2))] + return [entry[0].numerator * den / entry[0].denominator \ + for entry in self._fixed_Nc_row(line_index)] @classmethod def fix_summed_indices(self, struct1, struct2): diff --git a/madgraph/core/diagram_generation.py b/madgraph/core/diagram_generation.py index 1f239abf5b..110f99e7d0 100755 --- a/madgraph/core/diagram_generation.py +++ b/madgraph/core/diagram_generation.py @@ -24,11 +24,13 @@ import array import copy +import fractions import itertools import logging import madgraph import madgraph.core.base_objects as base_objects +import madgraph.core.color_algebra as color_algebra import madgraph.various.misc as misc import madgraph.fks.fks_tag as fks_tag from madgraph import InvalidCmd, MadGraph5Error @@ -427,6 +429,435 @@ def __str__(self): __repr__ = __str__ +#=============================================================================== +# Unrolling of quartic vertices into pairs of cubic vertices +#=============================================================================== + +class UnrollDiagramTag(DiagramTag): + """DiagramTag which keeps every external leg distinct. + + The default DiagramTag deliberately identifies identical final state + particles, which is what is wanted when comparing different processes. To + recognise a diagram inside its own amplitude we need the leg numbers, so + that e.g. the t- and u-channel of g g > g g do not collide.""" + + @staticmethod + def link_from_leg(leg, model): + return [((leg.get('id'), leg.get('number')), leg.get('number'))] + + +def get_unrollable_quartic_vertices(model): + """Find the quartic interactions which factorise into two cubic ones. + + A quartic vertex qualifies when its four legs carry the same + self-conjugate particle and each of its colour structures is a product of + two structure constants sharing a single summed index -- the four gluon + vertex being the canonical example. Such a colour structure splits the + four colour indices into two pairs, and that splitting is precisely the + one produced by two cubic vertices joined by an internal line. + + Returns {quartic_id: (cubic_id, pairings)} where pairings[icolor] is the + pair of 2-tuples of colour index positions separated by the summed index. + """ + + try: + return model._unrollable_quartic_vertices + except AttributeError: + pass + + # Cubic candidates: three identical legs with a single f(0,1,2) structure + cubic_by_pdg = {} + for inter in model.get('interactions'): + parts = inter.get('particles') + if len(parts) != 3: + continue + pdgs = misc.make_unique([p.get_pdg_code() for p in parts]) + if len(pdgs) != 1: + continue + color = inter.get('color') + if len(color) != 1 or len(color[0]) != 1: + continue + if not isinstance(color[0][0], color_algebra.f) or \ + sorted(color[0][0]) != [0, 1, 2]: + continue + cubic_by_pdg[pdgs[0]] = inter.get('id') + + res = {} + for inter in model.get('interactions'): + parts = inter.get('particles') + if len(parts) != 4: + continue + pdgs = misc.make_unique([p.get_pdg_code() for p in parts]) + if len(pdgs) != 1 or pdgs[0] not in cubic_by_pdg: + continue + pairings = [] + for col_str in inter.get('color'): + pairing = _f_pair_split(col_str) + if pairing is None: + break + pairings.append(pairing) + else: + if pairings: + res[inter.get('id')] = (cubic_by_pdg[pdgs[0]], pairings) + + try: + model._unrollable_quartic_vertices = res + except AttributeError: + pass + return res + + +def _f_pair_split(col_str): + """If col_str is f(..)*f(..) with a single shared summed index and the + four remaining indices being 0,1,2,3, return the two pairs of external + colour index positions it separates, otherwise None.""" + + if len(col_str) != 2 or not all(isinstance(obj, color_algebra.f) + for obj in col_str): + return None + shared = [i for i in col_str[0] if i in col_str[1]] + if len(shared) != 1 or shared[0] >= 0: + return None + pairs = tuple(tuple(sorted(i for i in obj if i != shared[0])) + for obj in col_str) + if sorted(pairs[0] + pairs[1]) != [0, 1, 2, 3]: + return None + return pairs + + +def glued_vertices(diagram): + """Vertices of the diagram with the trailing identity vertex glued into + the one before it. + + The identity vertex only states that its two legs are the two ends of one + and the same line, so it is dropped once the diagram is complete, by + handing that line to the vertex before it. Until that is done the same + diagram can be written in several ways, and comparing two of them is + meaningless. Returns None when there is nothing to glue.""" + + vertices = diagram.get('vertices') + if len(vertices) <= 1 or vertices[-1].get('id') != 0: + return None + + vertices = copy.copy(vertices) + lastvx = vertices.pop() + nexttolastvertex = copy.copy(vertices.pop()) + legs = copy.copy(nexttolastvertex.get('legs')) + ntlnumber = legs[-1].get('number') + lastleg = [leg for leg in lastvx.get('legs') + if leg.get('number') != ntlnumber][0] + # Reset onshell in case we have forbidden s-channels + if lastleg.get('onshell') == False: + lastleg.set('onshell', None) + # Replace the last leg of nexttolastvertex + legs[-1] = lastleg + nexttolastvertex.set('legs', legs) + vertices.append(nexttolastvertex) + return vertices + + +def get_unrollable_cubic_ids(model): + """Interaction ids of the cubic vertices a factorisable quartic vertex + unrolls into -- the three gluon vertex for the four gluon one.""" + + return frozenset(cubic_id for cubic_id, pairings in + get_unrollable_quartic_vertices(model).values()) + + +def colour_index_order(vertex, is_last, model): + """Return the vertex legs in the order in which color_amp.ColorBasis maps + them onto the colour indices of the interaction. + + This mirrors ColorBasis.add_vertex: the outgoing leg of an internal + vertex is flipped to its antiparticle and moved to the front, then the + legs are sorted following the particle order of the interaction. Note + that the sorting can move the outgoing leg away from the front again, so + its position is returned along with the legs. + + Returns (legs, outgoing_position), the position being None for the last + vertex of a diagram, or None if the vertex does not match its + interaction.""" + + inter = model.get_interaction(vertex.get('id')) + if inter is None: + return None + + legs = vertex.get('legs') + entries = [] + for index, leg in enumerate(legs): + part = model.get('particle_dict')[leg.get('id')] + outgoing = index == len(legs) - 1 and not is_last + entries.append((leg, part.get_anti_pdg_code() if outgoing + else part.get_pdg_code(), outgoing)) + + if not is_last: + entries.insert(0, entries.pop(-1)) + + ordered = [] + for pdg in [p.get_pdg_code() for p in inter.get('particles')]: + for index, entry in enumerate(entries): + if entry[1] == pdg: + ordered.append(entries.pop(index)) + break + else: + return None + + out_position = None + for index, entry in enumerate(ordered): + if entry[2]: + out_position = index + + return [entry[0] for entry in ordered], out_position + + +def split_quartic_vertex(vertex, is_last, pairing, cubic_id, model): + """Replace a quartic vertex by the two cubic vertices that the given + colour structure factorises into, joined by a new internal line.""" + + ordered, out_position = colour_index_order(vertex, is_last, model) + first, second = pairing + if not is_last and out_position in first: + # the pair the outgoing leg does not belong to is the one replaced by + # the new internal line + first, second = second, first + + combined = sorted([ordered[i] for i in first], + key=lambda leg: leg.get('number')) + new_leg = base_objects.Leg({ + 'id': combined[0].get('id'), + 'number': min(leg.get('number') for leg in combined), + 'state': len([l for l in combined if not l.get('state')]) != 1, + 'from_group': True}) + + first_vx = base_objects.Vertex({ + 'legs': base_objects.LegList(combined + [new_leg]), + 'id': cubic_id}) + if is_last: + rest = sorted([ordered[i] for i in second] + [new_leg], + key=lambda leg: leg.get('number')) + else: + # the outgoing leg has to stay last + rest = sorted([ordered[i] for i in second if i != out_position] + + [new_leg], key=lambda leg: leg.get('number')) + \ + [ordered[out_position]] + second_vx = base_objects.Vertex({'legs': base_objects.LegList(rest), + 'id': cubic_id}) + + return [first_vx, second_vx] + + +# Placeholder standing for the single summed index of a factorisable quartic +# colour structure, which is not a leg of the vertex. +_SUMMED = 'summed' + + +def reroot_diagram(diagram, root, model): + """Return the same diagram decomposed around another final vertex. + + A diagram is a tree, and which of its vertices is written last decides + which internal lines become currents: everything on the far side of the + final vertex is built up as a wavefunction, while the final vertex only + produces an amplitude. Re-rooting therefore changes which currents exist + without changing the diagram -- same vertices, same lines, same physics -- + which is what lets a quartic current find the cubic current it has to be + summed with. + + root is the index of the vertex to end on. Returns None if the diagram + does not decompose into a tree, which should not happen. + """ + + vertices = diagram.get('vertices') + last = len(vertices) - 1 + if root == last: + return diagram + + # Split every vertex into the external legs it holds and the internal + # lines it shares with another vertex. A line is recognised by its number + # being live, since a vertex reuses the smallest incoming number for the + # leg it produces. + live = {} + externals = [[] for _ in vertices] + lines = [] + for i, vertex in enumerate(vertices): + incoming = vertex.get('legs') if i == last else vertex.get('legs')[:-1] + for leg in incoming: + producer = live.pop(leg.get('number'), None) + if producer is None: + externals[i].append(leg) + else: + lines.append((producer, i, + vertices[producer].get('legs')[-1])) + if i != last: + live[vertex.get('legs')[-1].get('number')] = i + if live: + return None + + neighbours = {} + line_of = {} + for producer, consumer, leg in lines: + neighbours.setdefault(producer, []).append(consumer) + neighbours.setdefault(consumer, []).append(producer) + line_of[(producer, consumer)] = (leg, True) + line_of[(consumer, producer)] = (leg, False) + + # Walk out from the new root so that every vertex is emitted after the + # ones now feeding it. + order = [] + parent = {root: None} + def visit(node): + for other in neighbours.get(node, []): + if other == parent[node]: + continue + parent[other] = node + visit(other) + order.append(node) + visit(root) + if len(order) != len(vertices): + return None + + produced = {} + res = base_objects.VertexList() + for node in order: + incoming = list(externals[node]) + for other in neighbours.get(node, []): + if other != parent[node]: + incoming.append(produced[other]) + legs = base_objects.LegList(incoming) + if node != root: + leg, forwards = line_of[(node, parent[node])] + part = model.get('particle_dict')[leg.get('id')] + outgoing = base_objects.Leg({ + # the line keeps its particle if it already pointed this way, + # and is flipped when the re-rooting reverses it + 'id': leg.get('id') if forwards or part.get('self_antipart') + else -leg.get('id'), + 'number': min(l.get('number') for l in incoming), + 'state': len([l for l in incoming + if not l.get('state')]) != 1, + 'from_group': True}) + produced[node] = outgoing + legs.append(outgoing) + res.append(base_objects.Vertex({'legs': legs, + 'id': vertices[node].get('id')})) + + return base_objects.Diagram({'vertices': res}) + + +def diagram_colour_signature(diagram, model, color_chain, unrollable): + """Canonical signature of the colour string of one colour structure choice. + + Returns (key, coeff), where key identifies the product of colour objects + up to the antisymmetry of the structure constants and coeff collects the + resulting sign together with the rational prefactors. Two contributions + sharing a key have proportional colour factors and can be summed, the + relative weight being the ratio of their coeff. + + Every line is labelled by the set of external legs it separates rather + than by its leg number, so that the signature can be compared between + diagrams which number their internal lines differently. Returns None when + the colour structure is not of a supported form. + """ + + vertices = diagram.get('vertices') + last = len(vertices) - 1 + + # A leg number is reused by the vertex which produces it, so an external + # leg is one consumed while no internal line of that number is alive. + alive = {} + externals = [] + for i, vertex in enumerate(vertices): + legs = vertex.get('legs') + incoming = legs if i == last else legs[:-1] + for leg in incoming: + if alive.pop(leg.get('number'), None) is None: + externals.append(leg.get('number')) + if i != last: + alive[legs[-1].get('number')] = True + externals = frozenset(externals) + if not externals: + return None + reference = min(externals) + + def normalise(side): + """Root independent label of the line splitting side from its rest.""" + return side if reference not in side else externals - side + + factors = [] + coeff = fractions.Fraction(1, 1) + imaginary = False + live = {} + for i, vertex in enumerate(vertices): + inter = model.get_interaction(vertex.get('id')) + if inter is None: + return None + order = colour_index_order(vertex, i == last, model) + if order is None: + return None + ordered, out_position = order + sides = [None] * len(ordered) + below = frozenset() + # the outgoing leg is only known once all the incoming ones are read, + # since a vertex reuses the smallest incoming leg number for it + for pos in range(len(ordered)): + if pos == out_position: + continue + number = ordered[pos].get('number') + side = live.pop(number, None) + if side is None: + side = frozenset([number]) + sides[pos] = side + below = below | side + if out_position is not None: + # the outgoing leg carries everything that is not below it + sides[out_position] = externals - below + live[ordered[out_position].get('number')] = below + + labels = dict(enumerate(normalise(side) for side in sides)) + if vertex.get('id') in unrollable: + pairing = unrollable[vertex.get('id')][1][color_chain[i]] + labels[_SUMMED] = normalise(sides[pairing[0][0]] | + sides[pairing[0][1]]) + + if not inter.get('color'): + continue + col_str = inter.get('color')[color_chain[i]] + coeff *= col_str.coeff + imaginary ^= col_str.is_imaginary + for obj in col_str: + try: + indices = [labels[j if j >= 0 else _SUMMED] for j in obj] + except KeyError: + return None + if isinstance(obj, color_algebra.f): + # totally antisymmetric: sort and keep track of the parity + order = sorted(range(len(indices)), + key=lambda k: sorted(indices[k])) + coeff *= _permutation_sign(order) + indices = [indices[k] for k in order] + factors.append((obj.__class__.__name__, + tuple(tuple(sorted(index)) for index in indices))) + + return (tuple(sorted(factors)), imaginary), coeff + + +def _permutation_sign(order): + """Signature of a permutation given as a list of positions.""" + + sign = 1 + seen = [False] * len(order) + for start in range(len(order)): + if seen[start]: + continue + length = 0 + pos = start + while not seen[pos]: + seen[pos] = True + pos = order[pos] + length += 1 + if length % 2 == 0: + sign = -sign + return sign + #=============================================================================== # Amplitude #=============================================================================== @@ -436,6 +867,13 @@ class Amplitude(base_objects.PhysicsObject): generate the diagrams for the amplitude """ + # Interaction ids of the cubic vertices which the seed rule forbids to + # share a line, see generate_diagrams. Empty -- so the rule is inactive -- + # unless madgraph.merge_quartic_vertices is set. + seed_forbidden_cubic_ids = frozenset() + # Links recorded while the seed was expanded, see expand_seed_diagrams + quartic_unroll_tags = {} + def default_setup(self): """Default values for all properties""" @@ -444,6 +882,13 @@ def default_setup(self): # has_mirror_process is True if the same process but with the # two incoming particles interchanged has been generated self['has_mirror_process'] = False + # Crossed subprocesses folded into this amplitude and NOT generated on + # their own (merge_crossing='record'): each entry is + # (crossed Process, base_permutation, crossed_permutation), enough for + # the exporter to reach the crossed process through this amplitude's + # crossing-aware SMATRIX (see MultiProcess.cross_amplitude for the same + # permutation pair). Empty in the historical modes. + self['crossed_processes'] = [] def __init__(self, argument=None): """Allow initialization with Process""" @@ -470,6 +915,9 @@ def filter(self, name, value): if name == 'has_mirror_process': if not isinstance(value, bool): raise self.PhysicsObjectError("%s is not a valid boolean" % str(value)) + if name == 'crossed_processes': + if not isinstance(value, list): + raise self.PhysicsObjectError("%s is not a valid list" % str(value)) return True def get(self, name): @@ -487,7 +935,8 @@ def get(self, name): def get_sorted_keys(self): """Return diagram property names as a nicely sorted list.""" - return ['process', 'diagrams', 'has_mirror_process'] + return ['process', 'diagrams', 'has_mirror_process', + 'crossed_processes'] def get_number_of_diagrams(self): """Returns number of diagrams for this amplitude""" @@ -580,8 +1029,7 @@ def generate_diagrams(self, returndiag=False, diagram_filter=False): "particles are missing in model: %s" % model.get('particles') assert model.get('interactions'), \ - "interactions are missing in model" - + "interactions are missing in model" res = base_objects.DiagramList() # First check that the number of fermions is even @@ -668,6 +1116,20 @@ def generate_diagrams(self, returndiag=False, diagram_filter=False): max_multi_to1 = max([len(key) for key in \ model.get('ref_dict_to1').keys()]) + # Seed rule: when the quartic vertices are to be put back afterwards + # by unrolling (see unroll_quartic_vertices), generate only the + # diagrams unrolling cannot produce. Unrolling a quartic vertex always + # yields two cubic vertices sharing the line which replaced it, so a + # diagram can be reconstructed exactly when two of its cubic vertices + # share a line -- and the seed is what is left over. + # Left off for a decay chain, whose identity vertex is kept rather + # than glued in, and for loop amplitudes, whose diagram set is not the + # one the unrolling reasons about. + self.seed_forbidden_cubic_ids = frozenset() + if madgraph.merge_quartic_vertices and not self.has_loop_process() \ + and not process.get('is_decay_chain'): + self.seed_forbidden_cubic_ids = get_unrollable_cubic_ids(model) + # Reduce the leg list and return the corresponding # list of vertices @@ -707,6 +1169,10 @@ def generate_diagrams(self, returndiag=False, diagram_filter=False): for vertex_list in reduced_leglist: res.append(self.create_diagram(base_objects.VertexList(vertex_list))) + # Put back the diagrams the seed rule left out + if self.seed_forbidden_cubic_ids: + res = self.expand_seed_diagrams(res) + # Record whether or not we failed generation before required # s-channel propagators are taken into account failed_crossing = not res @@ -822,25 +1288,11 @@ def generate_diagrams(self, returndiag=False, diagram_filter=False): # Replace final id=0 vertex if necessary if not process.get('is_decay_chain'): for diagram in res: - vertices = diagram.get('vertices') - if len(vertices) > 1 and vertices[-1].get('id') == 0: - # Need to "glue together" last and next-to-last - # vertex, by replacing the (incoming) last leg of the - # next-to-last vertex with the (outgoing) leg in the - # last vertex - vertices = copy.copy(vertices) - lastvx = vertices.pop() - nexttolastvertex = copy.copy(vertices.pop()) - legs = copy.copy(nexttolastvertex.get('legs')) - ntlnumber = legs[-1].get('number') - lastleg = [leg for leg in lastvx.get('legs') if leg.get('number') != ntlnumber][0] - # Reset onshell in case we have forbidden s-channels - if lastleg.get('onshell') == False: - lastleg.set('onshell', None) - # Replace the last leg of nexttolastvertex - legs[-1] = lastleg - nexttolastvertex.set('legs', legs) - vertices.append(nexttolastvertex) + # "glue together" last and next-to-last vertex, by replacing + # the (incoming) last leg of the next-to-last vertex with the + # (outgoing) leg in the last vertex + vertices = glued_vertices(diagram) + if vertices is not None: diagram.set('vertices', vertices) if res and not returndiag: @@ -944,9 +1396,245 @@ def remove_diag(diag, model=None): return res + def expand_seed_diagrams(self, seed): + """Put back the diagrams the seed rule left out of the generation. + + A seed diagram stands for itself and for every diagram obtained by + replacing any subset of its quartic vertices by the pair of cubic + vertices one of their colour structures factorises into. Several seeds + reach the same diagram, hence the dedup, and the result is the full + diagram set again -- same diagrams, same count, so nothing + user-visible moves. + + What the detour buys is the decomposition: an unrolled diagram is the + seed with one vertex taken apart, so it is rooted exactly like the + diagram it has to be summed with and shares every current except the + one being summed. + + Also records the link between a quartic contribution and the diagram + it unrolls to, see get_quartic_unroll_links. + """ + + model = self.get('process').get('model') + unrollable = get_unrollable_quartic_vertices(model) + ninitial = self.get_ninitial() + + def canonical_tag(diagram): + return str(UnrollDiagramTag(diagram, model, ninitial)) + + res = base_objects.DiagramList() + seen = set() + tag_of = [] + last_seen = {} + clock = [0] + + def touch(tag): + # A diagram is placed at its *last* discovery, so that it lands + # after every seed which can reach it -- and so after every + # quartic current which can be summed into it. Seeing it again + # only moves it later, it is never generated twice. + clock[0] += 1 + last_seen[tag] = clock[0] + + self.quartic_unroll_tags = {} + todo = [] + for diagram in seed: + # The identity vertex is glued in right away rather than at the + # end of generate_diagrams. Until it is, the same diagram has + # several spellings and comparing two of them is meaningless, and + # a quartic vertex sitting just before it is not yet the last one + # -- which is what decides how its colour structures are indexed. + vertices = glued_vertices(diagram) + if vertices is not None: + diagram = self.create_diagram(vertices) + tag = canonical_tag(diagram) + seen.add(tag) + touch(tag) + res.append(diagram) + tag_of.append(tag) + todo.append((diagram, tag)) + + # Unrolling is confluent, so taking the diagrams it produces through + # the same treatment adds nothing to the set -- but it does give the + # link for the quartic vertices they have left. + cursor = 0 + while cursor < len(todo): + diagram, own_tag = todo[cursor] + cursor += 1 + vertices = diagram.get('vertices') + positions = [i for i, vertex in enumerate(vertices) + if vertex.get('id') in unrollable] + if not positions: + continue + # None leaves the vertex alone, the other entries are the colour + # structures carrying a coupling, as kept by ColorBasis.colorize + allowed = [[None] + sorted(misc.make_unique( + [key[0] for key in model.get_interaction( + vertices[p].get('id')).get('couplings')])) + for p in positions] + + for keys in itertools.product(*allowed): + choice = dict((position, key) for position, key + in zip(positions, keys) if key is not None) + if not choice: + continue + unrolled = self.unrolled_diagram(diagram, choice, unrollable) + tag = canonical_tag(unrolled) + touch(tag) + if tag not in seen: + seen.add(tag) + res.append(unrolled) + tag_of.append(tag) + todo.append((unrolled, tag)) + if len(choice) == len(positions): + chain = tuple(choice.get(i, 0) + for i in range(len(vertices))) + self.quartic_unroll_tags[(own_tag, chain)] = tag + + order = sorted(range(len(res)), key=lambda i: (last_seen[tag_of[i]], i)) + if madgraph.merge_quartic_vertices == 'slots': + # Reversed, each diagram lands at its *first* discovery instead. + # That keeps far fewer currents alive -- 199 slots against 259 at + # seven gluons -- at the price of the current sums, since a target + # then comes before the quartic currents which feed it. The trade + # a gpu wants, where the wavefunction store is per thread. + order.reverse() + return base_objects.DiagramList([res[i] for i in order]) + + def get_quartic_unroll_links(self, diaglist=None): + """Return {(diagram index, colour chain): target index} recorded while + the seed was expanded, resolved against the diagram list as it stands. + + This is the same map as the one unroll_quartic_vertices reconstructs + from the colour algebra, but obtained for free: the two diagrams are + one and the same seed unrolled differently. Empty when the diagrams + were not generated from a seed.""" + + if not self.quartic_unroll_tags: + return {} + + model = self.get('process').get('model') + if diaglist is None: + diaglist = self.get('diagrams') + ninitial = self.get_ninitial() + index = dict((str(UnrollDiagramTag(diagram, model, ninitial)), i) + for i, diagram in enumerate(diaglist)) + + res = {} + for (seed_tag, chain), tag in self.quartic_unroll_tags.items(): + if seed_tag in index and tag in index: + res[(index[seed_tag], chain)] = index[tag] + return res + + def unroll_quartic_vertices(self, diaglist=None): + """Link every quartic vertex contribution to the cubic diagram it + merges with. + + Each colour structure of an unrollable quartic vertex (see + get_unrollable_quartic_vertices) splits its four legs into two pairs, + which is exactly what two cubic vertices joined by an internal line + do. Replacing every quartic vertex of a diagram by that pair of cubic + vertices therefore turns it into a diagram already present in the + amplitude, and the two carry the same colour factor up to a rational + coefficient. Their amplitudes can hence be summed before the colour + algebra is applied. + + Returns {(diagram_index, colour_chain): (target_index, coeff)} where + colour_chain follows the convention of color_amp.ColorBasis.colorize + (one colour structure index per vertex) and the amplitude of the + source is coeff times a contribution to the target diagram. Diagrams + without any quartic vertex are left out. + + To line the result up with HelasAmplitude, call this on the + *reconstructed* amplitude, HelasMatrixElement.get_base_amplitude(), + and not on the amplitude the matrix element was built from. That is + the one helas_objects itself colorizes, and the vertex order of the + reconstructed diagrams -- hence the position of a colour structure + index inside the chain -- can differ from the generated ones. The two + happen to agree up to six gluons and start to differ at seven. + """ + + model = self.get('process').get('model') + if diaglist is None: + diaglist = self.get('diagrams') + + unrollable = get_unrollable_quartic_vertices(model) + if not unrollable: + return {} + + quartic_positions = [] + for diag in diaglist: + quartic_positions.append([i for i, vx in + enumerate(diag.get('vertices')) + if vx.get('id') in unrollable]) + if not any(quartic_positions): + return {} + + ninitial = self.get_ninitial() + # Diagrams free of quartic vertices are the possible merge targets + target_index = {} + for i, diag in enumerate(diaglist): + if quartic_positions[i]: + continue + target_index[str(UnrollDiagramTag(diag, model, ninitial))] = i + + res = {} + for i, diag in enumerate(diaglist): + positions = quartic_positions[i] + if not positions: + continue + # only the colour structures which carry a coupling contribute, + # matching what color_amp.ColorBasis.colorize keeps + allowed = [sorted(misc.make_unique( + [key[0] for key in model.get_interaction( + diag.get('vertices')[p].get('id')).get('couplings')])) + for p in positions] + for keys in itertools.product(*allowed): + choice = dict(zip(positions, keys)) + unrolled = self.unrolled_diagram(diag, choice, unrollable) + try: + target = target_index[ + str(UnrollDiagramTag(unrolled, model, ninitial))] + except KeyError: + # No cubic partner: the merge is not available, which can + # happen when the partner was removed by a diagram filter + # or by a forbidden s-channel. + continue + chain = tuple(choice.get(p, 0) + for p in range(len(diag.get('vertices')))) + source_sig = diagram_colour_signature(diag, model, chain, + unrollable) + target_chain = (0,) * len(diaglist[target].get('vertices')) + target_sig = diagram_colour_signature(diaglist[target], model, + target_chain, unrollable) + if source_sig is None or target_sig is None or \ + source_sig[0] != target_sig[0]: + continue + res[(i, chain)] = (target, source_sig[1] / target_sig[1]) + + return res + + def unrolled_diagram(self, diagram, choice, unrollable): + """Return a copy of diagram where the quartic vertices listed in + choice (position -> colour structure index) have been replaced by the + two cubic vertices that colour structure factorises into.""" + + model = self.get('process').get('model') + vertices = base_objects.VertexList() + for i, vertex in enumerate(diagram.get('vertices')): + if i not in choice: + vertices.append(vertex) + continue + cubic_id, pairings = unrollable[vertex.get('id')] + vertices.extend(split_quartic_vertex( + vertex, i == len(diagram.get('vertices')) - 1, + pairings[choice[i]], cubic_id, model)) + + return base_objects.Diagram({'vertices': vertices}) + def apply_4gluon_specials(self, diag_list): - res = diag_list.__class__() + res = diag_list.__class__() for diag in diag_list: keep = True for vertex in diag.get('vertices'): @@ -988,9 +1676,14 @@ def copy_leglist(self, legs): [ copy.copy(leg) for leg in legs ]) def reduce_leglist(self, curr_leglist, max_multi_to1, ref_dict_to0, - is_decay_proc = False, coupling_orders = None): + is_decay_proc = False, coupling_orders = None, + cubic_legs = frozenset()): """Recursive function to reduce N LegList to N-1 For algorithm, see doc for generate_diagrams. + + cubic_legs holds the numbers of the legs of curr_leglist which were + produced by a vertex the seed rule keeps apart, and is only ever + non-empty when that rule is active. """ # Result variable which is a list of lists of vertices @@ -1023,6 +1716,8 @@ def reduce_leglist(self, curr_leglist, max_multi_to1, ref_dict_to0, vertex_id in vertex_ids] # Check for coupling orders. If orders < 0, skip vertex for final_vertex in final_vertices: + if self.joins_two_cubics(final_vertex, cubic_legs): + continue if self.reduce_orders(coupling_orders, model, [final_vertex.get('id')]) != False: res.append([final_vertex]) @@ -1060,13 +1755,24 @@ def reduce_leglist(self, curr_leglist, max_multi_to1, ref_dict_to0, # Some coupling order < 0 continue + # Seed rule: drop the combinations putting two of the cubic + # vertices to be kept apart on the same line + if any(self.shares_line_with_cubic(vertex.get('id'), + vertex.get('legs')[:-1], + cubic_legs) + for vertex in leg_vertex_tuple[1]): + continue + # This is where recursion happens # First, reduce again the leg part reduced_diagram = self.reduce_leglist(leg_vertex_tuple[0], max_multi_to1, ref_dict_to0, is_decay_proc, - new_coupling_orders) + new_coupling_orders, + self.mark_cubic_legs(\ + cubic_legs, + leg_vertex_tuple[1])) # If there is a reduced diagram if reduced_diagram: vertex_list_list = [list(leg_vertex_tuple[1])] @@ -1076,6 +1782,49 @@ def reduce_leglist(self, curr_leglist, max_multi_to1, ref_dict_to0, return res + def shares_line_with_cubic(self, vertex_id, incoming, cubic_legs): + """True when vertex_id is one of the cubic vertices the seed rule + keeps apart and one of the lines coming in was produced by another + one of them.""" + + if not self.seed_forbidden_cubic_ids or \ + vertex_id not in self.seed_forbidden_cubic_ids: + return False + return any(leg.get('number') in cubic_legs for leg in incoming) + + def joins_two_cubics(self, vertex, cubic_legs): + """True when the vertex closing the diagram puts two of the cubic + vertices the seed rule keeps apart on the same line. + + The closing vertex is either a real n->0 interaction, whose incoming + lines are simply the legs left over, or the identity vertex, which + states that its two legs are the two ends of one and the same line -- + and that line joins the two vertices which produced them.""" + + if not self.seed_forbidden_cubic_ids: + return False + legs = vertex.get('legs') + if vertex.get('id'): + return self.shares_line_with_cubic(vertex.get('id'), legs, + cubic_legs) + return all(leg.get('number') in cubic_legs for leg in legs) + + def mark_cubic_legs(self, cubic_legs, vertices): + """Update the set of leg numbers standing for a line produced by one + of the cubic vertices the seed rule keeps apart. + + A number is dropped as soon as the line is consumed, since a vertex + reuses the smallest number coming in for the leg it produces.""" + + if not self.seed_forbidden_cubic_ids: + return cubic_legs + consumed = frozenset(leg.get('number') for vertex in vertices + for leg in vertex.get('legs')[:-1]) + produced = frozenset(vertex.get('legs')[-1].get('number') + for vertex in vertices if vertex.get('id') in \ + self.seed_forbidden_cubic_ids) + return (cubic_legs - consumed) | produced + def reduce_orders(self, coupling_orders, model, vertex_id_list): """Return False if the coupling orders for any coupling is < 0, otherwise return the new coupling orders with the vertex @@ -1377,24 +2126,55 @@ def default_setup(self): self['amplitudes'] = AmplitudeList() self['decay_chains'] = DecayChainAmplitudeList() + @staticmethod + def _decays_break_crossing(process_definition): + """True if any decay (recursively) pins a specific s-channel propagator. + + Crossing acts at the production level and lets the force-onshell decays + ride along, so a plain decay chain keeps crossing (see + export_v4.breaks_crossing_symmetry). But a decay that names a required or + forbidden s-channel does break it, and the production generator cannot + see that constraint (the core process it builds has the decays stripped + off). Detect it here so the production is recorded with merge_crossing off + in that case, keeping generation and the crossing-machinery emission + (which tests the full process) in agreement. + """ + for decay in process_definition.get('decay_chains'): + if decay.get('required_s_channels') or \ + decay.get('forbidden_s_channels') or \ + DecayChainAmplitude._decays_break_crossing(decay): + return True + return False + def __init__(self, argument = None, collect_mirror_procs = False, - ignore_six_quark_processes = False, loop_filter=None, diagram_filter=False): + ignore_six_quark_processes = False, loop_filter=None, + diagram_filter=False, merge_crossing=False): """Allow initialization with Process and with ProcessDefinition""" - + if isinstance(argument, base_objects.Process): super(DecayChainAmplitude, self).__init__() from madgraph.loop.loop_diagram_generation import LoopMultiProcess if argument['perturbation_couplings']: MultiProcessClass=LoopMultiProcess else: - MultiProcessClass=MultiProcess + MultiProcessClass=MultiProcess + # Record the production's crossings onto the base amplitude (so the + # decay-chain matrix element inherits them and the crossed + # subprocesses are not generated separately), UNLESS a decay pins an + # s-channel -- then the crossing machinery is not emitted downstream + # and the crossed subprocesses must stay fully generated. + prod_merge_crossing = merge_crossing + if isinstance(argument, base_objects.ProcessDefinition) and \ + self._decays_break_crossing(argument): + prod_merge_crossing = False if isinstance(argument, base_objects.ProcessDefinition): self['amplitudes'].extend(\ MultiProcessClass.generate_multi_amplitudes(argument, collect_mirror_procs, ignore_six_quark_processes, loop_filter=loop_filter, - diagram_filter=diagram_filter)) + diagram_filter=diagram_filter, + merge_crossing=prod_merge_crossing)) else: self['amplitudes'].append(\ MultiProcessClass.get_amplitude_from_proc(argument, @@ -1672,7 +2452,8 @@ def get(self, name): DecayChainAmplitude(process_def, self.get('collect_mirror_procs'), self.get('ignore_six_quark_processes'), - diagram_filter=self['diagram_filter'])) + diagram_filter=self['diagram_filter'], + merge_crossing=self['merge_crossing'])) else: self['amplitudes'].extend(\ self.generate_multi_amplitudes(process_def, @@ -1914,11 +2695,18 @@ def get_flavor(id, fsleg): continue # Check for successful crossings, unless we have specified - # properties that break crossing symmetry + # properties that break crossing symmetry. Crossing is a + # tree-level construction: a perturbative process (anything with + # the [...] syntax -- NLO, loop-induced, loop) must NOT be + # crossed, not even its tree-level Born/real sub-amplitudes, so + # its output stays byte-identical to a no-crossing build. (The + # 'loop_diagrams' guard below only catches an actual loop + # amplitude; the Born of an NLO process is an ordinary tree.) if not process.get('required_s_channels') and \ not process.get('forbidden_onsh_s_channels') and \ not process.get('forbidden_s_channels') and \ - not process.get('is_decay_chain') and not diagram_filter: + not process.get('is_decay_chain') and not diagram_filter and \ + not process.get('perturbation_couplings'): try: crossed_index = success_procs.index(sorted_legs) # The relabeling of legs for loop amplitudes is cumbersome @@ -1944,6 +2732,20 @@ def get_flavor(id, fsleg): non_permuted_procs.append(fast_proc) logger.info("Crossed process found for %s, reuse diagrams." % \ process.base_string()) + elif merge_crossing == 'record': + # Found crossing - do NOT generate a separate + # amplitude, but record the crossed process on the + # base so the exporter can still reach it through the + # base's crossing-aware SMATRIX (its partonic + # contribution is not lost, unlike merge_crossing=True). + amplitudes[crossed_index].get('crossed_processes')\ + .append((process, permutations[crossed_index], + permutation)) + logger.info("Crossed process %s recorded on %s " + "(not generated)." % + (process.base_string(), + amplitudes[crossed_index].get('process') + .base_string())) else: logger.info("Crossed process found for %s, do not generate diagrams." % \ process.base_string()) diff --git a/madgraph/core/helas_objects.py b/madgraph/core/helas_objects.py index 93a5c43945..920835d509 100755 --- a/madgraph/core/helas_objects.py +++ b/madgraph/core/helas_objects.py @@ -125,6 +125,16 @@ def create_tag(cls, amplitude, identical_particle_factor = 0): process.get('is_decay_chain'), identical_particle_factor, dc, + # Two modules told to cover DIFFERENT halves of a pattern's + # flavors (set_excluded_flavors) must not be identified, however + # alike their diagrams: this tag deliberately identifies + # processes that agree up to a leg permutation, and the split + # that lets a crossing serve q q~ > q' q~' is exactly such a + # pair. Merging them would relabel one to the other's leg order + # (reorder_process below) and undo the split silently. Empty for + # every process that was never told anything, so ordinary + # flavor combination is untouched. + getattr(process, '_excluded_flavors', ()), perms, sorted_tags] @@ -871,6 +881,12 @@ def get(self, name): self['lcut_size'] = self.get_lcut_size() if name in ['spin', 'mass', 'width', 'self_antipart']: + # onshell_zero_width (a runtime annotation set post-generation on an + # internal propagator whose particle is also an external/on-shell + # state) forces a ZERO width there -- see + # HelasMatrixElement.set_onshell_particles_width_to_zero. + if name == 'width' and getattr(self, 'onshell_zero_width', False): + return 'ZERO' return self['particle'].get(name) elif name == 'pdg_code': return self['particle'].get_pdg_code() @@ -3984,6 +4000,11 @@ def default_setup(self): # has_mirror_process is True if the same process but with the # two incoming particles interchanged has been generated self['has_mirror_process'] = False + # Crossed subprocesses folded into this ME and NOT generated on their + # own (merge_crossing='record'); carried over from the base amplitude so + # the exporter can reach them through this ME's crossing-aware SMATRIX. + # Each entry is (crossed Process, base_permutation, crossed_permutation). + self['crossed_processes'] = [] self['allowed_flavors'] = [] # list of all allowed flavors for the process self['allowed_flavors_with_iden'] = [] # list of all allowed flavors for the process but grouped by identical matrix-element self['allowed_flavors_with_iden_sign'] = [] # list of all allowed flavors for the process but grouped by identical matrix-element @@ -3994,9 +4015,22 @@ def default_setup(self): # _flavor_populated -- the valid_flavors store is up to date # _flavor_allow_trimming -- a per-leg flavor restriction is active # _flavor_trimmed -- restricted-flavor diagram trimming has run + # _flavor_epoch -- bumped whenever the store or the diagram + # list is rebuilt; invalidates the masks + # _flavor_mask_cache -- token of the last compute_flavor_masks() + # pass, or None if the masks are not current self._flavor_populated = False self._flavor_allow_trimming = False self._flavor_trimmed = False + # Cache for get_quartic_amplitude_merges(), needed both by the helas + # calls and by the colour amplitudes. Runtime only, like the above. + self.quartic_amplitude_merges = None + # Cache for get_quartic_current_sums(), same reason + self.quartic_current_sums = None + # Slots the current sums were given by reuse_outdated_wavefunctions + self.quartic_sum_me_ids = None + self._flavor_epoch = 0 + self._flavor_mask_cache = None def filter(self, name, value): """Filter for valid diagram property values.""" @@ -4023,6 +4057,9 @@ def filter(self, name, value): if name == 'has_mirror_process': if not isinstance(value, bool): raise self.PhysicsObjectError("%s is not a valid boolean" % str(value)) + if name == 'crossed_processes': + if not isinstance(value, list): + raise self.PhysicsObjectError("%s is not a valid list" % str(value)) return True def get_sorted_keys(self): @@ -4030,7 +4067,8 @@ def get_sorted_keys(self): return ['processes', 'identical_particle_factor', 'diagrams', 'color_basis', 'color_matrix', - 'base_amplitude', 'has_mirror_process'] + 'base_amplitude', 'has_mirror_process', + 'crossed_processes'] # Enhanced get function def get(self, name): @@ -4055,6 +4093,10 @@ def __init__(self, amplitude=None, optimization=1, self.get('processes').append(amplitude.get('process')) self.set('has_mirror_process', amplitude.get('has_mirror_process')) + if 'crossed_processes' in amplitude and \ + amplitude.get('crossed_processes'): + self.set('crossed_processes', + list(amplitude.get('crossed_processes'))) self.generate_helas_diagrams(amplitude, optimization, decay_ids) self.calculate_fermionfactors() self.calculate_identical_particle_factor() @@ -4415,26 +4457,67 @@ def reuse_outdated_wavefunctions(self, helas_diagrams): for diag in helas_diagrams: for wf in diag['wavefunctions']: wf.set('me_id',wf.get('number')) + self.quartic_sum_me_ids = None # a fresh slot each, at the end return helas_diagrams + # A current sum is a line of its own, written as soon as the later of + # the two currents it reads is made, and read by the amplitudes it was + # built for. Giving it a key here lets it take a slot from the same + # pool as everything else, rather than one of its own at the end -- + # and lets the cubic current die at the sum rather than at the + # amplitude, since the amplitude no longer reads it. + sums, uses, folded = self.get_quartic_current_sums() + read_after = {} + for isum, (cubic, quartic, coeff) in enumerate(sums): + read_after.setdefault(max(cubic.get('number'), + quartic.get('number')), []).append(isum) + # keys for the sums, above every wavefunction number + offset = max([wf.get('number') for diag in helas_diagrams + for wf in diag['wavefunctions']] or [0]) + sum_key = lambda isum: offset + 1 + isum + # First compute the first/last appearance of each wavefunctions # first takes the line number and return the id of the created wf # last_lign takes the id of the wf and return the line number last_lign={} first={} pos=0 + written = set() + allocated = set() for diag in helas_diagrams: for wf in diag['wavefunctions']: pos+=1 for wfin in wf.get('mothers'): last_lign[wfin.get('number')] = pos assert wfin.get('number') in list(first.values()) + # the same wavefunction can be listed by more than one + # diagram; it is written twice with the same value, so it owns + # one slot from its first appearance to its last use, and + # handing it a second one here would leak the first + if wf.get('number') in allocated: + continue + allocated.add(wf.get('number')) first[pos] = wf.get('number') + for isum in read_after.get(wf.get('number'), []): + if isum in written: + continue + written.add(isum) + pos+=1 + cubic, quartic, coeff = sums[isum] + last_lign[cubic.get('number')] = pos + last_lign[quartic.get('number')] = pos + first[pos] = sum_key(isum) for amp in diag['amplitudes']: pos+=1 + substitution = uses.get(amp.get('number'), {}) for wfin in amp.get('mothers'): - last_lign[wfin.get('number')] = pos - + isum = substitution.get(wfin.get('number')) + if isum is None: + last_lign[wfin.get('number')] = pos + else: + # this amplitude reads the sum, not the cubic current + last_lign[sum_key(isum)] = pos + # last takes the line number and return the last appearing wf at #that particular line last=collections.defaultdict(list) @@ -4463,7 +4546,9 @@ def reuse_outdated_wavefunctions(self, helas_diagrams): for diag in helas_diagrams: for wf in diag['wavefunctions']: wf.set('me_id', replace[wf.get('number')]) - + self.quartic_sum_me_ids = [replace[sum_key(isum)] + for isum in range(len(sums))] + return helas_diagrams def restore_original_wavefunctions(self): @@ -4476,7 +4561,8 @@ def restore_original_wavefunctions(self): for diag in helas_diagrams: for wf in diag['wavefunctions']: wf.set('me_id',wf.get('number')) - + self.quartic_sum_me_ids = None # a fresh slot each, at the end + return helas_diagrams @@ -5314,12 +5400,15 @@ def get_num_configs(self): def get_number_of_wavefunctions(self): """Gives the total number of wavefunctions for this ME""" - out = max([wf.get('me_id') for wfs in self.get('diagrams') + # a current sum can hold the highest slot of all + extra = self.get_quartic_sum_me_ids() + + out = max([wf.get('me_id') for wfs in self.get('diagrams') for wf in wfs.get('wavefunctions')]) - if out: - return out - return sum([ len(d.get('wavefunctions')) for d in \ - self.get('diagrams')]) + if out: + return max([out] + extra) + return max([sum([ len(d.get('wavefunctions')) for d in \ + self.get('diagrams')])] + extra) def get_all_wavefunctions(self): """Gives a list of all wavefunctions for this ME""" @@ -5334,6 +5423,79 @@ def get_all_mass_widths(self): return set([(d.get('mass'),d.get('width')) for d in self.get_all_wavefunctions()]) + def set_onshell_particles_width_to_zero(self): + """Drop the width of any internal propagator whose particle is also an + external (initial/final) state of the process. + + An external particle is an on-shell asymptotic state, so treating an + internal propagator of the same field as an unstable resonance + (the i*M*Gamma in its denominator) is inconsistent: e.g. the s/u-channel + top in t a > t a. This mirrors the T-channel width drop but is keyed on + the particle being external rather than on the propagator momentum being + spacelike. It is applied by setting the propagator wavefunction's width + to ZERO, which every UFO backend reads for the propagator's W argument + (see HelasWavefunction.get_helas_call_dict); in the complex-mass scheme + the same ZERO makes that propagator use the real mass. Controlled by the + zerowidth_external option; returns True if any width was dropped.""" + # The asymptotic external states are the decay LEAVES: in a decay chain + # (d d~ > z z, z > e+ e-) the core final z is not asymptotic, it is a + # resonance decaying to e+ e-, so expand the decays (a no-op without + # them). Using the core legs would wrongly flag the resonance's field. + external_pdgs = set() + offshell_pdgs = set() + for proc in self.get('processes'): + legs = proc.get_legs_with_decays() \ + if hasattr(proc, 'get_legs_with_decays') else proc.get('legs') + for leg in legs: + external_pdgs.add(abs(leg.get('id'))) + # A leg tagged off-shell (the "particle*" syntax -> leg['offshell']) + # is deliberately NOT treated as an asymptotic on-shell state: it + # stands for an off-shell resonance whose Breit-Wigner width must be + # kept (e.g. p p > t* t~*). Exclude its field so no propagator of it + # has the width dropped. + for leg in proc.get('legs'): + if leg.get('offshell'): + offshell_pdgs.add(abs(leg.get('id'))) + external_pdgs -= offshell_pdgs + # A would-be Goldstone boson is eaten by -- and shares the mass of -- its + # gauge boson (G+ <-> W+, G0 <-> Z). In Feynman/FD gauge the Goldstone + # propagates explicitly, so if the gauge boson is an external on-shell + # state the Goldstone's internal propagator must drop its width too: + # otherwise the vector propagator carries ZERO width while its Goldstone + # keeps i*M*Gamma, an inconsistency that breaks the gauge-boson/Goldstone + # Ward identity. Add the Goldstone PDGs whose (shared) mass matches an + # external massive gauge boson. No-op in unitary gauge (no Goldstones) + # and for processes without an external vector boson. + model = self.get('processes')[0].get('model') if self.get('processes') \ + else None + if model is not None and external_pdgs: + ext_vector_masses = set() + for pdg in external_pdgs: + part = model.get_particle(pdg) + if part and part.get('spin') == 3 \ + and str(part.get('mass')).lower() != 'zero': + ext_vector_masses.add(part.get('mass')) + if ext_vector_masses: + for part in model.get('particles'): + if part.get('goldstone') \ + and part.get('mass') in ext_vector_masses: + external_pdgs.add(abs(part.get('pdg_code'))) + dropped = False + for wf in self.get_all_wavefunctions(): + # a wavefunction with no mothers is an external leg (no propagator, + # hence no width); only internal propagators carry the i*M*Gamma. + # A decay-chain resonance (onshell is True: produced on shell then + # decayed) MUST keep its Breit-Wigner width even if the same field + # also appears as an asymptotic external leg (e.g. one top decays + # while the other is final). + if wf.get('mothers') and wf.get('width') != 'ZERO' \ + and wf.get('onshell') is not True \ + and abs(wf.get_pdg_code()) in external_pdgs: + wf.onshell_zero_width = True + dropped = True + return dropped + + def get_coupling_for_flv(self, flv, model): """Return the coupling constant for a specific flavor""" @@ -5491,6 +5653,47 @@ def _iter_candidate_flavors(self, pdgs, pdg_signs, to_map, yield one_flavor, signed_pdg, signature + def set_excluded_flavors(self, flavors): + """Declare external-flavor assignments this module does NOT cover. + + A merged matrix element offers every flavor its diagrams support. That + is right while one module covers a whole pattern, and wrong as soon as + two modules are meant to SHARE a pattern's flavors between them -- each + would offer the other's, and the two would double count. + + The case that needs it: ``Q Q~ > Q Q~`` bundles three coupling classes, + and the flavor-changing annihilation ``q q~ > q' q~'`` is the one class a + crossing of ``Q Q > Q Q`` reaches only with the two light legs the other + way round. A module cannot list that class in the reachable order + (its leg pattern is shared by every row -- the FLAVOR table carries + unsigned group POSITIONS), so freeing it means generating a sibling with + the reordered pattern and giving each module HALF the flavors. This is + how a module is told which half is not its own. + + `flavors` is an iterable of flavor-index tuples, in the same convention + as get_external_flavors(). Setting it invalidates the populated store so + the next read recomputes; passing an empty set restores the default + "cover everything the diagrams support". + + The set is stored on the PROCESS, not on this matrix element, because a + matrix element is a derived object: the exporter rebuilds it from the + amplitude, and an exclusion recorded here would be silently dropped on + the way. The process travels with the amplitude, so the module that + comes out the far end still knows which half of the flavors is not its + own. + """ + excluded = frozenset(tuple(f) for f in flavors) + for proc in self.get('processes'): + proc._excluded_flavors = excluded + # force a repopulate: allowed_flavors and everything derived from it + # (masks, pdg tables, coupling classes) must be rebuilt. + self._flavor_epoch = getattr(self, '_flavor_epoch', 0) + 1 + self._flavor_populated = False + self['allowed_flavors'] = [] + self['allowed_flavors_pdgs'] = [] + self['allowed_flavors_with_iden'] = [] + self['allowed_flavors_with_iden_pdgs'] = [] + def populate_flavor_validity(self, model=None): """Eager, single-source-of-truth pass for multi-flavor generation. @@ -5515,6 +5718,10 @@ def populate_flavor_validity(self, model=None): if model is None: model = self.get('processes')[0].get('model') + # The store below is what the flavor masks are read from, so rebuilding + # it retires any masks computed against the previous one. + self._flavor_epoch = getattr(self, '_flavor_epoch', 0) + 1 + # reset the per-diagram store (this is the authoritative source) for diag in self.get('diagrams'): diag.valid_flavors = set() @@ -5562,6 +5769,11 @@ def populate_flavor_validity(self, model=None): flavor_list = [] pdg_list = [] + # Flavors this module has been told are not its own (set_excluded_ + # flavors); carried on the process so it survives the exporter + # rebuilding the matrix element from the amplitude. + excluded_flavors = getattr(self.get('processes')[0], + '_excluded_flavors', ()) # signature -> whether some diagram is valid for it, used to skip # permutation-equivalent assignments we have already decided on. checked = {} @@ -5600,8 +5812,18 @@ def populate_flavor_validity(self, model=None): # populate every diagram's store for this flavor if self.check_flavor_for_all_diagrams(one_flavor, model): - flavor_list.append(one_flavor) - pdg_list.append(signed_pdg) + # A flavor this module has been told it does not cover (see + # set_excluded_flavors) is still CHECKED -- the per-diagram + # store stays an honest record of what the diagrams support -- + # but it is not offered, so it gets no bit in the flavor masks + # and no row anywhere downstream. Everything that describes the + # module's flavor content (compute_flavor_masks, the PDG + # tables, get_external_flavors_with_iden, the generated FLAVOR + # table) reads allowed_flavors, so dropping it here is the one + # place that needs to know. + if tuple(one_flavor) not in excluded_flavors: + flavor_list.append(one_flavor) + pdg_list.append(signed_pdg) checked[signature] = True else: checked[signature] = False @@ -5796,6 +6018,10 @@ def restore_dropped(wft, dropped_wfct, def_wfct, diag): debug = False + # Diagrams are about to be dropped and the survivors renumbered, so any + # mask set computed against the untrimmed ME is retired here. + self._flavor_epoch = getattr(self, '_flavor_epoch', 0) + 1 + # store which diagram dropped_wfct = {} def_wfct = set() @@ -5849,6 +6075,53 @@ def restore_dropped(wft, dropped_wfct, def_wfct, diag): raise self.NoFlavorError("No diagram left after trimming for flavor! \n Please check the diagram generated and change the QCD/QED restriction to allow more diagrams to be generated.") + def _clear_flavor_tags(self, objects): + """Drop the temporary 'flavortag' key from `objects` (wavefunctions and + amplitudes). + + The tag is written by check_flavor()/get_coupling_for_flv() while they + walk the diagram and neither removes it afterwards, so it outlives the + computation that produced it. It must not: replace_single_wavefunction + iterates old_wf.keys() and looks each one up on the new wavefunction, so + a leftover tag turns into a lookup on a key the replacement does not + have. compute_flavor_masks() runs this on BOTH its paths -- the masks + can be reused from the cache, a stale tag never can. + """ + for obj in objects: + try: + del obj['flavortag'] + except Exception: + pass + + def _flavor_mask_token(self, allowed_flavors, all_wfs, all_amps): + """Fingerprint of everything the flavor masks are derived from. + + A cached mask set stays usable for exactly as long as this is unchanged. + It pins three things: + + - `_flavor_epoch`, bumped by every routine that rebuilds the per-diagram + valid_flavors store or the allowed-flavor list (populate_flavor_ + validity, set_excluded_flavors) or drops diagrams from the ME + (remove_diagrams_without_flavor). That store is what the diagram masks + are read from, so a rebuild must not be served from the cache. + - the shape of the object graph, so any structural rewrite of the ME + forces a recompute. + - the amplitude NUMBERS, because guard_amp_number is one of them and the + helas call writer turns it straight into a bit index. The C++/ + madmatrix exporter renumbers amplitudes onto its single rolling + amp_sv slot while emitting calls, so the guards that hold inside that + window are not the ones that hold outside it and the two must not be + confused for each other. + + It is built from the flat views the caller already has, so it costs no + traversal of its own. + """ + return (getattr(self, '_flavor_epoch', 0), + len(allowed_flavors), + len(self.get('diagrams')), + len(all_wfs), + tuple(amp.get('number') for amp in all_amps)) + def compute_flavor_masks(self): """Compute per-diagram, per-amplitude and per-wavefunction flavor bitmasks. Bit i of a mask is set iff the object contributes for @@ -5860,6 +6133,14 @@ def compute_flavor_masks(self): Returns the list of allowed-flavor tuples used to define the bit order (same object as self.get_external_flavors()). Returns [] if the ME has no merged-particle flavor variants (single flavor / nothing to mask). + + Memoized. A single `output` asks for the masks of one matrix element + four to six times (the flavor table, the pdg tables, the mask blocks, + the crossing rows), and step 2 below is an ancestor walk over every + wavefunction of every amplitude -- millions of visits for a process like + g g > t t~ 4g, repeated identically each time. The masks are left ON the + objects, so a cache hit has nothing to re-apply; see _flavor_mask_token + for what keeps a hit honest. """ if not self.get('processes'): @@ -5868,6 +6149,27 @@ def compute_flavor_masks(self): if not allowed_flavors: return [] + # The two flat views, taken once and reused for every step below. + # Same content and order as get_all_wavefunctions()/get_all_amplitudes(), + # flattened with a comprehension rather than their sum(..., []): that + # re-copies the accumulator once per diagram, so on a large matrix + # element one such rebuild costs about as much as the mask pass it + # feeds -- which would leave a cache hit nearly as dear as a miss. + diagrams = self.get('diagrams') + all_wfs = [wf for diag in diagrams for wf in diag.get('wavefunctions')] + all_amps = [amp for diag in diagrams for amp in diag.get('amplitudes')] + + token = self._flavor_mask_token(allowed_flavors, all_wfs, all_amps) + if getattr(self, '_flavor_mask_cache', None) == token: + # Every mask, valid_flavors set and guard_amp_number this method + # writes is still on the objects from the computing call, and the + # token says nothing they are derived from has moved since. Only + # step 3 still has to run: whatever ran in between may have re-tagged + # the objects (get_external_flavors_with_iden goes through + # get_coupling_for_flv, which tags and does not clean up). + self._clear_flavor_tags(all_wfs + all_amps) + return allowed_flavors + # 1) Per-diagram mask, derived purely from the precomputed flavor store. # populate_flavor_validity() (triggered by get_external_flavors above) # has already recorded, for every diagram, the flavors it supports in @@ -5893,34 +6195,32 @@ def compute_flavor_masks(self): # wavefunction contributes to exactly one amplitude, the call writer can # reuse that amplitude guard for the wavefunction call. wf_amp_sinks = {} - for diag in self.get('diagrams'): - for wf in diag.get('wavefunctions'): - wf['flavor_mask'] = 0 - wf.pop('guard_amp_number', None) + for wf in all_wfs: + wf['flavor_mask'] = 0 + wf.pop('guard_amp_number', None) - for diag in self.get('diagrams'): - for amp in diag.get('amplitudes'): - amp_mask = amp['flavor_mask'] - if amp_mask == 0: + for amp in all_amps: + amp_mask = amp['flavor_mask'] + if amp_mask == 0: + continue + amp_num = amp.get('number') + stack = list(amp.get('mothers')) + seen = set() + while stack: + wf = stack.pop() + wf_id = id(wf) + if wf_id in seen: continue - amp_num = amp.get('number') - stack = list(amp.get('mothers')) - seen = set() - while stack: - wf = stack.pop() - wf_id = id(wf) - if wf_id in seen: - continue - seen.add(wf_id) - if amp_num is not None: - wf_amp_sinks.setdefault(wf_id, set()).add(amp_num) - existing = wf['flavor_mask'] if 'flavor_mask' in wf else 0 - new_mask = existing | amp_mask - if new_mask != existing: - wf['flavor_mask'] = new_mask - stack.extend(wf.get('mothers')) - - for wf in self.get_all_wavefunctions(): + seen.add(wf_id) + if amp_num is not None: + wf_amp_sinks.setdefault(wf_id, set()).add(amp_num) + existing = wf['flavor_mask'] if 'flavor_mask' in wf else 0 + new_mask = existing | amp_mask + if new_mask != existing: + wf['flavor_mask'] = new_mask + stack.extend(wf.get('mothers')) + + for wf in all_wfs: sinks = wf_amp_sinks.get(id(wf)) if sinks and len(sinks) == 1: wf['guard_amp_number'] = next(iter(sinks)) @@ -5928,7 +6228,7 @@ def compute_flavor_masks(self): # Mirror the wavefunction masks into per-wavefunction 'valid_flavors' # sets so HelasWavefunction.has_flavor() answers consistently with the # bitmasks (a wf contributes for flavor f iff bit f of its mask is set). - for wf in self.get_all_wavefunctions(): + for wf in all_wfs: mask = wf['flavor_mask'] if 'flavor_mask' in wf else 0 wf.valid_flavors = set(flavor for flav_idx, flavor in enumerate(allowed_flavors) @@ -5937,12 +6237,9 @@ def compute_flavor_masks(self): # 3) Clean up the 'flavortag' side effect left by diag.check_flavor on # wavefunctions and amplitudes. Same cleanup pattern as # get_external_flavors. - for wfct in self.get_all_wavefunctions() + self.get_all_amplitudes(): - try: - del wfct['flavortag'] - except Exception: - pass + self._clear_flavor_tags(all_wfs + all_amps) + self._flavor_mask_cache = token return allowed_flavors def flavor_mask_is_trivial(self): @@ -6152,13 +6449,311 @@ def generate_color_amplitudes(self, color_basis, diagrams): return col_amp_list - def get_color_amplitudes(self): + + def get_color_amplitudes(self, merge_quartic_amplitudes=True): """Return a list of (coefficient, amplitude number) lists, corresponding to the JAMPs for this matrix element. The coefficients are given in the format (fermion factor, color - coeff (frac), imaginary, Nc power).""" - - return self.generate_color_amplitudes(self['color_basis'],self['diagrams']) + coeff (frac), imaginary, Nc power). + + merge_quartic_amplitudes says whether the caller also writes out the + sums of get_quartic_amplitude_merges. Only the Fortran writer does. + A backend which does not has to leave those amplitudes in the JAMPs, + where their own colour coefficients give the identical result -- the + two carry the same colour factor, which is the whole premise.""" + + col_amps = self.generate_color_amplitudes(self['color_basis'], + self['diagrams']) + # never computed at all: their current was summed instead + dropped = set(self.get_quartic_current_sums()[2]) + if merge_quartic_amplitudes: + # summed into their partner by GET_AMP, so they must not enter the + # JAMPs a second time + dropped |= set(self.get_quartic_amplitude_merges()) + if not dropped: + return col_amps + return [[entry for entry in col_amp if entry[1] not in dropped] + for col_amp in col_amps] + + def get_quartic_amplitude_merges(self): + """Return {amplitude number: (target number, coefficient)} for the + quartic gluon contributions which can be summed into another + amplitude. + + Each colour structure of a four gluon vertex carries the same colour + factor as the diagram obtained by splitting that vertex into two cubic + ones, see diagram_generation.Amplitude.unroll_quartic_vertices, so the + two amplitudes may be added before the colour algebra is applied. + Doing so here keeps the JAMPs -- and hence the work of the JAMP + optimiser -- proportional to the number of cubic diagrams rather than + to the number of amplitudes. + + The result is cached, since it is needed both when writing the helas + calls and when building the colour amplitudes. + """ + + if self.quartic_amplitude_merges is None: + self.quartic_amplitude_merges = self.compute_quartic_amplitude_merges() + return self.quartic_amplitude_merges + + def compute_quartic_amplitude_merges(self): + """Work out the amplitude merges, see get_quartic_amplitude_merges.""" + + if not madgraph.merge_quartic_vertices: + return {} + # colorize() is applied to the reconstructed amplitude, so this is the + # one whose colour chains line up with our 'color_indices' + base = self.get('base_amplitude') + links = base.unroll_quartic_vertices() + if not links: + return {} + + numbers = {} + for diagram in self.get('diagrams'): + for amplitude in diagram.get('amplitudes'): + numbers[(diagram.get('number') - 1, + tuple(amplitude.get('color_indices')))] = \ + amplitude.get('number') + + res = {} + for source, (target, coeff) in links.items(): + target_chain = (0,) * \ + len(base.get('diagrams')[target].get('vertices')) + try: + res[numbers[source]] = (numbers[(target, target_chain)], coeff) + except KeyError: + # no amplitude for one of the two: leave them alone + continue + + # a merged amplitude must never itself be merged away, otherwise the + # contributions it absorbed would be lost + targets = set(target for target, _ in res.values()) + return dict((source, value) for source, value in res.items() + if source not in targets) + + def get_quartic_current_sums(self): + """Return the current sums which take a quartic amplitude away. + + Where a quartic current and the cubic current carrying the same colour + factor feed the same vertex, the two amplitudes they give differ by + that one line and by nothing else. Summing the two currents once and + calling the amplitude on the sum therefore gets both contributions out + of a single call: + + TMP = W1 + c*W4 + CALL VVV1_0(..., TMP, AMP(t)) + + instead of one call for AMP(t) and one for the quartic AMP(s) which is + then added to it. The sum is one addition and is shared by every + amplitude which uses it, so it replaces as many calls as it has users. + + Only the amplitudes are treated. A sum sitting deeper would have to be + carried through every current above it, and those currents are shared + with diagrams which must not get the extra term -- see + docs/gluon-quartic-plan.md. + + Returns (sums, uses, folded): + sums [(cubic wavefunction, quartic wavefunction, coefficient)] + uses {amplitude number: {cubic wavefunction number: sums index}} + folded set of amplitude numbers the sums make unnecessary + """ + + if self.quartic_current_sums is None: + self.quartic_current_sums = self.compute_quartic_current_sums() + return self.quartic_current_sums + + def get_quartic_sum_me_ids(self): + """Wavefunction slot of each current sum. + + reuse_outdated_wavefunctions hands them out of the same pool as the + wavefunctions themselves, so a sum lands in whatever slot happens to + be free. When the wavefunctions were not recycled they get a fresh + slot each, at the end.""" + + sums = self.get_quartic_current_sums()[0] + if not sums: + return [] + if self.quartic_sum_me_ids is not None: + return self.quartic_sum_me_ids + used = [wf.get('me_id') or wf.get('number') + for diagram in self.get('diagrams') + for wf in diagram.get('wavefunctions')] + base = max(used or [0]) + return [base + 1 + isum for isum in range(len(sums))] + + def compute_quartic_current_sums(self): + """Work out the current sums, see get_quartic_current_sums.""" + + merges = self.get_quartic_amplitude_merges() + if not merges or madgraph.merge_quartic_vertices == 'slots': + # 'slots' orders the diagrams for the smallest wavefunction store + # instead, which puts a target ahead of the quartic currents + # feeding it, so no sum can be built + return [], {}, set() + + model = self.get('processes')[0].get('model') + unrollable = diagram_generation.get_unrollable_quartic_vertices(model) + cubic_ids = diagram_generation.get_unrollable_cubic_ids(model) + + amplitudes = {} + available = {} + written = set() + for diagram in self.get('diagrams'): + for wavefunction in diagram.get('wavefunctions'): + written.add(wavefunction.get('number')) + for amplitude in diagram.get('amplitudes'): + amplitudes[amplitude.get('number')] = amplitude + # the currents which exist by the time it is written out + available[amplitude.get('number')] = frozenset(written) + + # For each target, the substitutions each of its merges asks for + candidates = {} + for source, (target, coeff) in merges.items(): + pairs = self.match_quartic_mothers(amplitudes[source], + amplitudes[target], + unrollable, cubic_ids) + if not pairs: + continue + # the sum is written with sumw_1 / subw_1, which carry no weight + if abs(coeff) != 1: + continue + # the quartic current has to be there when the target is written + if any(quartic.get('number') not in available[target] + for cubic, quartic in pairs): + continue + key = frozenset((cubic.get('number'), quartic.get('number')) + for cubic, quartic in pairs) + candidates.setdefault(target, {})[key] = (source, coeff, pairs) + + sums, uses, folded = [], {}, set() + index = {} + for target in sorted(candidates): + entries = candidates[target] + singles = dict((next(iter(key)), value) + for key, value in entries.items() if len(key) == 1) + + # Substituting several mothers at once also produces the amplitude + # with all of them substituted, so every subset has to be a merge + # into this same target, with the product of the coefficients. + # Keep the substitutions which pass, drop the ones which do not. + chosen = [] + for pair in sorted(singles): + trial = chosen + [pair] + if all(self.subset_is_merged(entries, singles, combination) + for size in range(2, len(trial) + 1) + for combination in itertools.combinations(trial, size)): + chosen = trial + if not chosen: + continue + + for size in range(1, len(chosen) + 1): + for combination in itertools.combinations(chosen, size): + folded.add(entries[frozenset(combination)][0]) + for pair in chosen: + source, coeff, pairs = singles[pair] + cubic, quartic = pairs[0] + if (pair, coeff) not in index: + index[(pair, coeff)] = len(sums) + sums.append((cubic, quartic, coeff)) + uses.setdefault(target, {})[cubic.get('number')] = \ + index[(pair, coeff)] + + return sums, uses, folded + + @staticmethod + def subset_is_merged(entries, singles, combination): + """Is the amplitude with all of combination substituted a merge into + the same target, weighing the product of the single coefficients?""" + + entry = entries.get(frozenset(combination)) + if entry is None: + return False + weight = 1 + for pair in combination: + weight *= singles[pair][1] + return entry[1] == weight + + @staticmethod + def match_quartic_mothers(source, target, unrollable, cubic_ids): + """Pair the mothers up where the source amplitude carries the quartic + current and the target the cubic one taking the same four lines. + + Returns [] unless the two are otherwise one and the same vertex, which + is what makes the substitution a plain swap of one argument.""" + + if source.get('interaction_id') != target.get('interaction_id') or \ + source.get('color_key') != target.get('color_key'): + return [] + + source_mothers = dict((mother.get('number'), mother) + for mother in source.get('mothers')) + target_mothers = dict((mother.get('number'), mother) + for mother in target.get('mothers')) + only_source = [source_mothers[number] for number in source_mothers + if number not in target_mothers] + only_target = [target_mothers[number] for number in target_mothers + if number not in source_mothers] + if not only_source or len(only_source) != len(only_target): + return [] + + res = [] + for quartic in only_source: + hit = [cubic for cubic in only_target + if HelasMatrixElement.is_unrolled_pair(quartic, cubic, + unrollable, + cubic_ids)] + if len(hit) != 1: + return [] + res.append((hit[0], quartic)) + if len(set(cubic.get('number') for cubic, quartic in res)) != len(res): + return [] + return res + + @staticmethod + def is_unrolled_pair(quartic, cubic, unrollable, cubic_ids): + """True when the cubic current is the pair of vertices the quartic one + factorises into: same four lines coming in, same line going out, and + the colour structure the quartic carries is the one which separates + the two lines the inner cubic vertex joins. + + That last condition is the one which is easy to forget. A quartic + vertex makes one current per colour structure, and all of them take + the same lines and make the same line, so the lines alone cannot tell + them apart -- only the pairing can, and it has to be read against + sorted_mothers, the order ALOHA receives the legs in.""" + + if quartic.get('interaction_id') not in unrollable or \ + cubic.get('interaction_id') not in cubic_ids or \ + quartic.get('number_external') != cubic.get('number_external'): + return False + + pairings = unrollable[quartic.get('interaction_id')][1] + if quartic.get('color_key') >= len(pairings): + return False + pairing = pairings[quartic.get('color_key')] + mothers = HelasMatrixElement.sorted_mothers(quartic) + outgoing = quartic.find_outgoing_number() - 1 + slots = [i for i in range(len(mothers) + 1) if i != outgoing] + + wanted = sorted(mother.get('number') + for mother in quartic.get('mothers')) + for inner in cubic.get('mothers'): + if inner.get('interaction_id') not in cubic_ids or \ + len(inner.get('mothers')) != 2: + continue + if sorted([mother.get('number') + for mother in inner.get('mothers')] + + [other.get('number') for other in cubic.get('mothers') + if other is not inner]) != wanted: + continue + joined = set(mother.get('number') + for mother in inner.get('mothers')) + separated = frozenset(slots[i] for i, mother in enumerate(mothers) + if mother.get('number') in joined) + if frozenset(pairing[0]) == separated or \ + frozenset(pairing[1]) == separated: + return True + return False def sort_split_orders(self, split_orders): """ Sort the 'split_orders' list given in argument so that the orders of diff --git a/madgraph/fks/fks_base.py b/madgraph/fks/fks_base.py index 3b6f941d39..a16566d0f2 100755 --- a/madgraph/fks/fks_base.py +++ b/madgraph/fks/fks_base.py @@ -129,6 +129,18 @@ def __init__(self, procdef=None, options={}): legs (stored in pdgs, so that they need to be generated only once and then reicycled """ + # The four gluon merging is validated at tree level only, and an NLO + # generation is left alone whatever the option says. fks reads the + # vertex decomposition of its real diagrams -- link_rb_configs finds + # the vertex splitting ij into i and j and takes it out -- and the + # unrolling re-roots them, which can put that pair in the closing + # vertex, where there is nothing to take out. + with misc.TMP_variable(madgraph, 'merge_quartic_vertices', False): + self.generate_all(procdef, options) + + def generate_all(self, procdef=None, options={}): + """Generates the born amplitudes, the born processes and the reals, + see __init__ which is the only caller.""" if 'nlo_mixed_expansion' in options: self['nlo_mixed_expansion'] = options['nlo_mixed_expansion'] diff --git a/madgraph/fks/fks_common.py b/madgraph/fks/fks_common.py index 7d055c6a0b..c07305adb9 100755 --- a/madgraph/fks/fks_common.py +++ b/madgraph/fks/fks_common.py @@ -196,14 +196,19 @@ def link_rb_configs(born_amp, real_amp, i, j, ij): for d in born_confs] - real_tags = [FKSDiagramTag(d['diagram'], - real_amp.get('process').get('model')) \ - for d in good_diags ] + # Dropping a duplicated tag has to drop its diagram with it: the two are + # walked in lockstep below, real_tags.index giving the position popped out + # of good_diags, so a tag left without its diagram misaligns everything + # after it. Which of two equal tags survives is decided by the order they + # come in, so keeping them apart also made the result order dependent. real_tags = [] + kept_diags = [] for d in good_diags: tag = FKSDiagramTag(d['diagram'], real_amp.get('process').get('model')) if not tag in real_tags: real_tags.append(tag) + kept_diags.append(d) + good_diags = kept_diags # and compare them if len(born_tags) != len(real_tags): diff --git a/madgraph/interface/amcatnlo_interface.py b/madgraph/interface/amcatnlo_interface.py index f2c6e2b8d8..9527a1cd99 100755 --- a/madgraph/interface/amcatnlo_interface.py +++ b/madgraph/interface/amcatnlo_interface.py @@ -65,6 +65,12 @@ logger = logging.getLogger('cmdprint') # -> stdout logger_stderr = logging.getLogger('fatalerror') # ->stderr +# Options baked into the matrix element at 'output' time (generation-time only, +# e.g. the T-channel width treatment): they can appear in the MG5 history but +# are rejected by the run interface's check_set (common_run_interface), so any +# replay of generation-time 'set' commands into a run interface must skip them. +NON_RUNTIME_SET_OPTIONS = ('zerowidth_tchannel',) + # a new function for the improved NLO generation glob_directories_map = [] def generate_directories_fks_async(i): @@ -1035,9 +1041,18 @@ def do_launch(self, line): else: ME = run_interface.aMCatNLOCmd(me_dir=argss[0],options=self.options) ME.pass_in_web_mode() - # transfer interactive configuration + # transfer interactive configuration. Generation-time-only options + # (e.g. zerowidth_tchannel, whose T-channel-width treatment is baked + # into the matrix element at 'output' time) appear in the MG5 history + # but are NOT valid run-time 'set' options -- replaying them would + # raise in the run interface's check_set. Skip them here; a genuine + # run-time 'set zerowidth_tchannel' typed at the run prompt still + # goes straight to the run interface and correctly crashes. config_line = [l for l in self.history if l.strip().startswith('set')] for line in config_line: + opt = line.split()[1] if len(line.split()) > 1 else '' + if opt in NON_RUNTIME_SET_OPTIONS: + continue ME.exec_cmd(line) stop = self.define_child_cmd_interface(ME) return stop diff --git a/madgraph/interface/common_run_interface.py b/madgraph/interface/common_run_interface.py index c8217cad3d..e0e89c0672 100755 --- a/madgraph/interface/common_run_interface.py +++ b/madgraph/interface/common_run_interface.py @@ -218,6 +218,14 @@ def check_set(self, args): self.help_set() raise self.InvalidCmd('set needs an option and an argument') + if args[0] == 'zerowidth_tchannel': + raise self.InvalidCmd( + "'zerowidth_tchannel' is a generation-time option: the T-channel " + "width treatment is now baked into the matrix element (ALOHA) at " + "'output' time and cannot be changed at run time. Choose it in MG5 " + "before output ('set zerowidth_tchannel True|False') and regenerate " + "the process.") + if args[0] not in self._set_options + list(self.options.keys()): self.help_set() raise self.InvalidCmd('Possible options for set are %s' % \ @@ -2395,6 +2403,14 @@ def check_multicore(self): for key, value in cross_sections.items(): cross_sections[key] = value / (nb_event+1) lhe.remove() + if reweight_mode == 'density': + # each job has written the average density matrix of its own + # chunk of events (and named the file after that chunk). Now + # that the chunks are recombined, re-compute the average over + # the full file --each event carries its own tag-- + # and clean up the per chunk files. + reweight_interface.combine_density_matrix(new_args[0], all_lhe, + reweight_card=pjoin(self.me_dir, 'Cards', 'reweight_card.dat')) for key in cross_sections: if key == 'orig' or (key.isdigit() and not (key[0] == '2')): continue @@ -4608,6 +4624,7 @@ def update_make_opts(self, run_card=None): self.make_opts_var['GLOBAL_FLAG'] = run_card['global_flag'] self.make_opts_var['ALOHA_FLAG'] = run_card['aloha_flag'] self.make_opts_var['MATRIX_FLAG'] = run_card['matrix_flag'] + self.make_opts_var['AMP_FLAG'] = run_card['amp_flag'] return self.update_make_opts_full(make_opts, self.make_opts_var) @@ -6860,7 +6877,28 @@ def check_card_consistency(self): if 'dressed_ee' in proc_charac['limitations']: if self.run_card['lpp1'] not in [0,1,-1] or self.run_card['lpp1'] not in [0,1,-1]: raise InvalidCmd("dressed lepton mode is not available for this process (see warning associated to the code generation to understand why)") - # + + if 'crossing' in proc_charac['limitations']: + # Crossing reuses one matrix element across physically distinct + # (crossed) initial states, so a per-beam property is ambiguous. + if self.run_card['polbeam1'] or self.run_card['polbeam2']: + raise InvalidCmd( + "Beam polarisation is not compatible with crossing symmetry:\n" + "this process reuses a matrix element across crossed initial\n" + "states, for which a per-beam polarisation is ill-defined.\n" + "Regenerate the process with crossing disabled, e.g.\n" + " generate --use_crossing=False\n" + "and 'output' again, to run polarised beams.") + if 'eva' in (self.run_card['pdlabel'], + self.run_card['pdlabel1'], self.run_card['pdlabel2']): + raise InvalidCmd( + "The EVA luminosity is not compatible with crossing symmetry:\n" + "this process reuses a matrix element across crossed initial\n" + "states, for which the per-beam EVA density is ill-defined.\n" + "Regenerate the process with crossing disabled, e.g.\n" + " generate --use_crossing=False\n" + "and 'output' again, to use EVA.") + # if 'fix_scale' in proc_charac['limitations']: if not self.run_card['fixed_fac_scale'] or not self.run_card['fixed_ren_scale']: raise InvalidCmd("Your model is identified as having not SM running of the strong coupling.\n"+\ diff --git a/madgraph/interface/madgraph_interface.py b/madgraph/interface/madgraph_interface.py index d7e1ef7fd0..c45ad84c8f 100755 --- a/madgraph/interface/madgraph_interface.py +++ b/madgraph/interface/madgraph_interface.py @@ -60,6 +60,7 @@ import madgraph.core.base_objects as base_objects +import madgraph.core.color_amp as color_amp import madgraph.core.diagram_generation as diagram_generation import madgraph.loop.loop_diagram_generation as loop_diagram_generation import madgraph.loop.loop_base_objects as loop_base_objects @@ -522,10 +523,12 @@ def help_output(self): logger.info(" --noeps=True: no jpeg and eps diagrams will be generated.") logger.info(" -name: the postfix of the main file in pythia8 mode.") logger.info(" --jamp_optim=[True|False]: [madevent(default:True)|standalone(default:False)] allows a more efficient code computing the color-factor.") + logger.info(" --jamp_orbit=[True|False]: [madevent|standalone|mg7] look for the shared color-factor sub-expressions by whole orbits of the color basis symmetry.") logger.info(" --t_strategy: [madevent] allows to change ordering strategy for t-channel.") logger.info(" --hel_recycling=False: [madevent] forbids helicity recycling optimization") logger.info(" --mask=False: [madevent|standalone] disable flavor-mask optimization for grouped/merged flavors (default:True).") logger.info(" --prefix=int|proc: [standalone] prefix matrix-element routine names (int: M_, proc: process name); generates f2py python-linkable routines.") + logger.info(" --use_crossing=False: [standalone|standalone_mg7] write this output without the crossing machinery; the crossed subprocesses folded onto their base at generation are written back as their own directories (same as generate --use_crossing=False).") logger.info(" Examples:",'$MG:color:GREEN') logger.info(" output",'$MG:color:GREEN') logger.info(" output standalone MYRUN -f",'$MG:color:GREEN') @@ -565,6 +568,21 @@ def help_check(self): logger.info(" Fortran standalone (SA), and C++ standalone (SA) back-ends") logger.info(" at the same phase-space point. Requires gfortran / g++.") logger.info(" Example: check language p p > e+ e-",'$MG:color:GREEN') + logger.info("o crossing:",'$MG:color:GREEN') + logger.info(" Output the process to a standalone backend twice, with") + logger.info(" the crossing symmetry on (--use_crossing=True) and off,") + logger.info(" then compare each subprocess evaluated through the") + logger.info(" extended flavor-index crossing against its independent") + logger.info(" value. --exporter picks the backend (default standalone):") + logger.info(" standalone (fortran/f2py), standalone_cpp, standalone_mg7.") + logger.info(" Requires gfortran+f2py (standalone) or a C++ compiler") + logger.info(" (standalone_cpp / standalone_mg7).") + logger.info(" For standalone_mg7, --simd picks the vectorisation width:") + logger.info(" auto (default), none, sse4, avx2, 512y, 512z; and") + logger.info(" --precision picks the float type: m mixed (default), d double, f float.") + logger.info(" Example: check crossing g u > g u",'$MG:color:GREEN') + logger.info(" Example: check crossing g u > g u --exporter=standalone_cpp",'$MG:color:GREEN') + logger.info(" Example: check crossing g u > g u --exporter=standalone_mg7 --simd=avx2 --precision=d",'$MG:color:GREEN') logger.info("o cms:",'$MG:color:GREEN') logger.info(" Check the complex mass scheme consistency by comparing") logger.info(" it to the narrow width approximation in the off-shell") @@ -935,7 +953,10 @@ def help_set(self): logger.info("zerowidth_tchannel ",'$MG:color:GREEN') logger.info(" > (default: True) [Used ONLY for tree-level output with madevent]") logger.info(" > set the width to zero for all T-channel propagator --no impact on complex-mass scheme mode") - logger.info("auto_convert_model ",'$MG:color:GREEN') + logger.info("zerowidth_external ",'$MG:color:GREEN') + logger.info(" > (default: True) [tree-level output] drop the width of an internal") + logger.info(" > propagator whose particle is also an external (initial/final) state") + logger.info("auto_convert_model ",'$MG:color:GREEN') logger.info(" > (default: False) If set on True any python2 UFO model will be automatically converted to pyton3 format") logger.info("nlo_mixed_expansion ",'$MG:color:GREEN') logger.info("deactivates mixed expansion support at NLO, goes back to MG5aMCv2 behavior") @@ -1088,7 +1109,16 @@ def check_check(self, args): '--collier_internal_stability_test':'False', '--collier_mode':'1', '--events': None, - '--skip_evt':0} + '--skip_evt':0, + # 'check crossing' backend: standalone (default), standalone_cpp + # or standalone_mg7. + '--exporter':'standalone', + # 'check crossing --exporter=standalone_mg7' vectorisation + # (SIMD) width: auto (default), none, sse4, avx2, 512y, 512z. + '--simd':'auto', + # 'check crossing --exporter=standalone_mg7' float precision: + # m mixed (default), d double, f float. + '--precision':'m'} if args[0] in ['cms'] or args[0].lower()=='cmsoptions': # increase the default energy to 5000 @@ -2293,7 +2323,8 @@ def complete_generate(self, text, line, begidx, endidx, formatting=True): return if text.startswith('--'): - return self.list_completion(text, ['--no_crossing', + return self.list_completion(text, ['--use_crossing=True', + '--use_crossing=False', '--no_warning=duplicate', '--diagram_filter', '--standalone']) @@ -2390,6 +2421,7 @@ def complete_check(self, text, line, begidx, endidx, formatting=True): cms_check_mode = len(args) >= 2 and args[1]=='cms' + crossing_check_mode = len(args) >= 2 and args[1]=='crossing' cms_options = ['--name=','--tweak=','--seed=','--offshellness=', '--lambdaCMS=','--show_plot=','--report=','--lambda_plot_range=','--recompute_width=', @@ -2399,6 +2431,25 @@ def complete_check(self, text, line, begidx, endidx, formatting=True): options = ['--energy='] if cms_options: options.extend(cms_options) + if crossing_check_mode: + # 'check crossing' only understands --energy, --exporter and (for + # standalone_mg7) --simd / --precision; the cms options above do not + # apply. + crossing_options = ['--energy=', '--exporter=', '--simd=', + '--precision='] + # Value completion for the crossing-specific options. + if args[-1] == '--exporter=': + return self.list_completion( + text, list(process_checks.CROSSING_EXPORTERS)) + elif args[-1] == '--simd=': + return self.list_completion( + text, list(process_checks.MG7_SIMD_CHOICES)) + elif args[-1] == '--precision=': + return self.list_completion( + text, list(process_checks.MG7_PRECISION_CHOICES)) + # Propose the options themselves once the user starts an option. + if text.startswith('-'): + return self.list_completion(text, crossing_options) # Directory continuation if args[-1].endswith(os.path.sep): @@ -2693,8 +2744,8 @@ def complete_open(self, text, line, begidx, endidx): def complete_output(self, text, line, begidx, endidx, possible_options = ['f', 'noclean', 'nojpeg'], possible_options_full = ['-f', '-noclean', '-nojpeg', '--noeps=True','--hel_recycling=False', - '--jamp_optim=', '--t_strategy=', '--vector_size=4', '--nb_warp=1', - '--mask=False', '--prefix=']): + '--jamp_optim=', '--jamp_orbit=', '--t_strategy=', '--vector_size=4', '--nb_warp=1', + '--mask=False', '--prefix=', '--use_crossing=False']): "Complete the output command" possible_format = list(self._export_formats) @@ -3122,7 +3173,8 @@ class MadGraphCmd(HelpToCmd, CheckValidForCmd, CompleteForCmd, CmdExtended): _tutorial_opts = ['aMCatNLO', 'stop', 'MadLoop', 'MadGraph5'] _switch_opts = ['mg5','aMC@NLO','ML5'] _check_opts = ['full', 'timing', 'stability', 'profile', 'permutation', - 'gauge','lorentz', 'brs', 'cms', 'flavor', 'language'] + 'gauge','lorentz', 'brs', 'cms', 'flavor', 'language', + 'crossing'] _import_formats = ['model_v4', 'model', 'proc_v4', 'command', 'banner'] _install_opts = ['Delphes', 'MadAnalysis4', 'ExRootAnalysis', 'update', 'Golem95', 'QCDLoop', 'maddm', 'maddump', @@ -3141,6 +3193,14 @@ class MadGraphCmd(HelpToCmd, CheckValidForCmd, CompleteForCmd, CmdExtended): _export_formats = _v4_export_formats + ['standalone_cpp', 'aloha', 'matchbox_cpp', 'matchbox', 'mg7_v5', 'mg7', 'standalone_mg7'] + # Formats that CONSUME the recorded crossings (merge_crossing='record') + # instead of needing them expanded back into separate subprocesses: they fold + # each crossed subprocess into its base directory and reach it through the + # base's crossing-aware SMATRIX/sigmaKin at an extended flavor index. + # 'standalone_rw' is the reweight's own output: its python driver resolves a + # crossed event through the generated GET_PDG_FOR_FLAVOR entry points (see + # reweight_interface.ReweightInterface.build_cross_resolve). + _crossing_folding_formats = ('standalone', 'standalone_mg7', 'standalone_rw') _set_options = ['group_subprocesses', 'ignore_six_quark_processes', 'stdout_level', @@ -3154,9 +3214,13 @@ class MadGraphCmd(HelpToCmd, CheckValidForCmd, CompleteForCmd, CmdExtended): 'max_npoint_for_channel', 'max_t_for_channel', 'zerowidth_tchannel', + 'zerowidth_external', 'default_unset_couplings', - 'nlo_mixed_expansion' + 'nlo_mixed_expansion', + 'color_basis', + 'merge_quartic_vertices' ] + _valid_color_basis = ['auto', 'trace', 'ddm'] _valid_nlo_modes = ['all','real','virt','sqrvirt','tree','noborn','LOonly', 'only'] _valid_sqso_types = ['==','<=','=','>'] _valid_amp_so_types = ['=','<=', '==', '>'] @@ -3237,8 +3301,11 @@ class MadGraphCmd(HelpToCmd, CheckValidForCmd, CompleteForCmd, CmdExtended): 'default_unset_couplings': 99, # 99 means infinity 'max_t_for_channel': 99, # means no restrictions 'zerowidth_tchannel': True, + 'zerowidth_external': True, 'nlo_mixed_expansion':True, - 'apply_flavor_grouping': True + 'apply_flavor_grouping': True, + 'color_basis': 'auto', + 'merge_quartic_vertices': False } options_madevent = {'automatic_html_opening':True, @@ -3256,6 +3323,11 @@ class MadGraphCmd(HelpToCmd, CheckValidForCmd, CompleteForCmd, CmdExtended): _curr_helas_model = None _curr_exporter = None _second_exporter = None + # UI flag --use_crossing (default on); see do_add. + _use_crossing = True + # Same flag on the output line, for the output being written (see do_output). + # do_output sets it on every call, so it can never leak to the next output. + _output_use_crossing = True _done_export = False _curr_decaymodel = None @@ -3354,6 +3426,32 @@ def do_quit(self, line): return value + def pop_use_crossing_flag(self, args): + """Remove --use_crossing[=True|False] from `args` and return its value. + + Returns None when the flag is absent, so the caller keeps its own + default. Shared by do_add (where the flag decides whether the crossed + subprocesses are folded onto their base at generation) and by do_output + (where it decides whether this output keeps them folded). + """ + value = None + for arg in args[:]: + if arg == '--use_crossing': + value = True + elif arg.startswith('--use_crossing='): + given = arg.split('=', 1)[1] + if given.lower() in ['true', 't', '1', 'yes', 'on']: + value = True + elif given.lower() in ['false', 'f', '0', 'no', 'off']: + value = False + else: + raise self.InvalidCmd('--use_crossing expects True or ' + 'False, got \'%s\'' % given) + else: + continue + args.remove(arg) + return value + # Add a process to the existing multiprocess definition # Generate a new amplitude def do_add(self, line): @@ -3361,7 +3459,18 @@ def do_add(self, line): existing amplitudes or merge two model """ - + + # The four gluon merging is wanted while the diagrams are generated, + # which is below the interface, so it travels on the module. Synced + # here rather than only in the setter, since the option can also + # arrive from mg5_configuration.txt. + madgraph.merge_quartic_vertices = \ + self.options.get('merge_quartic_vertices', False) + # an added process arrives in the generated order whatever an earlier + # output reordered, so the two have to be brought back together -- + # a value matching neither makes the next output redo all of them + self._quartic_order = 'mixed' + args = self.split_arg(line) @@ -3378,20 +3487,49 @@ def do_add(self, line): standalone_only = False if '--standalone' in args: standalone_only = True - merge_crossing = True - args.remove('--standalone') - - merge_crossing = False - if '--no_crossing' in args: - merge_crossing = True - args.remove('--no_crossing') + args.remove('--standalone') + + # Crossing symmetry is used by default. --use_crossing (bare) or + # --use_crossing=True keep it on, --use_crossing=False turns it off. + # --standalone does not affect it. + use_crossing = self.pop_use_crossing_flag(args) + if use_crossing is None: + use_crossing = True + # Crossed subprocesses are ALWAYS kept (merge_crossing=False, the + # historical 3.x default): use_crossing only decides later, at the + # exporter stage, whether they collapse into a single extended-FLAV_IDX + # matrix element (fortran standalone) or are written out as their own + # matrix elements (every other output, incl. madevent). The old + # merge_crossing=True / --no_crossing path DROPS the crossed processes + # from the amplitude list ("do not generate diagrams"), silently losing + # those partonic contributions, so it must not be reachable from + # --use_crossing: use_crossing=False has to remain a complete output. + # + # merge_crossing='record' keeps the partonic contribution: the crossed + # process is recorded on the base (not generated on its own) and reached + # through the base's crossing-aware SMATRIX. This is the DEFAULT for a + # crossing-enabled generation -> the standalone output is one directory + # per base ME, and the grouped backends (madevent/mg7) reconstruct the + # crossed subprocesses at output time (see do_output). A process that + # breaks crossing (s-channel constraint, decay chain, loop, ...) falls + # back to full generation per-process inside generate_matrix_elements. + # --use_crossing=False keeps the complete unmerged generation, and + # MG_MERGE_CROSSING=off is a debug escape hatch to the same. + merge_crossing = 'record' if use_crossing else False + if os.environ.get('MG_MERGE_CROSSING') == 'off': + merge_crossing = False # Check the validity of the arguments self.check_add(args) if args[0] == 'model': return self.add_model(args[1:]) - + + # Remember the choice for the exporter: the crossing machinery is only + # written out in the fortran standalone if every process of the current + # generation asked for it (reset by clean_process/do_generate). + self._use_crossing = self._use_crossing and use_crossing + # special option for 1->N to avoid generation of kinematically forbidden #decay. if args[-1].startswith('--optimize'): @@ -4239,6 +4377,9 @@ def create_lambda_values_list(lower_bound, N): ###### BEGIN do_check + # No exporter here, so 'auto' means the safe trace basis + self.set_color_basis_mode() + args = self.split_arg(line) # Check args validity param_card = self.check_check(args) @@ -4315,6 +4456,36 @@ def create_lambda_values_list(lower_bound, N): options['report'] = option[1].lower() elif option[0]=='--seed': options['seed'] = int(option[1]) + elif option[0]=='--exporter': + # Backend for 'check crossing': which standalone output to build + # and run the crossing self-check against. + if option[1] not in process_checks.CROSSING_EXPORTERS: + raise self.InvalidCmd( + "The '--exporter' option for 'check crossing' must be " + "one of %s, not '%s'." % ( + ', '.join(process_checks.CROSSING_EXPORTERS), + option[1])) + options['exporter'] = option[1] + elif option[0]=='--simd': + # Vectorisation width for 'check crossing --exporter= + # standalone_mg7' (ignored by the other backends). + if option[1] not in process_checks.MG7_SIMD_CHOICES: + raise self.InvalidCmd( + "The '--simd' option for 'check crossing' must be one " + "of %s, not '%s'." % ( + ', '.join(process_checks.MG7_SIMD_CHOICES), + option[1])) + options['simd'] = option[1] + elif option[0]=='--precision': + # Floating-point precision for 'check crossing --exporter= + # standalone_mg7' (ignored by the other backends). + if option[1] not in process_checks.MG7_PRECISION_CHOICES: + raise self.InvalidCmd( + "The '--precision' option for 'check crossing' must be " + "one of %s, not '%s'." % ( + ', '.join(process_checks.MG7_PRECISION_CHOICES), + option[1])) + options['precision'] = option[1] elif option[0]=='--name': if '.' in option[1]: raise self.InvalidCmd("Do not specify the extension in the"+ @@ -4568,6 +4739,24 @@ def create_lambda_values_list(lower_bound, N): # specified below where the user must be sure to have writing access. output_path = os.getcwd() + # The crossing check does not use the analytic MatrixElementEvaluator / + # gauge / CMS machinery: it regenerates the process to fortran + # standalone twice (crossing on and off) and compares the compiled + # matrix elements. Route it here and return early. + if args[0] == 'crossing': + options['proc_line'] = proc_line + options.setdefault('exporter', 'standalone') + crossing_result = process_checks.check_crossing( + myprocdef, param_card=param_card, options=options, cmd=self) + text = ('Crossing symmetry check (crossing on vs off, exporter=%s):' + '\n' % options['exporter']) + text += process_checks.output_crossing(crossing_result) + '\n' + logging.getLogger('madgraph.check_cmd').info(text) + process_checks.clean_added_globals(process_checks.ADDED_GLOBAL) + if not options['reuse']: + process_checks.clean_up(self._mgme_dir) + return + if args[0] in ['timing','stability', 'profile'] and not \ myprocdef.get('perturbation_couplings'): raise self.InvalidCmd("Only loop processes can have their "+ @@ -5007,10 +5196,21 @@ def clean_process(self): # Reset Helas matrix elements self._curr_matrix_elements = helas_objects.HelasMultiProcess() self._generate_info = "" + # Reset the diagrams kept for an 'auto' merge_quartic_vertices, they + # describe the amplitudes just dropped + self._quartic_order = None + self._quartic_pristine = None # Reset polarization-citation marker (a new process definition starts) self._uses_polarization = False self._uses_density_matrix = False self._uses_quarkonia = False + # Reset the --use_crossing choice (a new process definition starts). + # The output-line one is set by every do_output, but the loop/aMC@NLO + # interfaces have their own do_output which does not, so give it the + # same lifetime as the generate-line flag rather than leaving the last + # output's choice behind. + self._use_crossing = True + self._output_use_crossing = True # Reset _done_export, since we have new process self._done_export = False # Also reset _export_format and _export_dir @@ -9132,19 +9332,88 @@ def set2_acknowledged_v3_1_syntax(self, args, log=True): def help_set2_zerowidth_tchannel(self): logger.info("zerowidth_tchannel ",'$MG:color:GREEN') - logger.info(" > (default: True) [Used ONLY for tree-level output with madevent]") - logger.info(" > set the width to zero for all T-channel propagator --no impact on complex-mass scheme mode") + logger.info(" > (default: True) [generation/output-time option for tree-level output]") + logger.info(" > drop the width in the propagator denominator for spacelike (t-channel,") + logger.info(" > P^2<0) momenta. Done inside the ALOHA routine (runtime sign of P^2), so it") + logger.info(" > applies to every tree-level output. No impact in complex-mass-scheme mode.") def set2_zerowidth_tchannel(self, args, log=True): """Set whether the code should use zero-width for t-channel propagators. Default is set to True. (since v2.8.0) - Example: set zerowidth_tchannel False - """ + The treatment is now performed inside the ALOHA propagator routine (it + drops the width for spacelike, P^2<0, momenta); this flag is therefore an + output-time (code-generation) option and propagates to aloha here. + Example: set zerowidth_tchannel False + """ args = ['zerowidth_tchannel'] + args self.check_set(args) - self.options[args[0]] = banner_module.ConfigFile.format_variable(args[1], bool, args[0]) + self.options[args[0]] = banner_module.ConfigFile.format_variable(args[1], bool, args[0]) + aloha.t_channel_width = not self.options[args[0]] + + def help_set2_zerowidth_external(self): + logger.info("zerowidth_external ",'$MG:color:GREEN') + logger.info(" > (default: True) [generation/output-time option for tree-level output]") + logger.info(" > drop the width in the propagator denominator of any internal propagator") + logger.info(" > whose particle also appears as an external (initial/final) state -- an") + logger.info(" > external particle is an on-shell asymptotic state, so its internal") + logger.info(" > propagator (e.g. the s/u-channel top in t a > t a) must not carry the") + logger.info(" > i*M*Gamma resonance term. In the complex-mass scheme the real mass is") + logger.info(" > used there too. External legs themselves have no width argument.") + + def set2_zerowidth_external(self, args, log=True): + """Set whether the width should be dropped for internal propagators whose + particle is also an external state. Default True. Applied at output time + per matrix element (HelasMatrixElement.set_onshell_particles_width_to_zero), + so it is a code-generation option like zerowidth_tchannel. + Example: set zerowidth_external False + """ + args = ['zerowidth_external'] + args + self.check_set(args) + self.options[args[0]] = banner_module.ConfigFile.format_variable(args[1], bool, args[0]) + + def help_set2_merge_quartic_vertices(self): + logger.info("merge_quartic_vertices ",'$MG:color:GREEN') + logger.info(" > (default: False) [pure gluon amplitudes]") + logger.info(" > Sum each four gluon contribution into the cubic") + logger.info(" > amplitude carrying the same colour factor.") + logger.info(" > False : off") + logger.info(" > speed : fewest amplitude calls (best on cpu)") + logger.info(" > slots : smallest wavefunction store (for gpu, where") + logger.info(" > that store is per thread); costs the sums") + logger.info(" > auto : slots when the matrix elements go to a gpu") + logger.info(" > backend, speed otherwise, decided per output") + + def set2_merge_quartic_vertices(self, args, log=True): + """Sum the four gluon contributions into the cubic amplitude carrying + the same colour factor. + + Read while the diagrams are generated, so it has to be set before + 'generate' -- 'speed' and 'slots' fix the diagram order there and an + output time option would come too late. 'auto' generates in the + 'speed' order and lets each output reorder, see + apply_quartic_diagram_order. + + Example: set merge_quartic_vertices auto + """ + args = ['merge_quartic_vertices'] + args + self.check_set(args) + value = args[1].lower() + if value in ('slots', 'speed', 'auto'): + pass + elif value in ('false', '0', 'none', 'off'): + value = False + elif value in ('true', '1', 'on'): + value = 'speed' + else: + raise self.InvalidCmd( + "merge_quartic_vertices takes False, speed, slots or auto," + " not '%s'" % args[1]) + self.options[args[0]] = value + madgraph.merge_quartic_vertices = value + if log: + logger.info('set merge_quartic_vertices to %s' % value) def set2_store_rwgt_info(self,args, log=True): """Set whether the code should generate systematics information in the output LHE file at NLO @@ -9355,6 +9624,28 @@ def set2_apply_flavor_grouping(self, args, log=True): """ self.options['apply_flavor_grouping'] = banner_module.ConfigFile.format_variable(args[0], bool, 'apply_flavor_grouping') + def help_set2_color_basis(self): + logger.info("color_basis ",'$MG:color:GREEN') + logger.info(" > (default: auto) select the color basis used for processes") + logger.info(" whose color structure is purely adjoint (multi-gluon).") + logger.info(" > trace: the (n-1)! basis of traces of fundamental generators") + logger.info(" > ddm: the (n-2)! Del Duca-Dixon-Maltoni half-ladder basis") + logger.info(" (n-1 times fewer JAMPs, (n-1)^2 times smaller color matrix)") + logger.info(" > auto: ddm for the output formats which do not need a color") + logger.info(" flow decomposition (standalone), trace otherwise") + + def set2_color_basis(self, args, log=True): + """Set the color basis used for fully adjoint (multi-gluon) processes. + Example: set color_basis ddm + """ + args = ['color_basis'] + args + self.check_set(args) + value = args[1].lower() + if value not in self._valid_color_basis: + raise self.InvalidCmd('color_basis needs one of %s, got %s' % \ + (self._valid_color_basis, args[1])) + self.options['color_basis'] = value + # not documented options: @@ -9545,10 +9836,82 @@ def do_open(self, line): launch_ext.open_file(file_path) + # Output formats whose matrix elements can run on a gpu, where the + # wavefunction store is per thread. See set2_merge_quartic_vertices. + _gpu_me_formats = ['mg7', 'mg7_v5', 'standalone_mg7'] + # Diagram order currently materialised in _curr_amps, and the diagrams as + # they came out of the generation. Both only used for 'auto'. + _quartic_order = None + _quartic_pristine = None + + def apply_quartic_diagram_order(self, options): + """Resolve an 'auto' merge_quartic_vertices against the backend which + is about to be handed the matrix elements. + + What the two modes disagree on is the diagram order, and that is fixed + while the diagrams are generated. 'auto' generates in the 'speed' + order and reorders here instead, which is only possible because + 'slots' is that same order reversed. + + The reordering has to start from the diagrams as generated, not from + the ones in hand: an export mutates what it is given, and reversing + mutated diagrams gives an equivalent but differently numbered result. + """ + + if self.options.get('merge_quartic_vertices') != 'auto': + return + + target = options['me_exporter'].get('name', self._export_format) + gpu = target in self._gpu_me_formats or \ + options['me_exporter'].get('exporter', options['exporter']) == 'gpu' + wanted = 'slots' if gpu else 'speed' + madgraph.merge_quartic_vertices = wanted + + # Keep the diagrams of the amplitudes the seed rule applied to -- the + # order of any other one is not ours to touch. Both have to be read + # before the first export: it is the last moment the diagrams are the + # generated ones, and it also drops the marks saying they came from a + # seed. Amplitudes added later are picked up on the way past. + if self._quartic_pristine is None: + self._quartic_pristine = [] + for amp in self._curr_amps[len(self._quartic_pristine):]: + self._quartic_pristine.append( + copy.deepcopy(amp.get('diagrams')) + if (getattr(amp, 'seed_forbidden_cubic_ids', None) and + getattr(amp, 'quartic_unroll_tags', None)) else None) + if all(pristine is None for pristine in self._quartic_pristine): + return + logger.info("merge_quartic_vertices: '%s' for the %s matrix elements" + % (wanted, target)) + # the generation leaves them in the 'speed' order + if wanted == (self._quartic_order or 'speed'): + return + for amp, pristine in zip(self._curr_amps, self._quartic_pristine): + if pristine is None: + continue + diagrams = copy.deepcopy(pristine) + if wanted == 'slots': + diagrams.reverse() + amp.set('diagrams', base_objects.DiagramList(diagrams)) + self._quartic_order = wanted + # anything cached was built in the other order + self._curr_matrix_elements = helas_objects.HelasMultiProcess() + def do_output(self, line): """Main commands: Initialize a new Template or reinitialize one""" args = self.split_arg(line) + + # --use_crossing=False on the output line: write THIS output without the + # crossing machinery, whatever the generation chose. The exporters read + # it through _use_crossing (see Export{V4,CPP}Factory) and the crossings + # folded onto their base at generation are expanded back into explicit + # subprocesses (_output_folds_crossings), so the output stays complete -- + # it is exactly the generate --use_crossing=False output. Set on every + # do_output, so it never leaks to the next one. + output_use_crossing = self.pop_use_crossing_flag(args) + self._output_use_crossing = output_use_crossing is not False + # Check Argument validity self._export_plugin = None self.check_output(args) @@ -9668,7 +10031,26 @@ def do_output(self, line): options['me_exporter']['name'] = me_exporter else: options['me_exporter'] = {} - + + # A loop-induced process is exported by this tree-level do_output (see + # create_loop_induced), but only the madevent formats have a + # loop-induced exporter to route it to. Refuse the others here, ahead + # of the directory cleaning just below, so that a guaranteed refusal + # never deletes an existing output directory first. The exporter + # factories carry the same check as a backstop. Kept first in this + # block so a refusal costs nothing: everything below it configures a + # build that is not going to happen. + if self._export_format not in export_v4.LOOP_INDUCED_FORMATS and \ + self._curr_amps and isinstance(self._curr_amps[0], + loop_diagram_generation.LoopAmplitude): + raise self.InvalidCmd(export_v4.loop_induced_not_supported_msg( + self._export_format, self._curr_amps[0].get('process'))) + + # now that the backend getting the matrix elements is known, an 'auto' + # merge_quartic_vertices can be resolved -- before anything is built + self.apply_quartic_diagram_order(options) + + # check if os.path.realpath(self._export_dir) == os.getcwd(): if len(args) == 0: @@ -9797,28 +10179,296 @@ def do_output(self, line): # Reset _export_dir, so we don't overwrite by mistake later self._export_dir = None + def _output_folds_crossings(self): + """True if the output being written consumes the recorded crossings. + + Only the folding-capable standalone backends do, and only when this + output asked for the crossing machinery: --use_crossing=False on the + output line drops that machinery, so the crossings have to come back as + explicit subprocesses just like for a non-folding backend. + """ + return self._export_format in self._crossing_folding_formats and \ + getattr(self, '_output_use_crossing', True) + + def _crossing_needs_expansion(self, amps): + """True if `amps` carry folded crossings the current output cannot read. + + Only the folding-capable standalone backends consume the recorded + crossings directly (they reach them through the base's crossing-aware + SMATRIX/sigmaKin). Every other output needs them back as explicit + subprocesses, and expanding is always safe: it just reproduces the + complete unmerged (--use_crossing=False) output. + """ + if self._output_folds_crossings(): + return False + return any(amp.get('crossed_processes') for amp in amps + if 'crossed_processes' in amp) + + def _split_reorder_blocked(self, amps): + """Peel the flavor classes that keep a module compiled for no good reason. + + A merged module drops its own matrix element only when EVERY one of its + flavors is a crossing of some base's, so one stubborn class keeps the + whole thing alive -- always the same shape: a class the crossing reaches + only with two same-side legs the other way round (q q~ > q' q~' off + Q Q > Q Q, which I=0/J=5 delivers as (q~', q')). + + Peel it into a sibling GENERATED with those legs swapped and give the two + modules complementary halves of the flavors, so nothing is covered twice + and both halves match a crossing by exact signature -- no permutation at + run time, and so nothing to compose into the colour, helicity and + multi-channel maps coming back from the base. + + Opt-in (MG_SPLIT_CROSSING): it changes which subprocesses exist. + """ + import madgraph.iolibs.group_subprocs as group_subprocs + import madgraph.iolibs.export_v4 as export_v4 + + exporter = export_v4.ProcessExporterFortranMEGroup() + groups = group_subprocs.SubProcessGroup.group_amplitudes(amps, 'madevent') + by_legs = {} + for amp in amps: + by_legs.setdefault( + tuple(l.get('id') for l in amp.get('process').get('legs')), amp) + + def physical(rows, nini): + return set((tuple(p[:nini]), tuple(sorted(p[nini:]))) for p in rows) + + extra = [] + for group in groups: + group.generate_matrix_elements() + mes = group.get('matrix_elements') + try: + candidates = exporter.find_reorder_candidates(mes) + except Exception as err: + logger.debug('crossing split: detection failed (%s)' % err) + continue + for ime, peel in candidates.items(): + me = mes[ime] + _nx, nini = me.get_nexternal_ninitial() + key = tuple(l.get('id') for l in + me.get('processes')[0].get('legs')) + amp = by_legs.get(key) + if amp is None: + continue + classes, class_pdgs = \ + me.get_external_flavors_with_iden(return_pdgs=True) + classes, class_pdgs = list(classes), list(class_pdgs) + for flav0, sigma, _b, _iflav in peel: + sib = self._reordered_sibling(amp, sigma) + if sib is None: + continue + want = physical([tuple(p) for p in class_pdgs[flav0]], nini) + sib_me = helas_objects.HelasMultiProcess( + diagram_generation.AmplitudeList([sib]))\ + .get_matrix_elements()[0] + sib_cls, sib_pdgs = \ + sib_me.get_external_flavors_with_iden(return_pdgs=True) + sib_cls, sib_pdgs = list(sib_cls), list(sib_pdgs) + keep = [k for k in range(len(sib_cls)) + if physical([tuple(p) for p in sib_pdgs[k]], + nini) == want] + if len(keep) != 1: + logger.debug('crossing split: no unique matching class') + continue + me.set_excluded_flavors(classes[flav0]) + sib_me.set_excluded_flavors( + [f for k, cls in enumerate(sib_cls) if k != keep[0] + for f in cls]) + extra.append(sib) + logger.info('crossing split: peeled class %d of %s' + % (flav0 + 1, key)) + if not extra: + return amps + return diagram_generation.AmplitudeList(list(amps) + extra) + + def _reordered_sibling(self, amp, sigma): + """`amp` with its final legs permuted by `sigma`, diagrams regenerated. + + legs_with_decays is a CACHE of the flattened leg list and a copied + process brings the old one with it, so it has to be dropped: leave it and + the process reports the original order to everything that asks -- the + flavor tables and the crossed signatures included -- while the legs + themselves are reordered, and the two disagree silently. + """ + proc = copy.copy(amp.get('process')) + legs = proc.get('legs') + try: + new_legs = base_objects.LegList( + [copy.copy(legs[sigma[k]]) for k in range(len(legs))]) + except IndexError: + return None + for i, leg in enumerate(new_legs): + leg.set('number', i + 1) + proc.set('legs', new_legs) + proc.set('legs_with_decays', base_objects.LegList()) + sib = diagram_generation.Amplitude({'process': proc}) + sib.generate_diagrams() + if not sib.get('diagrams'): + return None + sib.set('has_mirror_process', amp.get('has_mirror_process')) + if 'crossed_processes' in sib: + sib.set('crossed_processes', []) + return sib + + def _expand_recorded_crossings(self, amps): + """Expand each amplitude's recorded crossings back into separate + (mirror-folded) amplitudes, reproducing a merge_crossing=False + generation. The crossed diagrams are reused (cross_amplitude), not + regenerated. Record mode stores a crossing and its beam-swap as two + separate entries (neither is in the amplitude list when the other is + met, so the generator's mirror check never fires); the beam-swap is + folded back into has_mirror_process here, exactly as + generate_matrix_elements would. + + Shared by the grouped and the ungrouped paths so that an output which + cannot read folded crossings gets them expanded automatically, without + the user having to pass --use_crossing=False. + """ + if self.options['group_subprocesses'] == 'Auto': + collect_mirror = True + else: + collect_mirror = self.options['group_subprocesses'] + + def _fastproc(amp): + return tuple(l.get('id') for l in amp.get('process').get('legs')) + + originals = [(amp, amp.get('crossed_processes') + if 'crossed_processes' in amp else []) + for amp in amps] + expanded = diagram_generation.AmplitudeList() + seen = {} # fast_proc -> amplitude, for mirror fold + for amp, _crossed in originals: + amp.set('crossed_processes', []) + expanded.append(amp) + seen[_fastproc(amp)] = amp + for amp, crossed in originals: + for (proc, base_perm, cross_perm) in crossed: + xamp = diagram_generation.MultiProcess.\ + cross_amplitude(amp, proc, base_perm, cross_perm) + xamp.set('crossed_processes', []) + fp = _fastproc(xamp) + mirror = (fp[1], fp[0]) + fp[2:] + if collect_mirror and mirror in seen and \ + proc.get_ninitial() == 2: + seen[mirror].set('has_mirror_process', True) + continue + xamp.set('has_mirror_process', False) + expanded.append(xamp) + seen[fp] = xamp + return expanded + + def _expand_crossings_for_ungrouped_output(self): + """Put folded crossings back for an output that cannot read them. + + Counterpart of the grouped path's expansion, for the ungrouped one. A + plain amplitude carries its crossings in `crossed_processes` and is + expanded in place; a decay chain records them on its inner amplitudes + instead, and its grouping does not survive a partial expansion, so the + affected chains are regenerated whole with merge_crossing=False (the + base diagrams are still reused by cross_amplitude). Either way the + result is exactly the complete unmerged output. + """ + dc_amps = [amp for amp in self._curr_amps + if isinstance(amp, diagram_generation.DecayChainAmplitude)] + non_dc_amps = diagram_generation.AmplitudeList( + [amp for amp in self._curr_amps + if not isinstance(amp, diagram_generation.DecayChainAmplitude)]) + + dc_crossed = not self._output_folds_crossings() and \ + any(a.get('crossed_processes') + for dc in dc_amps for a in dc.get('amplitudes') + if 'crossed_processes' in a) + expand_non_dc = self._crossing_needs_expansion(non_dc_amps) + if not dc_crossed and not expand_non_dc: + return + + if expand_non_dc: + non_dc_amps = self._expand_recorded_crossings(non_dc_amps) + + if dc_crossed: + ign6 = self.options.get('ignore_six_quark_processes', []) or [] + if self.options['group_subprocesses'] == 'Auto': + collect_mirror = True + else: + collect_mirror = self.options['group_subprocesses'] + dc_amps = [diagram_generation.DecayChainAmplitude( + procdef, collect_mirror, ign6, merge_crossing=False) + for procdef in self._curr_proc_defs + if procdef.get('decay_chains')] + + new_amps = diagram_generation.AmplitudeList() + new_amps.extend(non_dc_amps) + new_amps.extend(dc_amps) + new_amps.sort(key=lambda x: x.get_number_of_diagrams(), reverse=True) + self._curr_amps = new_amps + # Export a matrix element - def export(self, nojpeg = False, main_file_name = "", group_processes=True, + def set_color_basis_mode(self, *exporters): + """Set the color basis used for fully adjoint (multi-gluon) processes. + The (n-2)! Del Duca-Dixon-Maltoni basis can only be used by the output + formats which never need a color flow decomposition, so in 'auto' mode + every exporter involved must support it.""" + + mode = self.options.get('color_basis', 'auto') + exporters = [exporter for exporter in exporters if exporter] + if mode == 'auto': + use_ddm = bool(exporters) and \ + all(getattr(exporter, 'support_ddm_color_basis', False) + for exporter in exporters) + else: + use_ddm = (mode == 'ddm') + + # An exporter which has to write a color flow per event also needs the + # trace basis next to the DDM one + with_flow = any(getattr(exporter, 'ddm_needs_flow_basis', False) + for exporter in exporters) + + color_amp.set_ddm_basis(use_ddm, with_flow=with_flow) + if use_ddm: + logger.debug('Using the Del Duca-Dixon-Maltoni color basis for ' + 'fully adjoint processes (flow basis: %s)', with_flow) + + def export(self, nojpeg = False, main_file_name = "", group_processes=True, args=[]): """Export a generated amplitude to file.""" + self.set_color_basis_mode(self._curr_exporter, self._me_curr_exporter) + try: + return self._export(nojpeg, main_file_name, group_processes, args) + finally: + # the color basis is tied to this output, it must not leak to the + # next command + color_amp.set_ddm_basis(False) + + def _export(self, nojpeg = False, main_file_name = "", group_processes=True, + args=[]): + """Export a generated amplitude to file, with the color basis already + selected.""" + + # T-channel width treatment is now baked into ALOHA (the propagator + # routine drops the width i*M*Gamma for spacelike, P^2<0, momenta -- the + # correct tree-level treatment outside the complex-mass scheme). Propagate + # the zerowidth_tchannel option to the aloha flag it now controls, so the + # generated propagator routines carry the runtime sign check. A 1->N decay + # has no t-channel, so keep every width there (as the legacy code did). + zerowidth_tchannel = self.options['zerowidth_tchannel'] + if self._curr_amps and self._curr_amps[0].get_ninitial() == 1: + zerowidth_tchannel = False + aloha.t_channel_width = not zerowidth_tchannel # Define the helas call writer if hasattr(self._curr_exporter, 'helas_exporter') and self._curr_exporter.helas_exporter: self._curr_helas_model = self._curr_exporter.helas_exporter(self._curr_model, options=self.options) - elif self._curr_exporter.exporter == 'cpp': + elif self._curr_exporter.exporter == 'cpp': self._curr_helas_model = helas_call_writers.CPPUFOHelasCallWriter(self._curr_model) - elif self._curr_exporter.exporter == 'gpu': + elif self._curr_exporter.exporter == 'gpu': self._curr_helas_model = helas_call_writers.GPUFOHelasCallWriter(self._curr_model) elif self._curr_exporter.exporter == 'v4': if self._model_v4_path: self._curr_helas_model = helas_call_writers.FortranHelasCallWriter(self._curr_model) else: - options = {'zerowidth_tchannel': self.options['zerowidth_tchannel']} - if self._curr_amps and self._curr_amps[0].get_ninitial() == 1: - options['zerowidth_tchannel'] = False - self._curr_helas_model = helas_call_writers.FortranUFOHelasCallWriter(self._curr_model, - options=options) + self._curr_helas_model = helas_call_writers.FortranUFOHelasCallWriter(self._curr_model) else: raise Exception('unable to associate an helas format') @@ -9883,6 +10533,95 @@ def generate_matrix_elements(self, group_processes=True): grouping_criteria = self._curr_exporter.grouped_mode if grouping_criteria == 'gpu': grouping_criteria = 'madevent' + + # merge_crossing='record' skipped generating the crossed + # subprocesses so the standalone output collapses to one + # directory per base. The grouped (madevent) backends need + # them back as integration units -- each crossing is its own + # partonic channel with its own PDF/phase-space -- so expand + # the recorded metadata into crossed amplitudes here, reusing + # the base's diagrams via cross_amplitude (no diagram + # regeneration). The normal grouping + crossing routing then + # handles them exactly as an unmerged (merge_crossing=False) + # generation would. + # The standalone exporters ('standalone' fortran and + # 'standalone_mg7' cudacpp) consume the crossed_processes + # metadata directly -- they fold the crossings into the base + # directory and reach them through the base's crossing-aware + # SMATRIX/sigmaKin (extended flavor id), so they must NOT + # reconstruct. Every other (summation / event-generation) + # backend needs the crossings back as integration units, and + # reconstructing is also the safe default for any format that + # does not implement folding (it just reproduces the complete + # unmerged output) -- or for a folding backend told to write + # this output without the machinery (--use_crossing=False). + if not self._output_folds_crossings(): + # DecayAmplitude / DecayChainAmplitude are Amplitude + # subclasses that override default_setup with their own + # key set and do NOT carry crossed_processes (e.g. the + # compute_widths and MadSpin decay paths reach here), so + # guard on the dict key rather than the amplitude type + # (_crossing_needs_expansion does that). + if self._crossing_needs_expansion(non_dc_amps): + non_dc_amps = \ + self._expand_recorded_crossings(non_dc_amps) + + # Opt-in: peel the flavor classes that keep a module + # compiled only because the crossing reaches them with + # two same-side legs the other way round. + # madevent only: the peeled sibling pays off through the + # grouped-subprocess router (it is detected with + # ProcessExporterFortranMEGroup over a 'madevent' + # grouping), and the exporters that build one module per + # leg pattern cannot consume a pattern split in two -- + # mg7 raises "no valid flavor configurations found for + # diagram 2" on the half that no longer carries them. + if self._export_format == 'madevent' and \ + os.environ.get('MG_SPLIT_CROSSING', '').lower() \ + in ('on', '1', 'true'): + non_dc_amps = \ + self._split_reorder_blocked(non_dc_amps) + + # Decay chains: the crossing dedup (folding the crossed + # decay-chain subprocesses into the base's crossing-aware + # SMATRIX) is implemented for the standalone backends only. + # For the summation backends each crossed decay-chain + # subprocess must stay its own integration unit; rather + # than reconstruct-and-route them (whose grouping does not + # reproduce the historical layout), regenerate the affected + # decay chains fully (merge_crossing=False), giving exactly + # the pre-dedup output. cross_amplitude reuse still avoids + # regenerating the diagrams of the base subprocess. + if any(a.get('crossed_processes') + for dc in dc_amps for a in dc.get('amplitudes') + if 'crossed_processes' in a): + ign6 = self.options.get( + 'ignore_six_quark_processes', []) or [] + if self.options['group_subprocesses'] == 'Auto': + collect_mirror = True + else: + collect_mirror = \ + self.options['group_subprocesses'] + regenerated = \ + diagram_generation.DecayChainAmplitudeList() + for procdef in self._curr_proc_defs: + if not procdef.get('decay_chains'): + continue + regenerated.append( + diagram_generation.DecayChainAmplitude( + procdef, collect_mirror, ign6, + merge_crossing=False)) + # Regenerating walks _curr_proc_defs, so the + # diagram-count ordering _curr_amps was sorted into + # above is lost -- and that ordering decides the + # subprocess group numbering (P1_/P2_...). Restore it + # so the output is named exactly as an uncrossed + # generation would name it. + regenerated.sort( + key=lambda x: x.get_number_of_diagrams(), + reverse=True) + dc_amps = regenerated + if non_dc_amps: subproc_groups.extend(\ group_subprocs.SubProcessGroup.group_amplitudes(\ @@ -9919,8 +10658,15 @@ def generate_matrix_elements(self, group_processes=True): if uid == 0 and last_error: raise last_error else: # Not grouped subprocesses + # Same automatic expansion as the grouped path above: an + # ungrouped output (e.g. the ungrouped madevent) cannot read + # the folded crossings, so put them back as explicit + # subprocesses instead of forcing the user to regenerate with + # --use_crossing=False. Without this the crossings would be + # silently missing from the output. + self._expand_crossings_for_ungrouped_output() mode = {} - if self._export_format in [ 'standalone_msP' , + if self._export_format in [ 'standalone_msP' , 'standalone_msF', 'standalone_rw']: mode['mode'] = 'MadSpin' # The conditional statement tests whether we are dealing @@ -9970,6 +10716,24 @@ def generate_matrix_elements(self, group_processes=True): ndiags, cpu_time = generate_matrix_elements(self,group_processes) + # zerowidth_external: an external (initial/final) particle is an on-shell + # asymptotic state, so an internal propagator of the same field must not + # carry the i*M*Gamma resonance term (e.g. the s/u-channel top in + # t a > t a). Drop that width per matrix element before any backend + # writes the propagator calls (all UFO backends read the wavefunction + # width). Tree-level only; complex-mass scheme then uses the real mass. + if self.options.get('zerowidth_external', True) and \ + self._curr_matrix_elements.get_matrix_elements(): + n_dropped = 0 + for me in self._curr_matrix_elements.get_matrix_elements(): + if me.set_onshell_particles_width_to_zero(): + n_dropped += 1 + if n_dropped: + logger.info("Some on-shell (external) particle widths have been " + "set to zero in their internal propagators [new]\n if " + "you want to keep them set \"zerowidth_external\" to " + "False", '$MG:BOLD') + calls = 0 @@ -10167,7 +10931,12 @@ def finalize(self, nojpeg, online = False, flaglist=[]): wanted_lorentz = self._curr_matrix_elements.get_used_lorentz() wanted_couplings = self._curr_matrix_elements.get_used_couplings() - if self._export_format == 'madevent' and not 'no_helrecycling' in flaglist and \ + # Standalone --hel_recycling reuses the madevent recycling machinery, + # which needs the P1N (amplitude-split) variant of every used routine. + sa_hel_recycling = str(getattr(self._curr_exporter, 'cmd_options', {}).get( + 'hel_recycling', False)).lower() in ('true', '1', 'yes') + if (self._export_format == 'madevent' or sa_hel_recycling) and \ + not 'no_helrecycling' in flaglist and \ not isinstance(self._curr_amps[0], loop_diagram_generation.LoopAmplitude): for (name, flag, out) in wanted_lorentz[:]: if out == 0: @@ -10494,7 +11263,7 @@ def do_compute_widths(self, line, model=None, do2body=True, decaymodel=None): logger_mg.info('More info in temporary files:\n %s/index.html' % (decay_dir)) with misc.MuteLogger(['madgraph','ALOHA','cmdprint','madevent'], [40,40,40,40]): self.exec_cmd('output madevent %s -f' % decay_dir,child=False) - + #modify some parameter of the default run_card run_card = banner_module.RunCard(pjoin(decay_dir,'Cards','run_card.dat')) if run_card['ickkw']: diff --git a/madgraph/interface/master_interface.py b/madgraph/interface/master_interface.py index b18de52528..92c2da2d2e 100755 --- a/madgraph/interface/master_interface.py +++ b/madgraph/interface/master_interface.py @@ -612,6 +612,9 @@ def help_set2_include_lepton_initiated_processes(self, *args, **opts): def help_set2_loop_color_flows(self, *args, **opts): return self.cmd.help_set2_loop_color_flows(self, *args, **opts) + def help_set2_merge_quartic_vertices(self, *args, **opts): + return self.cmd.help_set2_merge_quartic_vertices(self, *args, **opts) + def help_set2_loop_optimized_output(self, *args, **opts): return self.cmd.help_set2_loop_optimized_output(self, *args, **opts) @@ -627,8 +630,14 @@ def help_set2_nlo_mixed_expansion(self, *args, **opts): def help_set2_output_dependencies(self, *args, **opts): return self.cmd.help_set2_output_dependencies(self, *args, **opts) + def help_set2_color_basis(self, *args, **opts): + return self.cmd.help_set2_color_basis(self, *args, **opts) + def help_set2_zerowidth_tchannel(self, *args, **opts): return self.cmd.help_set2_zerowidth_tchannel(self, *args, **opts) + + def help_set2_zerowidth_external(self, *args, **opts): + return self.cmd.help_set2_zerowidth_external(self, *args, **opts) def help_tutorial(self, *args, **opts): diff --git a/madgraph/interface/reweight_interface.py b/madgraph/interface/reweight_interface.py index a2ddef2c4f..75d484ac50 100755 --- a/madgraph/interface/reweight_interface.py +++ b/madgraph/interface/reweight_interface.py @@ -118,7 +118,12 @@ def __init__(self, event_path=None, allow_madspin=False, mother=None, *completek self.use_eventid = False self.inc_sudakov = False self.event_path = event_path - self.path2prefix = {} # store the f2pyprefix associated to a library + self.path2prefix = {} # store the f2pyprefix associated to a library + # id_to_path-style tag -> folded crossed subprocesses reachable through + # a base matrix element (see build_cross_resolve); empty when crossing + # folded nothing. + self.cross_resolve = {} + self.cross_resolve_second = {} if event_path: logger.info("Extracting the banner ...") self.do_import(event_path, allow_madspin=allow_madspin) @@ -1596,6 +1601,28 @@ def _get_revert_merged_for(self, model): rm[val] = key return rm + def _pdg_for_me_call(self, event, orig_order, momenta, relevant_model): + """Return the PDG list to hand to the fortran for one permutation. + + orig_order holds the leg ids of the generated process, which under + flavor grouping are the merged codes (81 for jets, 82 for charged + leptons, ...). Those are SIGNED: a subprocess whose grouped legs are + anti-particles -- g q~ > w+ q~, whose get_pdg_order is + [21,-81,24,-81] -- carries the negative code. model['merged_particles'] + is keyed by the POSITIVE code only ({81: [1,2,3,4], ...}), hence the + abs(): without it the merged labels are handed to the fortran as-is, + the flavor mapping there resolves them to "no flavour", and both + SMATRIXHEL and GET_DENSITY return an exact zero. + + Kept in one place because both calculate_matrix_element implementations + (matrix element and density matrix) need exactly this.""" + pdg = list(orig_order[0]) + list(orig_order[1]) + merged = relevant_model.get('merged_particles') if relevant_model \ + else self.merged_particles + if merged and any(abs(p) in merged for p in pdg): + return event.get_pdg(momenta) + return pdg + def calculate_matrix_element(self, event, hypp_id, scale2=0): """routine to return the matrix element""" @@ -1635,19 +1662,33 @@ def calculate_matrix_element(self, event, hypp_id, scale2=0): #else: # base = "rw_me" + # A crossed subprocess folded in by crossing symmetry has no id_to_path + # entry of its own; it is reached through the base's crossing-aware + # SMATRIX at an extended flavor index (see build_cross_resolve). Stays + # None for every ordinary lookup. + flav_idx = procindex = None if (not self.second_model and not self.second_process and not self.dedicated_path) or hypp_id==0: if tag in self.id_to_path: orig_order, Pdir, hel_dict = self.id_to_path[tag] else: cross_tag = self.get_crossing_tag(tag) - orig_order, Pdir, hel_dict = self.id_to_path[cross_tag] + folded = None if cross_tag else self.resolve_folded_crossing( + tag, tag_orig, self.cross_resolve) + if folded: + orig_order, Pdir, hel_dict, procindex, flav_idx = folded + else: + orig_order, Pdir, hel_dict = self.id_to_path[cross_tag] else: try: orig_order, Pdir, hel_dict = self.id_to_path_second[tag] except KeyError: cross_tag = self.get_crossing_tag(tag) + folded = None if cross_tag else self.resolve_folded_crossing( + tag, tag_orig, self.cross_resolve_second) if cross_tag: orig_order, Pdir, hel_dict = self.id_to_path[cross_tag] + elif folded: + orig_order, Pdir, hel_dict, procindex, flav_idx = folded elif self.options['allow_missing_finalstate']: return 0.0 else: @@ -1679,10 +1720,7 @@ def calculate_matrix_element(self, event, hypp_id, scale2=0): else: nhel = -1 - pdg = list(orig_order[0])+list(orig_order[1]) - relevant_merged = relevant_model.get('merged_particles') if relevant_model else self.merged_particles - if relevant_merged and any(p in relevant_merged for p in pdg): - pdg = event.get_pdg(all_p[0]) + pdg = self._pdg_for_me_call(event, orig_order, all_p[0], relevant_model) #boosting the event all_p = self.method_boost_event(event, all_p, orig_order, hypp_id) @@ -1712,7 +1750,14 @@ def calculate_matrix_element(self, event, hypp_id, scale2=0): with misc.chdir(Pdir): with misc.stdchannel_redirected(sys.stdout, os.devnull): #misc.sprint(pdg, pid, p, event.aqcd, scale2, nhel) - new_value = module.smatrixhel(pdg, pid, p, event.aqcd, scale2, nhel) + if flav_idx is None: + new_value = module.smatrixhel(pdg, pid, p, event.aqcd, scale2, nhel) + else: + # folded crossing: name the matrix element by its slot + # and the process by the extended flavor index, the PDG + # dispatch cannot reach it. NPDG is f2py-derived from p. + new_value = module.smatrixhel_idx(procindex, flav_idx, p, + event.aqcd, scale2, nhel) #misc.sprint(new_value) if new_value == 0: raise Exception("Invalid matrix element") @@ -1741,14 +1786,25 @@ def get_crossing_tag(self,tag): """find if using crossing symmetry allow to find the correct tag and return the assoicated tag""" # get list of possible crossing tag - crossing_tag = [tuple([int(x) for x in sorted(list(t[0])+list(t[1]))]) for t in self.id_to_path.keys()] + # id_to_path is not uniformly keyed: the NLO path also stores the + # virtual matrix element under ((initial, final), 'V'), so t[1] can be a + # string rather than a list of PDGs. Only a plain (initial, final) pair + # can carry a crossing, so skip anything else instead of trying to sort + # a string against a tuple. + crossing_tag = [] + for t in self.id_to_path.keys(): + try: + crossing_tag.append( + tuple([int(x) for x in sorted(list(t[0]) + list(t[1]))])) + except (TypeError, ValueError): + continue mytag = list(tag[0])+list(tag[1]) if self.revert_merged: for i in range(len(mytag)): if mytag[i] in self.revert_merged: mytag[i] = self.revert_merged[mytag[i]] - if -mytag[i] in self.revert_merged: + elif -mytag[i] in self.revert_merged: mytag[i] = -self.revert_merged[-mytag[i]] mytag.sort() mytag=tuple(mytag) @@ -1893,10 +1949,30 @@ def create_standalone_tree_directory(self, data ,second=False): else: logger.info('generating the square matrix element for reweighting (second model and/or processes)') start = time.time() + # The reweight matches each event's flavor to a subprocess matrix + # element (id_to_path), and reaches a FOLDED crossed subprocess through + # the base's crossing-aware SMATRIX (see build_cross_resolve), so the + # crossings stay folded: the crossed subprocesses cost neither a + # generation nor a compilation. + # + # Two modes still want the crossed subprocesses back as separate entries: + # - 'keep_ordering' promises that the events are written in the matrix + # element's own leg order, which makes the id_to_path key + # order-sensitive; a folded crossing has no directory and hence no + # such order to promise, so a crossed event would miss the lookup. + # - the density mode evaluates GET_DENSITY, not SMATRIX, and only its + # FLAVOR-array entry point is wired up here; the FLAVOR array cannot + # express a crossing (GET_DENSITY_IDX would be needed, as MadSpin's + # density path does it). + # Perturbative (NLO / ewsudakov [...]) definitions are left untouched: + # they already skip crossing at generation, and the flag must not land + # inside their option-laden line. + xflag = ' --use_crossing=False' \ + if (self.keep_ordering or self.flag_density_matrix) else '' commandline='' for i,proc in enumerate(data['processes']): if '[' not in proc: - commandline += "add process %s ;" % proc + commandline += "add process %s%s ;" % (proc, xflag) else: has_nlo = True if self.banner.get('run_card','ickkw') == 3: @@ -1907,15 +1983,6 @@ def create_standalone_tree_directory(self, data ,second=False): self.model, real_only=True, ewsudakov=self.inc_sudakov) else: commandline += self.get_LO_definition_from_NLO(proc, self.model, ewsudakov=self.inc_sudakov) - # --no_crossing skips the generation of crossed subprocesses (e.g. - # u~ g > h u~ when u g > h u is already there). That's fine when - # flavor grouping is on, because the merged matrix element handles - # all signs internally. Without flavor grouping, however, the - # crossed subprocesses must be generated as separate entries -- - # otherwise antiparticle events have nothing to match against in - # id_to_path. Only emit --no_crossing when both conditions hold. - if not self.keep_ordering and self._reweight_use_flavor_grouping(): - commandline = commandline.replace('add process', 'add process --no_crossing') commandline = commandline.replace('add process', 'generate',1) logger.info(commandline) try: @@ -2158,7 +2225,7 @@ def load_interface_model(self, second=False): #if not self.keep_ordering: # for i,line in enumerate(data['processes']): - # data['processes'][i] = '%s --no_crossing' % line + # data['processes'][i] = '%s --use_crossing=False' % line # 0. clean previous run ------------------------------------------------ @@ -2382,6 +2449,8 @@ def load_module(self, metag=1): self.id_to_path = {} self.id_to_path_second = {} + self.cross_resolve = {} + self.cross_resolve_second = {} rwgt_dir_possibility = ['rw_me','rw_me_%s' % self.nb_library,'rw_mevirt','rw_mevirt_%s' % self.nb_library] fprefix = '' for onedir in rwgt_dir_possibility: @@ -2448,8 +2517,10 @@ def load_module(self, metag=1): data = self.id_to_path + cross_data = self.cross_resolve if onedir not in ["rw_me", "rw_mevirt"]: data = self.id_to_path_second + cross_data = self.cross_resolve_second # get all the information @@ -2521,8 +2592,218 @@ def load_module(self, metag=1): misc.sprint(order, pdir,) raise Exception( "two different matrix-element have the same initial/final state. Leading to an ambiguity. If your events are ALWAYS written in the correct-order (look at the numbering in the Feynman Diagram). Then you can add inside your reweight_card the line 'change keep_ordering True'." ) data[tag] = order, pdir, hel - - + + # The merged-particle convention of the model that built `data`: + # get_pdg_order (hence every id_to_path key) may speak merged codes, + # and the crossed subprocesses must be keyed the same way. + if onedir in ("rw_me", "rw_mevirt"): + cross_model = getattr(self, 'original_model', None) or self.model + else: + cross_model = self.model + if cross_model is not None: + merged_map = self._get_revert_merged_for(cross_model) + else: + # restored from a pickle without a model loaded: the saved map + merged_map = getattr(self, 'revert_merged', None) + self.build_cross_resolve(mymod, all_prefix, all_pdgs, hel_dict, + pdir, 'virt' in onedir, cross_data, + merged_map) + + def build_cross_resolve(self, mymod, all_prefix, all_pdgs, hel_dict, pdir, + is_virt, cross_data, merged_map): + """Add to `cross_data` every CROSSED subprocess folded into this + module's matrix elements, keyed exactly like id_to_path. + + With crossing on (merge_crossing='record') a crossed subprocess is not + generated as a directory of its own: the base's crossing-aware SMATRIX + evaluates it at an *extended* flavor index (FLAV_IDX = cross*NFLAV+flav), + so it has no get_pdg_order entry and id_to_path cannot see it -- a + crossed event would silently lose its weight. The per-process f2py entry + points PY_GET_FLAVOR_LAYOUT / GET_PDG_FOR_FLAVOR let us walk that + index space and ask each entry which process it evaluates, restricted to + the crossings the generation actually recorded (crossed_flavors.dat -- + the runtime space also holds crossings that are merely applicable, e.g. a + Z pulled into the initial state, and evaluating one of those for an event + would produce a wrong weight rather than no weight). + + A matrix element covers several subprocesses in two independent ways, and + the crossing has to be applied to each: as FLAVOR indices inside one + get_pdg_order entry (flavor grouping: 81 for jets), and as several + get_pdg_order entries sharing one prefix (the exporter combining + processes with an identical matrix element, e.g. g u > h u and g s > h s). + So each recorded crossing is applied to EVERY base entry of the prefix, + by permuting and conjugating its PDGs the way GET_PDG_FOR_FLAVOR did for + the representative -- which is what makes the tags come out in the same + vocabulary the base entries use. + + Each entry maps an id_to_path-style tag to a LIST of candidates + ``(order, pdir, hel, procindex, flav_idx, pdgs)``, one per flavor of the + tag's merged matrix element: the matrix element is NOT flavor blind + across those -- g d > z d and g u > z u differ by ~25% -- so the flavor is + picked per event from the signed PDGs (see resolve_folded_crossing). + `procindex` is the 1-based get_prefix slot the crossing-aware + SMATRIXHEL_IDX dispatch expects. + + The helicity dictionary is the base one, unchanged: SMATRIX applies the + crossing to its whole NHEL table before the helicity loop, which makes + the helicity configuration selected by row r the base row r read + positionally in the crossed leg order (verified against independently + generated crossed subprocesses, per helicity).""" + codes = self.get_recorded_crossings(pdir) + if not codes: + return + import madgraph.iolibs.export_v4 as export_v4 + get_perm = export_v4.ProcessExporterFortran.get_crossing_permutation + # merged codes (81, ...) as they appear in a base entry, i.e. the legs + # whose flavor a base entry leaves open and the flavor index resolves. + labels = set(merged_map.values()) if merged_map else set() + slots = {} + for i, (prefix, pdgs) in enumerate(zip(all_prefix, all_pdgs), 1): + slots.setdefault(prefix, []).append((i, [int(x) for x in pdgs])) + for prefix, entries in slots.items(): + if not codes.get(prefix): + continue + layout = getattr(mymod, 'py_%sget_flavor_layout' % prefix, None) + get_pdg = getattr(mymod, 'py_%sget_pdg_for_flavor' % prefix, None) + if layout is None or get_pdg is None: + # matrix element written without the crossing machinery + continue + nflav, nexternal, ncross = (int(x) for x in layout()) + entries = [e for e in entries if len(e[1]) == nexternal] + for cross in codes[prefix]: + if not 0 < cross < ncross: + continue # 0 is the base, already in id_to_path + perm, ic, valid = get_perm(cross, nexternal) + if not valid: + continue + for flav in range(1, nflav+1): + crossed = [int(x) for x in get_pdg(cross*nflav + flav)] + if not any(crossed): + continue # names no valid flavor/crossing + base = [int(x) for x in get_pdg(flav)] + # Which legs the crossing conjugated: those it moved between + # the initial and the final state, except a self-conjugate + # one (a gluon crossed to the other side is still a gluon). + # Read off the representative rather than from the model, so + # that this needs nothing but the generated entry points. + conj = [ic[k] == -1 and crossed[k] != base[perm[k]] + for k in range(nexternal)] + for (procindex, pdgs) in entries: + xpdgs = [-pdgs[perm[k]] if conj[k] else pdgs[perm[k]] + for k in range(nexternal)] + # The physical process this candidate evaluates: the + # crossed base entry, with the legs it leaves merged + # resolved by the flavor index. + phys = [crossed[k] if abs(xpdgs[k]) in labels + else xpdgs[k] for k in range(nexternal)] + tag, order = self.tag_from_pdgs(xpdgs) + if is_virt: + tag = (tag, 'V') + cross_data.setdefault(tag, []).append( + (order, pdir, hel_dict.get(prefix, {}), + procindex, cross*nflav + flav, phys)) + + def tag_from_pdgs(self, pdgs): + """(tag, order) of a subprocess given its per-leg PDG codes, in the same + convention load_module uses to key id_to_path.""" + if self.is_decay: + incoming, outgoing = [pdgs[0]], list(pdgs[1:]) + else: + incoming, outgoing = list(pdgs[0:2]), list(pdgs[2:]) + order = (list(incoming), list(outgoing)) + incoming.sort() + if not self.keep_ordering: + outgoing.sort() + return (tuple(incoming), tuple(outgoing)), order + + def get_recorded_crossings(self, pdir): + """{prefix: [cross codes]} of the crossed subprocesses folded into the + matrix elements of `pdir`, from the crossed_flavors.dat written at output + time (see export_v4.write_crossing_records). + + An absent file means an output produced before crossings were recorded, + hence one with nothing folded; an empty list for a prefix means that + matrix element folds no crossing.""" + path = pjoin(pdir, 'crossed_flavors.dat') + if not os.path.exists(path): + return {} + codes = {} + for line in open(path): + line = line.split('#', 1)[0].split() + if not line: + continue + prefix, complete = line[0].lower(), line[1] == '1' + codes[prefix] = [int(c) for c in line[2:]] + if not complete: + logger.warning('Crossing symmetry folded a subprocess into the ' + 'matrix element %s that could not be resolved ' + 'back to a flavor. An event of that flavor will ' + 'stop the reweighting rather than be given a ' + 'wrong weight; if that happens, rerun with ' + '"change keep_ordering True" (which keeps the ' + 'crossed subprocesses separate) and report it.', + prefix) + return codes + + def resolve_folded_crossing(self, tag, phys_tag, cross_data): + """(order, Pdir, hel, procindex, flav_idx) of the folded crossed + subprocess matching an event, or None. + + `tag` is the (merged) id_to_path key the event was looked up with and + `phys_tag` its signed *physical* PDG twin. All candidates under one tag + share the merged flavor pattern, so the physical PDGs pick which flavor + of the merged matrix element the event actually is -- the matrix element + is not flavor blind. A candidate leg that the flavor index does not + resolve keeps its merged label and matches any member of the group.""" + candidates = cross_data.get(tag) if cross_data else None + if not candidates: + return None + merged = self.revert_merged_groups() + ninitial = 1 if self.is_decay else 2 + for (order, Pdir, hel, procindex, flav_idx, pdgs) in candidates: + if self.pdgs_match_event(pdgs, ninitial, phys_tag, merged): + return order, Pdir, hel, procindex, flav_idx + return None + + def revert_merged_groups(self): + """{merged code: [member pdgs]} of the model the current lookup uses + (empty without flavor grouping).""" + if not self.revert_merged: + return {} + groups = {} + for pdg, code in self.revert_merged.items(): + groups.setdefault(code, []).append(pdg) + return groups + + @staticmethod + def pdgs_match_event(pdgs, ninitial, phys_tag, merged): + """Can `pdgs` (a subprocess' per-leg codes, possibly carrying merged + labels) be the event whose physical tag is `phys_tag`? Compared as + multisets per side, a merged label absorbing any one member flavor of the + same sign. Merged groups are disjoint, so the greedy assignment below is + exact.""" + sides = ((pdgs[:ninitial], phys_tag[0]), (pdgs[ninitial:], phys_tag[1])) + for legs, want in sides: + left = list(want) + labels = [] + for pdg in legs: + if abs(pdg) in merged: + labels.append(pdg) + elif pdg in left: + left.remove(pdg) + else: + return False + for pdg in labels: + hit = next((q for q in left if (q > 0) == (pdg > 0) + and abs(q) in merged[abs(pdg)]), None) + if hit is None: + return False + left.remove(hit) + if left: + return False + return True + + def load_model(self, name, use_mg_default, complex_mass=False, ew_scheme=None): """load the model""" @@ -2703,7 +2984,153 @@ def load_from_pickle(self, keep_name=False): - + +#=============================================================================== +# Helper functions for the average density matrix (density mode) +# +# Those are module level functions since the average density matrix is written +# either by DensityInterface itself (single core) or by the mother interface +# recombining the output of the various multicore jobs +# (common_run_interface.do_reweight). +#=============================================================================== +def parse_matrix_normalisation(value): + """interpret the argument of 'change matrix_normalisation'. + return (value, understood) where understood is False if the argument is + neither 'True' nor 'False' (in which case the normalisation is disabled)""" + + value = value.strip("[],()") + if value == 'True': + return True, True + elif value == 'False': + return False, True + return False, False + +def get_matrix_normalisation(card_path): + """return the matrix_normalisation option of a density mode reweight card. + The default (option absent from the card) is the one of DensityInterface.""" + + matrix_normalisation = True # default value of DensityInterface + if not card_path or not os.path.exists(card_path): + return matrix_normalisation + + with open(card_path) as card: + for line in card: + line = line.strip() + if not line or line[0] in ('#', '!'): + continue + split_line = line.split() + if len(split_line) < 3 or split_line[0] != 'change' or \ + split_line[1] != 'matrix_normalisation': + continue + # last occurence wins, as when the card is executed line by line + matrix_normalisation, _ = parse_matrix_normalisation(split_line[2]) + return matrix_normalisation + +def average_density_matrix_label(lhe_path): + """the label identifying an event file in the average density matrix output""" + + if lhe_path.endswith('.gz'): + lhe_path = lhe_path[:-3] + return os.path.basename(lhe_path)[:-4] + +def average_density_matrix_path(lhe_path, output_dir=None): + """the canonical path of the average density matrix associated to an event file""" + + if output_dir is None: + output_dir = os.path.dirname(lhe_path) + return pjoin(output_dir, + "Average_density_matrix_%s.txt" % average_density_matrix_label(lhe_path)) + +def write_average_density_matrix(rho_avg, lhe_path, output_dir=None): + """log the average density matrix rho_avg (line form) and write it in square + form next to the event file it has been computed from. return the path used.""" + + import madgraph.various.Density_functions as dens + + rho_avg_square = dens.DensityMatrixObservables(rho_avg).square_matrix() + + logger.info("Average density matrix:") + for i in range(len(rho_avg_square)): + print("\t",list(rho_avg_square[i])) + + path = average_density_matrix_path(lhe_path, output_dir) + file_density = open(path, 'w') + file_density.write(f'Average density matrix of LHE file {average_density_matrix_label(lhe_path)}:\n') + # Cast each entry to a plain Python ``complex`` so that the file is + # written in the legacy ``(re+imj)`` repr regardless of the underlying + # numpy dtype (newer numpy prints np.complex64 values with a + # ``np.complex64(...)`` wrapper which the consumer parser cannot read). + for i in range(len(rho_avg_square)): + row = [complex(v) for v in rho_avg_square[i]] + file_density.write('\t' + str(row) + '\n') + file_density.close() + return path + +def average_density_matrix_from_lhe(lhe_path, matrix_normalisation=True): + """re-compute the average density matrix from the tag of every + event of an already reweighted event file. This reproduces exactly what + DensityInterface.launch_actual_reweighting accumulates on the fly: + - matrix_normalisation True: the per event matrices stored in the file are + already normalised by their trace, the average is weighted by the weight + of the events. + - matrix_normalisation False: the per event matrices are the raw ones, the + average is a plain average over the events. + return the average density matrix in line form (None if no event of the file + carries a density matrix).""" + + average_rho = None + total_wgt = 0. + nb_event = 0 + + lhe = lhe_parser.EventFile(lhe_path) + lhe.parsing = "wgt_only" # we only need the weight and the tag + for event in lhe: + if not event.density: + continue + if matrix_normalisation: + contrib = [value * event.wgt for value in event.density] + total_wgt += event.wgt + else: + contrib = event.density + nb_event += 1 + if average_rho is None: + average_rho = list(contrib) + elif len(contrib) != len(average_rho): + raise Exception("Inconsistent size of the density matrices within %s" % lhe_path) + else: + for i in range(len(average_rho)): + average_rho[i] += contrib[i] + lhe.close() + + if average_rho is None: + return None + + norm = total_wgt if matrix_normalisation else nb_event + return [value / norm for value in average_rho] + +def combine_density_matrix(lhe_path, chunk_paths=(), reweight_card=None): + """write the canonical average density matrix of lhe_path after a multicore + reweighting: each job has written the average of its own chunk of events, so + the average of the full (recombined) file is re-computed here and the per + chunk files are removed. return the path of the file written (None if the + average could not be computed).""" + + matrix_normalisation = get_matrix_normalisation(reweight_card) + rho_avg = average_density_matrix_from_lhe(lhe_path, matrix_normalisation) + + if rho_avg is None: + # keep the per chunk files: they are the only output left in that case + logger.warning("No density matrix found in %s: the average density matrix is not written." % lhe_path) + return None + + path = write_average_density_matrix(rho_avg, lhe_path) + for chunk in chunk_paths: + chunk_path = average_density_matrix_path(chunk) + if chunk_path != path and os.path.exists(chunk_path): + os.remove(chunk_path) + return path + + class DensityInterface(ReweightInterface): """Basic interface for computing density matrix""" @@ -2927,15 +3354,9 @@ def do_change_matrix_normalisation(self,line): Choses if the production matrix should be normalised by its trace or not. Default = True """ - for i in range(len(line)): - line[i] = line[i].strip("[],()") - if line[0] == 'True': - self.matrix_normalisation = True - elif line[0] == 'False': - self.matrix_normalisation = False - else: + self.matrix_normalisation, understood = parse_matrix_normalisation(line[0]) + if not understood: logger.warning('Option matrix_normalisation not understood, set it to True. Please use the syntax: change matrix_normalisation True if you want to enable it.') - self.matrix_normalisation = False def do_change_particle_in_density_matrix(self, line): @@ -3115,22 +3536,8 @@ def launch_actual_reweighting(self, param_card_iterator, for i in range(len(rho_avg)): rho_avg[i] = self.average_rho[i] / self.nevents - rho_avg_instance = dens.DensityMatrixObservables(rho_avg) - rho_avg_square = rho_avg_instance.square_matrix() - - logger.info("Average density matrix:") - for i in range(len(rho_avg_square)): - print("\t",list(rho_avg_square[i])) - file_density = open(pjoin(os.path.dirname(self.event_path), f"Average_density_matrix_{os.path.basename(self.lhe_input.name)[:-4]}.txt"), 'w') - file_density.write(f'Average density matrix of LHE file {os.path.basename(self.lhe_input.name)[:-4]}:\n') - # Cast each entry to a plain Python ``complex`` so that the file is - # written in the legacy ``(re+imj)`` repr regardless of the underlying - # numpy dtype (newer numpy prints np.complex64 values with a - # ``np.complex64(...)`` wrapper which the consumer parser cannot read). - for i in range(len(rho_avg_square)): - row = [complex(v) for v in rho_avg_square[i]] - file_density.write('\t' + str(row) + '\n') - file_density.close() + write_average_density_matrix(rho_avg, self.lhe_input.name, + output_dir=os.path.dirname(self.event_path)) if self.output_type == "default": @@ -3238,10 +3645,7 @@ def calculate_matrix_element(self, event, hypp_id, scale2=0): else: nhel = -1 - pdg = list(orig_order[0])+list(orig_order[1]) - relevant_merged = relevant_model.get('merged_particles') if relevant_model else self.merged_particles - if relevant_merged and any(p in relevant_merged for p in pdg): - pdg = event.get_pdg(all_p[0]) + pdg = self._pdg_for_me_call(event, orig_order, all_p[0], relevant_model) #list_properties is the list of properties of the class FourMomentum that we can use to rank particles list_properties = [p for p in dir(lhe_parser.FourMomentum) if isinstance(getattr(lhe_parser.FourMomentum,p),property)] @@ -3334,6 +3738,21 @@ def calculate_matrix_element(self, event, hypp_id, scale2=0): rho_instance = dens.DensityMatrixObservables(production_matrix, self.number_combinations * (self.number_combinations + 1) / 2) new_value = rho_instance.density_matrix + # An identically-zero density matrix is not a physical answer for an + # event that is in the event file: either GET_DENSITY could not resolve + # the flavour of the legs it was handed (typically merged-particle + # labels 81/82/... reaching the fortran instead of the concrete PDGs), + # or 'allowed_helicities' selects a helicity configuration that carries + # no amplitude. Do not average it in: the trace is zero, so with + # matrix_normalisation on the normalisation is 0/0 and the average + # density matrix comes back as NaN; with it off the event silently + # dilutes the average. Refuse loudly, as the matrix-element path does. + if not any(new_value): + raise Exception("Invalid density matrix: only zeros returned for " + "event %s (pdg %s). Check that the flavour of every leg can be " + "resolved and that 'allowed_helicities' selects a contributing " + "helicity configuration." % (getattr(event, 'ievent', -1), list(pdg))) + return new_value diff --git a/madgraph/iolibs/export_cpp.py b/madgraph/iolibs/export_cpp.py index 354815b369..48c790bb0f 100755 --- a/madgraph/iolibs/export_cpp.py +++ b/madgraph/iolibs/export_cpp.py @@ -39,10 +39,12 @@ import madgraph.iolibs.file_writers as writers import madgraph.iolibs.template_files as template_files import madgraph.iolibs.ufo_expression_parsers as parsers +import madgraph.loop.loop_diagram_generation as loop_diagram_generation import madgraph.various.banner as banner_mod from madgraph import MadGraph5Error, InvalidCmd, MG5DIR from madgraph.iolibs.files import cp, ln, mv +import madgraph.iolibs.export_v4 as export_v4 from madgraph.iolibs.export_v4 import VirtualExporter, ProcessExporterFortran import madgraph.various.misc as misc @@ -705,6 +707,10 @@ def __init__(self, matrix_elements, cpp_helas_call_writer, process_string = "", self.process_name = self.get_process_name() self.process_class = "CPPProcess" + # Emit the crossing-symmetry machinery (extended flavor_id carrying a + # crossing). Off by default; ProcessExporterCPP.generate_subprocess_- + # directory turns it on for standalone_cpp when --use_crossing is set. + self.use_crossing = False self.path = path self.helas_call_writer = cpp_helas_call_writer @@ -936,6 +942,8 @@ def get_process_class_definitions(self, write=True): """The complete class definition for the process""" replace_dict = {} + # Default (no-crossing) fill; overridden in the single_helicities branch. + replace_dict['cross_member_decl'] = '' # Extract model name replace_dict['model_name'] = self.model_name @@ -967,7 +975,8 @@ def get_process_class_definitions(self, write=True): replace_dict['nprocesses'] = self.nprocesses - color_amplitudes = self.matrix_elements[0].get_color_amplitudes() + color_amplitudes = self.matrix_elements[0].get_color_amplitudes( + merge_quartic_amplitudes=False) # Number of color flows replace_dict['ncolor'] = len(color_amplitudes) @@ -981,15 +990,18 @@ def get_process_class_definitions(self, write=True): replace_dict['wfct_size'] = wfct_size + cross_repl = self.get_crossing_replace_dict(self.matrix_elements[0]) + replace_dict['cross_member_decl'] = cross_repl['cross_member_decl'] replace_dict['all_sigma_kin_definitions'] = \ """// Calculate wavefunctions - void calculate_wavefunctions(const int perm[], const int hel[], const int flavor[]); + void calculate_wavefunctions(const int perm[], const int hel[], const int flavor[]%(cross_cw_sig_extra)s); static const int nwavefuncs = %(nwfct)d; MG5_%(model_name)s::ALOHAOBJ w[nwavefuncs]; """ % \ {'nwfct':len(self.wavefunctions), 'sizew': wfct_size, - 'model_name': self.model_name + 'model_name': self.model_name, + 'cross_cw_sig_extra': cross_repl['cross_cw_sig_extra'], } replace_dict['all_matrix_definitions'] = \ @@ -1045,7 +1057,7 @@ def get_process_function_definitions(self, write=True): # Extract process class name (for the moment same as file name) replace_dict['process_class_name'] = self.process_name - color_amplitudes = [me.get_color_amplitudes() for me in \ + color_amplitudes = [me.get_color_amplitudes(merge_quartic_amplitudes=False) for me in \ self.matrix_elements] replace_dict['initProc_lines'] = \ @@ -1069,7 +1081,11 @@ def get_process_function_definitions(self, write=True): process = self.matrix_elements[0].get('processes')[0] sym_data = ProcessExporterFortran._get_broken_symmetry_data(process, nincoming) ProcessExporterFortran._fill_broken_sym_replace_dict(replace_dict, sym_data) - + + # ident_cross() companion of broken_sym() (empty unless crossing is on). + replace_dict['ident_cross_function'] = \ + self.get_crossing_replace_dict(self.matrix_elements[0])['ident_cross_function'] + if write: file = self.read_template_file(self.process_definition_template) %\ replace_dict @@ -1177,6 +1193,10 @@ def get_calculate_wavefunctions(self, wavefunctions, amplitudes, write=True): self.helas_call_writer.use_flavor_mask = (n_flavors > 0) self.helas_call_writer.me_n_flavors = n_flavors self.helas_call_writer.me_active_flavor_mask = active_flavor_mask + # When crossing is on, the external HELAS calls must permute the + # helicity through perm[] and multiply their NSF flag by ic[] (both set + # up by sigmaKin); mirror of the fortran use_crossing_ic gate. + self.helas_call_writer.use_crossing_ic = getattr(self, 'use_crossing', False) try: replace_dict['wavefunction_calls'] = "\n".join(\ self.helas_call_writer.get_wavefunction_calls(\ @@ -1188,6 +1208,7 @@ def get_calculate_wavefunctions(self, wavefunctions, amplitudes, write=True): self.helas_call_writer.use_flavor_mask = False self.helas_call_writer.me_n_flavors = 0 self.helas_call_writer.me_active_flavor_mask = None + self.helas_call_writer.use_crossing_ic = False if write: file = self.read_template_file(self.process_wavefunction_template) % \ @@ -1388,6 +1409,286 @@ def fmt_uint64_2d(dtype, name, matrix): n_flavors, active_flavor_mask) + @staticmethod + def _cpp_int_array(values): + """Flat C++ initialiser '{a, b, c}' for a list of ints.""" + return '{%s}' % ', '.join(str(int(v)) for v in values) + + @staticmethod + def _cpp_int_array2d(flat, ncols): + """Nested C++ initialiser '{{...}, {...}}' from a flat list, ncols wide.""" + rows = ['{%s}' % ', '.join(str(int(v)) for v in flat[i:i + ncols]) + for i in range(0, len(flat), ncols)] + return '{%s}' % ', '.join(rows) + + def get_crossing_replace_dict(self, matrix_element): + """Fill the crossing-machinery holes of the C++ standalone templates. + + Mirrors export_v4.fill_crossing_replace_dict for the standalone_cpp + backend. When self.use_crossing is False every hole gets the plain, + pre-crossing code so the output is byte-for-byte the old one; when it is + True the extended flavor_id (a flavor AND a crossing) is decoded in + sigmaKin, the momenta/helicities are permuted through the crossing and + the swapped legs' NSF flag is flipped (via the ic[] array the HELAS + calls now read), and the denominator is split into the crossing- + dependent initial-state spin*color (spincol_cross) times the flavor- + dependent identical-final-state factor (ident_cross). + """ + # Plain (no-crossing) fills: identical to the historical template. + plain = { + 'fidx': 'flavor_id', + 'cross_tables_decode': '', + 'cross_perm_block': ('int perm[nexternal];\n' + 'for(int i = 0; i < nexternal; i++){\n' + ' perm[i]=i;\n' + '}'), + 'cross_cw_args': '', + 'cross_return': + 'return matrix_element * broken_sym(flavor) / denominator;', + 'cross_cw_sig_extra': '', + 'cross_member_decl': '', + 'ident_cross_function': '', + # No crossing: every call is the uncrossed process, so the C-parity + # de-duplication is always allowed. + 'csym_dedup_ok': 'true', + # Historical good-helicity filter (byte-identical to pre-crossing). + 'cross_ghidx_setup': '', + 'cross_goodhel_gate': + 'goodhel[flavor_id][ihel] || ntry[flavor_id] < 2', + 'cross_goodhel_train': + 'if (t != 0. && !goodhel[flavor_id][ihel]){\n' + ' goodhel[flavor_id][ihel]=true;\n' + ' ngood[flavor_id] ++;\n' + ' igood[flavor_id][ngood[flavor_id]] = ihel;\n' + ' }', + } + if not self.use_crossing: + return plain + + tables = ProcessExporterFortran.compute_crossing_tables( + self, matrix_element) + nexternal = tables['nexternal'] + ninitial = tables['ninitial'] + ncross = (nexternal + 1) * (nexternal + 1) + + # Per-leg tables (one entry per external leg). The crossing's slot + # permutation and NSF sign flips are decoded from the crossing code at + # runtime (cross_perm_ic, mirroring the fortran GET_CROSS_PERM), and the + # two halves of the denominator are rebuilt from these -- so no + # cross-indexed table (spincol/basepid/src/perm/ic) is stored. + spincol_part_init = self._cpp_int_array(tables['spincol_part']) + ids_base_init = self._cpp_int_array(tables['ids_base']) + antipid_base_init = self._cpp_int_array(tables['antipid_base']) + # Good-helicity remap: instead of the baked ghremap[ncross*ncomb] row + # table, keep only the per-crossing filterable flag and resolve the + # gating identity row at runtime (see cross_ghidx_setup) -- the same + # NCROSS*NCOMB -> NCROSS shrink the fortran path does via CROSS_GHIDX. + # allow_reverse False so it matches the order helicities[] is emitted in. + ghfilt_init = self._cpp_int_array( + ProcessExporterFortran.compute_ghfilt( + self, matrix_element, allow_reverse=False)) + + cross_tables_decode = ( + "// Crossing symmetry: flavor_id carries a flavor AND a crossing.\n" + "// cross = flavor_id / nflavors\n" + "// flav_use = flavor_id %% nflavors (index used for masking)\n" + "// A crossing permutes momenta/helicities between slots and flips\n" + "// each swapped leg's NSF flag. The slot permutation is a fixed\n" + "// relabelling decoded from the crossing code at runtime\n" + "// (cross_perm_ic), so no cross-indexed table is stored; the\n" + "// denominator splits into the crossing-dependent initial-state\n" + "// spin*color (spincol_cross) and the flavor-dependent identical-\n" + "// final-state factor (ident_cross), both rebuilt from per-leg data.\n" + "const int ncross = %(ncross)d;\n" + "// ghfilt[cross] = 1 if this crossing's good-helicity filter is a\n" + "// clean bijection of the identity rows, 0 otherwise (initial-\n" + "// initial swap, inapplicable, or non-bijection). Genuinely per-\n" + "// crossing (not derivable from per-leg data), so kept as a table --\n" + "// the fortran path tabulates it too. The gating identity row itself\n" + "// is recomputed per row at runtime (see the good-helicity loop).\n" + "// See ProcessExporterFortran.compute_ghfilt.\n" + "static const int ghfilt[ncross] = %(ghfilt)s;\n" + "int cross = flavor_id / nflavors;\n" + "int flav_use = flavor_id %% nflavors;\n" + "// A null spin*color entry (out of range, impossible, or an\n" + "// overlapping swap) means an identically-zero matrix element.\n" + "if (cross < 0 || cross >= ncross || spincol_cross(cross) == 0)\n" + " return 0.;" + ) % {'ncross': ncross, 'ghfilt': ghfilt_init} + + cross_perm_block = ( + "int perm[nexternal];\n" + "int ic[nexternal];\n" + "cross_perm_ic(cross, perm, ic);") + + cross_return = ( + "// Uncrossed: historical path (IDEN via denominator, BROKEN_SYM\n" + "// correcting the identical-particle count per flavor). Crossed:\n" + "// rebuild the denominator from the crossed initial-state spin*color\n" + "// and the identical final-state factor of the actual flavors.\n" + "if (cross == 0)\n" + " return matrix_element * broken_sym(flavor) / denominator;\n" + "return matrix_element / " + "(spincol_cross(cross) * ident_cross(cross, flavor));") + + ident_cross_function = ( + "//------------------------------------------------------------------\n" + "// Runtime crossing decode (mirrors the fortran GET_CROSS_PERM/\n" + "// SWAP_LEGS): cross = i*(nexternal+1) + j swaps particle 1 with i\n" + "// and particle 2 with j (0 = leave alone; i==1 / j==2 are self-swaps,\n" + "// also no-ops). perm[k] is the input slot landing in crossed slot k\n" + "// and ic[k] its NSF sign flip. perm/ic are always left a valid\n" + "// permutation (identity for an inapplicable code) so a momentum\n" + "// gather never reads out of range; the return value flags an\n" + "// applicable crossing (false = overlapping swap / out of range).\n" + "bool CPPProcess::cross_perm_ic(int cross, int* perm, int* ic)\n" + "{\n" + " const int ncross = (nexternal + 1) * (nexternal + 1);\n" + " for (int k = 0; k < nexternal; k++) { perm[k] = k; ic[k] = 1; }\n" + " if (cross < 0 || cross >= ncross) return false;\n" + " const int xi = cross / (nexternal + 1);\n" + " const int xj = cross %% (nexternal + 1);\n" + " // Overlapping-swap codes compose into a 3-cycle the consumers\n" + " // read with opposite orientation: pure redundancy, invalid.\n" + " if (xi != 0 && xi != 1 && xj != 0 && xj != 2 &&\n" + " (xi == 2 || xj == 1 || xi == xj)) return false;\n" + " if (xi != 0 && xi != 1)\n" + " {\n" + " int t = perm[0]; perm[0] = perm[xi - 1]; perm[xi - 1] = t;\n" + " ic[0] = -ic[0]; ic[xi - 1] = -ic[xi - 1];\n" + " }\n" + " if (xj != 0 && xj != 2)\n" + " {\n" + " int t = perm[1]; perm[1] = perm[xj - 1]; perm[xj - 1] = t;\n" + " ic[1] = -ic[1]; ic[xj - 1] = -ic[xj - 1];\n" + " }\n" + " // A crossing may only conjugate a leg that CHANGES SIDE. Both\n" + " // legs of a same-side transposition are conjugated while\n" + " // neither moves across, which is no crossing at all: for a\n" + " // 2 -> N process that is the beam swap (xi==2 / xj==1), which\n" + " // must not conjugate anything; for a 1 -> N one it is every xj\n" + " // swap. Mirrors the fortran GET_CROSS_PERM.\n" + " for (int k = 0; k < nexternal; k++)\n" + " if (ic[k] == -1 &&\n" + " ((k < %(ninitial)d) == (perm[k] < %(ninitial)d)))\n" + " return false;\n" + " return true;\n" + "}\n" + "\n" + "//------------------------------------------------------------------\n" + "// Initial-state spin*color average of the crossed process: the\n" + "// product of the per-leg spin*color (spincol_part, conjugation\n" + "// invariant) over the legs the crossing puts in the initial state.\n" + "// 0 for a crossing that cannot be applied.\n" + "int CPPProcess::spincol_cross(int cross)\n" + "{\n" + " static const int spincol_part[nexternal] = %(spincol_part)s;\n" + " int perm[nexternal], ic[nexternal];\n" + " if (!cross_perm_ic(cross, perm, ic)) return 0;\n" + " int factor = 1;\n" + " for (int k = 0; k < %(ninitial)d; k++)\n" + " factor *= spincol_part[perm[k]];\n" + " return factor;\n" + "}\n" + "\n" + "//------------------------------------------------------------------\n" + "// Identical-final-state factor (product of n!) of the crossed\n" + "// process. Flavor dependent, so computed at runtime: two crossed\n" + "// final legs are identical when they carry the same flavor group\n" + "// (same representative PDG -- ids_base, conjugated to antipid_base\n" + "// when the leg swapped side) and the same actual flavor. FLAVOR is\n" + "// not permuted by the crossing, so slot k reads flavor[perm[k]].\n" + "int CPPProcess::ident_cross(int cross, const int* flavor)\n" + "{\n" + " static const int ids_base[nexternal] = %(ids_base)s;\n" + " static const int antipid_base[nexternal] = %(antipid_base)s;\n" + " int perm[nexternal], ic[nexternal];\n" + " cross_perm_ic(cross, perm, ic);\n" + " int bpid[nexternal];\n" + " for (int k = 0; k < nexternal; k++)\n" + " bpid[k] = (ic[k] == 1) ? ids_base[perm[k]] : antipid_base[perm[k]];\n" + " bool used[nexternal];\n" + " for (int k = 0; k < nexternal; k++) used[k] = false;\n" + " int fact = 1;\n" + " for (int k = %(ninitial)d; k < nexternal; k++)\n" + " {\n" + " if (used[k]) continue;\n" + " int n = 1;\n" + " for (int l = k + 1; l < nexternal; l++)\n" + " {\n" + " if (used[l]) continue;\n" + " if (bpid[k] == bpid[l] &&\n" + " flavor[perm[k]] == flavor[perm[l]])\n" + " {\n" + " used[l] = true;\n" + " n = n + 1;\n" + " fact = fact * n;\n" + " }\n" + " }\n" + " }\n" + " return fact;\n" + "}" + ) % {'spincol_part': spincol_part_init, 'ids_base': ids_base_init, + 'antipid_base': antipid_base_init, 'ninitial': ninitial} + + return { + 'fidx': 'flav_use', + 'cross_tables_decode': cross_tables_decode, + 'cross_perm_block': cross_perm_block, + 'cross_cw_args': ', ic', + 'cross_return': cross_return, + 'cross_cw_sig_extra': ', const int ic[]', + 'cross_member_decl': + ' bool cross_perm_ic(int cross, int* perm, int* ic);\n' + ' int spincol_cross(int cross);\n' + ' int ident_cross(int cross, const int* flavor);', + 'ident_cross_function': ident_cross_function, + # C-parity de-duplication only for the uncrossed process (cross 0): + # a crossing permutes/sign-flips the helicities so a base-row flip + # is not the crossed C-parity partner (crossed flavors: full sum). + 'csym_dedup_ok': 'cross == 0', + # The good-helicity filter is shared per flavor but consulted and + # trained through the crossing's row permutation sigma^-1: a crossed + # row is good iff its identity counterpart is. Rather than store the + # whole sigma^-1 (ghremap[ncross*ncomb]), recompute the gating + # identity row here: inverse-permute + sign-flip the crossed row's + # config (perm/ic already hold the runtime-decoded cross_perm_ic), + # then find the identity row carrying it. ghidx = -1 disables the + # filter for a non-filterable crossing (ghfilt[cross] == 0: compute + # the row, never train). For cross 0 perm/ic are the identity so + # ghidx == ihel, exactly the historical filter. The search is only + # reached while scanning (ntry < 10), so it is off the hot path. + 'cross_ghidx_setup': + 'int ghidx = -1;\n' + ' if (ghfilt[cross]){\n' + ' int tgt[nexternal];\n' + ' for(int k = 0; k < nexternal; k++){\n' + ' tgt[perm[k]] = ic[k] * helicities[ihel][k];\n' + ' }\n' + ' for(int r = 0; r < ncomb; r++){\n' + ' bool same = true;\n' + ' for(int k = 0; k < nexternal; k++){\n' + ' if (helicities[r][k] != tgt[k]){\n' + ' same = false;\n' + ' }\n' + ' }\n' + ' if (same){\n' + ' ghidx = r;\n' + ' break;\n' + ' }\n' + ' }\n' + ' }\n' + ' ', + 'cross_goodhel_gate': + 'ghidx < 0 || goodhel[flav_use][ghidx] || ntry[flav_use] < 2', + 'cross_goodhel_train': + 'if (t != 0. && ghidx >= 0 && !goodhel[flav_use][ghidx]){\n' + ' goodhel[flav_use][ghidx]=true;\n' + ' ngood[flav_use] ++;\n' + ' igood[flav_use][ngood[flav_use]] = ihel;\n' + ' }', + } + def get_sigmaKin_lines(self, color_amplitudes, write=True): """Get sigmaKin_lines for function definition for Pythia 8 .cc file""" @@ -1399,6 +1700,10 @@ def get_sigmaKin_lines(self, color_amplitudes, write=True): replace_dict = {} assert len(self.matrix_elements) == 1 + # Crossing-symmetry holes (identity fills when use_crossing is off). + replace_dict.update( + self.get_crossing_replace_dict(self.matrix_elements[0])) + # Number of helicity combinations replace_dict['ncomb'] = \ self.matrix_elements[0].get_helicity_combinations() @@ -1569,9 +1874,11 @@ def get_all_sigmaKin_lines(self, color_amplitudes, class_name): ret_lines = [] if self.single_helicities: + cross_cw_sig_extra = \ + self.get_crossing_replace_dict(self.matrix_elements[0])['cross_cw_sig_extra'] ret_lines.append(\ - "void %s::calculate_wavefunctions(const int perm[], const int hel[], const int flavor[]){" % \ - class_name) + "void %s::calculate_wavefunctions(const int perm[], const int hel[], const int flavor[]%s){" % \ + (class_name, cross_cw_sig_extra)) ret_lines.append("// Calculate wavefunctions for all processes") ret_lines.append(self.get_calculate_wavefunctions(\ self.wavefunctions, self.amplitudes)) @@ -1665,6 +1972,10 @@ def get_matrix_single_process(self, i, matrix_element, color_amplitudes, replace_dict['jamp_lines'] = self.get_jamp_lines(color_amplitudes) + # The color sum may run on a smaller basis than the one the color flow + # is picked among (see the madmatrix override) + self.set_color_flow_lines_cpp(matrix_element, replace_dict) + replace_dict['amp2_lines'] = self.get_amp2_lines(matrix_element) #specific exporter hack @@ -1817,6 +2128,15 @@ def coeff(cls, ff_number, frac, is_imaginary, Nc_power, Nc_value=3): + def set_color_flow_lines_cpp(self, matrix_element, replace_dict): + """Tell the process template that the color sum and the color flow use + the same basis. Overridden by the backends which can put the color sum + on a smaller one.""" + + replace_dict['ncolor_flow'] = replace_dict['ncolor'] + replace_dict['jampflow_lines'] = '' + replace_dict['jamp_flow'] = 'jamp_sv' + def get_jamp_lines(self, color_amplitudes): """Return the jamp = sum(fermionfactor * amp[i]) lines""" @@ -2124,7 +2444,7 @@ def get_process_function_definitions(self, write=True): # Extract process class name (for the moment same as file name) replace_dict['process_class_name'] = self.process_name - color_amplitudes = [me.get_color_amplitudes() for me in \ + color_amplitudes = [me.get_color_amplitudes(merge_quartic_amplitudes=False) for me in \ self.matrix_elements] replace_dict['initProc_lines'] = \ @@ -2622,6 +2942,10 @@ class ProcessExporterCPP(VirtualExporter): grouped_mode = False exporter = 'cpp' + # Only the plain standalone_cpp exporter emits the crossing machinery; the + # matchbox/pythia8/mg7 subclasses write their own templates and override + # this back to False. + supports_crossing = True default_opt = {'clean': False, 'complex_mass':False, 'export_format':'madevent', 'mp': False, @@ -2688,17 +3012,21 @@ def copy_template(self, model): if self.template_src_make: # Copy src Makefile makefile = self.read_template_file(self.template_src_make) % \ - {'model': self.get_model_name(model.get('name')), - 'cpp_compiler': self.opt['cpp_compiler'] if self.opt['cpp_compiler'] else 'g++'} + self.get_makefile_replace_dict(model) open(os.path.join('src', 'Makefile'), 'w').write(makefile) if self.template_Sub_make: # Copy SubProcesses Makefile makefile = self.read_template_file(self.template_Sub_make) % \ - {'model': self.get_model_name(model.get('name')), - 'cpp_compiler': self.opt['cpp_compiler'] if self.opt['cpp_compiler'] else 'g++'} + self.get_makefile_replace_dict(model) open(os.path.join('SubProcesses', 'Makefile'), 'w').write(makefile) + def get_makefile_replace_dict(self, model): + """Template replacements for the src and SubProcesses makefiles.""" + + return {'model': self.get_model_name(model.get('name')), + 'cpp_compiler': self.opt['cpp_compiler'] if self.opt['cpp_compiler'] else 'g++'} + #=========================================================================== # Helper functions #=========================================================================== @@ -2737,7 +3065,111 @@ def get_mg5_info_lines(cls): #=============================================================================== # generate_subprocess_directory #=============================================================================== - def write_check_sa_cpp(self, matrix_element, dirpath): + def _get_check_sa_cpp_crossing_example(self, matrix_element, maxflavor, + nexternal, use_crossing): + """C++ block for check_sa.cpp demonstrating the crossed matrix elements. + + Returns '' when crossing is not active for this backend/matrix element, + leaving the driver unchanged. Otherwise it mirrors the Fortran + check_sa.f demonstration: a loop over every way of crossing particle 1 + and particle 2 with a final-state particle (and over each flavor) that, + for each, evaluates the crossed matrix element and prints its signed + PDGs and value. The whole section is gated behind `if(false)` so it is + present only as a ready-to-enable example. + + flavor_id is 0-based in C++: flavor_id = cross*nflav + flav0, with + cross = flip1*(nexternal+1) + flip2 (flip1/flip2 the partners of + particle 1/2), matching sigmaKin's decode. standalone_cpp has no runtime + PDG accessor, so the signed PDG of each flavor_id is precomputed here + into demo_pdg[flavor_id*nexternal + slot] the same way + GET_PDG_FOR_FLAVOR does (conjugating swapped legs, zeros for an + impossible/overlapping crossing). Each evaluation uses a FRESH + CPPProcess so the shared good-helicity cache cannot contaminate it. + """ + if not use_crossing: + return '' + + tables = ProcessExporterFortran.compute_crossing_tables( + self, matrix_element) + spincol = tables['spincol'] + perm = tables['perm'] + ic = tables['ic'] + nx = tables['nexternal'] + ncross = len(spincol) + # The flavor count sigmaKin decodes against (CPPProcess::nflavors); read + # from the same source that fills %(nflav)d so the demo_pdg table indexes + # by flavor_id exactly as the runtime does. + n_flav = len(matrix_element.get_external_flavors_with_iden()) + # Physical signed PDGs (basepid holds internal group codes like 81, not + # the physical PDG the user expects). + _, pdg_flat, antipdg_flat = \ + ProcessExporterFortran._build_flav_pdg_tables(self, matrix_element) + # Those tables are indexed by physical flavor combination while flavor_id + # counts coupling-equivalence classes; _flavor_rep_rows bridges the two + # (the same lookup compute_crossing_pdg_entries does, kept shared so the + # demo table and the fortran signatures cannot drift apart). + rep_rows = ProcessExporterFortran._flavor_rep_rows( + self, matrix_element) + + # demo_pdg[flavor_id*nexternal + slot], flavor_id = cross*nflav+flav0. + demo_pdg = [] + for cross in range(ncross): + for flav0 in range(n_flav): + row = rep_rows[flav0] + for k in range(nx): + if spincol[cross] == 0: + demo_pdg.append(0) + continue + src = perm[cross * nx + k] + if ic[cross * nx + k] == 1: + demo_pdg.append(pdg_flat[row * nx + src]) + else: + demo_pdg.append(antipdg_flat[row * nx + src]) + + sep = (' cout << " ---------------------------------------------------' + '--------------------------" << endl;') + lines = [ + ' // Crossing-symmetry examples (crossed processes); see the', + ' // matching block in the Fortran check_sa.f. Gated behind', + ' // if(false): flip it to true to actually print them. Each', + ' // flavor_id is evaluated on a fresh CPPProcess so the shared', + ' // good-helicity cache cannot contaminate the crossed value.', + ' if(false){', + ' const int nflav = process.nflavors;', + ' const int nin = process.ninitial;', + ' const int nx = process.nexternal;', + ' static const int demo_pdg[%d] = {%s};' + % (len(demo_pdg), ', '.join(str(p) for p in demo_pdg)), + ' cout << endl << " Crossing-symmetry examples (crossed ' + 'processes):" << endl << endl;', + ' for(int flip1 = nin+1; flip1 <= nx; flip1++){', + ' for(int flip2 = nin+1; flip2 <= nx; flip2++){', + ' for(int j = 1; j <= nflav; j++){', + ' // cross = (partner of p1)*(nx+1) + (partner of p2)', + ' int cross = flip1*(nx+1) + flip2;', + ' int flavor_id = cross*nflav + (j-1);', + ' CPPProcess xproc("../../Cards/param_card.dat");', + ' xproc.setMomenta(p);', + ' double xme = xproc.sigmaKin(flavor_id);', + ' cout << "PARTICLE #1 crossed with particle # " ' + '<< flip1 << endl;', + ' cout << "PARTICLE #2 crossed with particle # " ' + '<< flip2 << endl;', + ' cout << "PDG";', + ' for(int s = 0; s < nx; s++) cout << " " ' + '<< demo_pdg[flavor_id*nx + s];', + ' cout << " FLAV_IDX " << flavor_id << endl;', + ' cout << "Matrix element = " << xme' + ' << " GeV^" << -(2*xproc.nexternal-8) << endl;', + sep, + ' }', + ' }', + ' }', + ' }', + ] + return '\n'.join(lines) + + def write_check_sa_cpp(self, matrix_element, dirpath, use_crossing=False): """Write a per-process check_sa.cpp with flavor arrays filled in. This mirrors the Fortran ``write_check_sa`` in ``export_v4.py``: @@ -2816,6 +3248,8 @@ def write_check_sa_cpp(self, matrix_element, dirpath): 'nexternal': nexternal, 'flavor_arr': flavor_arr_str, 'pdg_arr': pdg_arr_str, + 'crossing_example': self._get_check_sa_cpp_crossing_example( + matrix_element, maxflavor, nexternal, use_crossing), } with open(pjoin(dirpath, 'check_sa.cpp'), 'w') as fout: fout.write(content) @@ -2828,7 +3262,18 @@ def generate_subprocess_directory(self, matrix_element, cpp_helas_call_writer, #matrix_element = copy.deepcopy(matrix_element) process_exporter_cpp = self.oneprocessclass(matrix_element,cpp_helas_call_writer) - + # Enable the crossing machinery for standalone_cpp when the process was + # generated with --use_crossing (default on) and the process does not + # pin a specific s-channel (which a crossing would not preserve). Only a + # single-ME directory carries the flavor tables the crossing needs. + process_exporter_cpp.use_crossing = bool( + getattr(self, 'supports_crossing', False) + and self.opt.get('use_crossing', False) + and len(process_exporter_cpp.matrix_elements) == 1 + and not ProcessExporterFortran.breaks_crossing_symmetry( + process_exporter_cpp.matrix_elements[0].get('processes')[0])) + + # Create the directory PN_xx_xxxxx in the specified path proc_dir_name = "P%d_%s" % (process_exporter_cpp.process_number, process_exporter_cpp.process_name) @@ -2846,7 +3291,8 @@ def generate_subprocess_directory(self, matrix_element, cpp_helas_call_writer, for file in self.to_link_in_P: ln('../%s' % file) # Write a per-process check_sa.cpp with flavor info filled in - self.write_check_sa_cpp(matrix_element, dirpath) + self.write_check_sa_cpp(matrix_element, dirpath, + use_crossing=process_exporter_cpp.use_crossing) return proc_dir_name @staticmethod @@ -2864,10 +3310,12 @@ def finalize(self, *args, **opts): class ProcessExporterMatchbox(ProcessExporterCPP): oneprocessclass = OneProcessExporterMatchbox + supports_crossing = False class ProcessExporterPythia8(ProcessExporterCPP): oneprocessclass = OneProcessExporterPythia8 grouped_mode = 'madevent' + supports_crossing = False #=============================================================================== # generate_process_files_pythia8 @@ -3175,6 +3623,7 @@ def read_template_file(cls, *args, **opts): class ProcessExporterMG7(ProcessExporterCPP): """ Extends the standalone CPP exporter to add files needed to run madevent7 / madnis """ + supports_crossing = False s= _file_path + 'iolibs/template_files/' dirs_to_create = ['bin', 'src', 'lib', 'Cards', 'SubProcesses'] # mg7_v5 builds api.so in the P* folders (instead of the standalone_cpp @@ -3206,6 +3655,18 @@ def generate_subprocess_directory( """ Override of super().generate_subprocess_directory """ process_exporter_mg7 = self.oneprocessclass(matrix_element,cpp_helas_call_writer) + # Enable the crossing machinery (extended flavor id) when the process was + # generated with --use_crossing (default on) and the process does not pin + # a specific s-channel (which a crossing would not preserve). Only a + # single-ME directory carries the flavor tables the crossing needs. When + # off, use_crossing stays False and the output is byte-identical. + process_exporter_mg7.use_crossing = bool( + getattr(self, 'supports_crossing', False) + and self.opt.get('use_crossing', False) + and len(process_exporter_mg7.matrix_elements) == 1 + and not ProcessExporterFortran.breaks_crossing_symmetry( + process_exporter_mg7.matrix_elements[0].get('processes')[0])) + # Create the directory PN_xx_xxxxx in the specified path proc_dir_name = process_exporter_mg7.name dirpath = pjoin(self.dir_path, 'SubProcesses', proc_dir_name) @@ -3464,8 +3925,24 @@ def ExportCPPFactory(cmd, group_subprocesses=False, cmd_options={}): opt = dict(cmd.options) opt['output_options'] = cmd_options + # --use_crossing of the generate/add process command, and of the output + # command for this output (both default on). Only the exporters that set + # supports_crossing (standalone_cpp, standalone_mg7/madmatrix) read this + # key; the others ignore it. + opt['use_crossing'] = getattr(cmd, '_use_crossing', True) \ + and getattr(cmd, '_output_use_crossing', True) cformat = cmd._export_format - + + # None of the C++ exporters below has a MadLoop backend, so a loop-induced + # process would reach them as a LoopHelasMatrixElement whose loop legs they + # cannot even index (the mg7 exporter builds its edge names from the + # external legs alone). Refuse it here instead. Plugins are left alone: + # they are free to implement their own loop support. + if cformat not in export_v4.LOOP_INDUCED_FORMATS and cmd._curr_amps and \ + isinstance(cmd._curr_amps[0], loop_diagram_generation.LoopAmplitude): + raise InvalidCmd(export_v4.loop_induced_not_supported_msg( + cformat, cmd._curr_amps[0].get('process'))) + if cformat == 'pythia8': return ProcessExporterPythia8(cmd._export_dir, opt) elif cformat == 'standalone_cpp': diff --git a/madgraph/iolibs/export_fks.py b/madgraph/iolibs/export_fks.py index eb45248cbe..322fa5304b 100755 --- a/madgraph/iolibs/export_fks.py +++ b/madgraph/iolibs/export_fks.py @@ -1960,6 +1960,32 @@ def write_extra_cnt_wrapper(self, writer, cnt_me_list, fortran_model): + #=========================================================================== + # get_fks_color_data_lines + #=========================================================================== + def get_fks_color_data_lines(self, matrix_element): + """The color matrix written out in full, for the templates this + exporter fills itself. + + born_fks.inc, born_fks_hel.inc, the four split-orders templates (born, + bhel, real, cnt) and the sudakov goldstone one all declare + CF(NCOLOR*(NCOLOR+1)/2) and sum it straight, and none of them carries + an INIT_CF call. Neither of the two compact forms get_color_data_lines + can otherwise choose -- one row per JAMP reversal pair, or the entries + rebuilt at run time -- can be read back from them, so every entry of + the upper triangle is written. + + The gate on ProcessExporterFortranSA does not cover these files: + get_matrix_template describes what write_matrix_element_v4 writes, and + it happens to answer no-fold for them only because an aMC@NLO process + always carries split orders (amcatnlo_interface fills them with every + coupling order of the model), so it reports the split-orders template. + Asking here instead of relying on that leaves the one path that does go + through write_matrix_element_v4 -- the MadLoop born_matrix.f, via + write_bornmatrix -- free to keep both forms.""" + + return self.get_color_data_lines(matrix_element, plain=True) + #=========================================================================== # write_split_me_fks #=========================================================================== @@ -2058,7 +2084,7 @@ def write_split_me_fks(self, writer, matrix_element, fortran_model, replace_dict['hel_avg_factor'] = matrix_element.get_hel_avg_factor() # Extract color data lines - color_data_lines = self.get_color_data_lines(matrix_element) + color_data_lines = self.get_fks_color_data_lines(matrix_element) replace_dict['color_data_lines'] = "\n".join(color_data_lines) % {'proc_prefix': ''} if self.opt['export_format']=='standalone_msP': @@ -2684,7 +2710,7 @@ def write_born_fks(self, writer, fksborn, fortran_model): replace_dict['ncolor'] = ncolor # Extract color data lines - color_data_lines = self.get_color_data_lines(matrix_element) + color_data_lines = self.get_fks_color_data_lines(matrix_element) replace_dict['color_data_lines'] = "\n".join(color_data_lines) % {'proc_prefix': ''} # Extract helas calls @@ -2785,7 +2811,7 @@ def write_born_hel(self, writer, fksborn, fortran_model): replace_dict['ncolor'] = ncolor # Extract color data lines - color_data_lines = self.get_color_data_lines(matrix_element) + color_data_lines = self.get_fks_color_data_lines(matrix_element) replace_dict['color_data_lines'] = "\n".join(color_data_lines) % {'proc_prefix': ''} # Extract amp2 lines @@ -3220,7 +3246,7 @@ def write_sudakov_goldstone_me(self, writer, sudakov_me, ime, fortran_model): replace_dict['ncolor'] = ncolor # Extract color data lines - color_data_lines = self.get_color_data_lines(matrix_element) + color_data_lines = self.get_fks_color_data_lines(matrix_element) replace_dict['color_data_lines'] = "\n".join(color_data_lines) # Extract helas calls of the base matrix element diff --git a/madgraph/iolibs/export_mg7.py b/madgraph/iolibs/export_mg7.py index a6cc4f0518..274bb6998d 100644 --- a/madgraph/iolibs/export_mg7.py +++ b/madgraph/iolibs/export_mg7.py @@ -25,6 +25,13 @@ def __init__(self, matrix_element, cpp_helas_call_writer): self.process = self.amplitude.get("process") self.legs = self.process.get("legs_with_decays") self.color_basis = self.matrix_element.get("color_basis") + # The basis a color flow is picked among: always the trace one, which + # is the color basis itself unless the color sum runs on the DDM basis. + # Everything indexing a color flow -- the color_flows table, the + # active_colors masks, icolamp -- has to use this one and not the + # (smaller) basis of the color sum. + self.color_flow_basis = self.color_basis.get_flow_basis() \ + if self.color_basis else self.color_basis self.set_topology() self.set_flavor_indices() self.set_active_flavors() @@ -104,9 +111,12 @@ def set_active_flavors(self): def set_channels_colors_map(self): if self.color_basis: + # active_colors ends up in the icolamp mask, which is walked over + # the color flows, so it must be indexed on the flow basis + flow_basis = self.color_flow_basis diag_jamps = defaultdict(list) - for ijamp, col_basis_elem in enumerate(sorted(self.color_basis.keys())): - for diag_tuple in self.color_basis[col_basis_elem]: + for ijamp, col_basis_elem in enumerate(sorted(flow_basis.keys())): + for diag_tuple in flow_basis[col_basis_elem]: diag_jamps[diag_tuple[0]].append(ijamp) sym_indices, sym_perms, _ = find_symmetry( @@ -195,6 +205,39 @@ def set_channels_colors_map(self): self.active_color_map.append(active_colors) i += 1 + @staticmethod + def get_color_code_tables(color_flow_dicts, legs): + """(codes, slots) -- the canonical colour-flow code of each flow plus the + slot structure needed to decode it, or (None, None) when the flows have + no usable code (a sextet, or an epsilon structure). + + Same encoding as the fortran madevent output (see export_v4: + _color_flow_code / _color_flow_decode): flip the initial-state pair so + every colour index connects to an anticolour index, then digit i is the + anticolour SLOT that colour slot i connects to, and + code = sum_i digit_i * N^i. `slots` is {"color": [...], "acolor": [...]} + with 1-based leg numbers, and is flow independent -- it is fixed by the + colour representations, so one table serves every flow. + + Consumers decode a code back to the per-leg tags rather than looking the + flow up in the ICOLUP-style "color_flows" table.""" + from madgraph.iolibs.export_v4 import ProcessExporterFortranME as _E + states = [l.get("state") for l in legs] + flows = [[tuple(cf[l.get("number")]) for l in legs] + for cf in color_flow_dicts] + if any(c < 0 or a < 0 for fl in flows for c, a in fl): + return None, None # sextet: negative tag, not representable + conns = [_E._color_flow_canon(fl, states) for fl in flows] + codes = [_E._color_flow_code(c) for c in conns] + if any(c is None for c in codes) or len(set(codes)) != len(codes): + return None, None + colslots, acolslots = _E._color_flow_slots(conns[0]) + if not acolslots or any(_E._color_flow_slots(c) != (colslots, acolslots) + for c in conns[1:]): + return None, None + return codes, {"color": [l + 1 for l in colslots], + "acolor": [l + 1 for l in acolslots]} + def get_subprocess_info(self, proc_dir, lib_me_path): n_external, n_initial = self.matrix_element.get_nexternal_ninitial() if self.color_basis: @@ -205,15 +248,21 @@ def get_subprocess_info(self, proc_dir, lib_me_path): repr_dict[leg.get("number")] = self.model.get_particle( leg.get("id") ).get_color() * (-1) ** (1 + leg.get("state")) - # Get the list of color flows - color_flow_dicts = self.color_basis.color_flow_decomposition(repr_dict, n_initial) + # Get the list of color flows. This is about color flows, so + # always the trace basis, even when the color sum runs on the DDM + # one. + color_flow_dicts = self.color_flow_basis.\ + color_flow_decomposition(repr_dict, n_initial) # And output them properly color_flows = [ [[color_flow_dict[leg.get("number")][i] for i in [0, 1]] for leg in legs] for color_flow_dict in color_flow_dicts ] + color_codes, color_slots = self.get_color_code_tables( + color_flow_dicts, legs) else: color_flows = [[[0, 0]] * n_external] + color_codes, color_slots = None, None # We need the both particle and antiparticle wf_ids, since the identity # depends on the direction of the wf. @@ -264,7 +313,15 @@ def get_subprocess_info(self, proc_dir, lib_me_path): "me_path": lib_me_path, "path": proc_dir, "flavors": flavors, + # ICOLUP-style per-flow tags. Still needed by the LHE writer to + # reconstruct the colour of INTERNAL (propagator/decay) lines, so it + # cannot be dropped just because the code gives the external legs. "color_flows": color_flows, + # canonical colour-flow code of each flow + the (flow independent) + # slot structure to decode it; null when the flows have no usable + # code, in which case a consumer falls back to "color_flows". + "color_codes": color_codes, + "color_slots": color_slots, "pdg_color_types": pdg_color_types, "diagram_count": len(self.diagrams), "helicities": list(self.matrix_element.get_helicity_matrix()), diff --git a/madgraph/iolibs/export_python.py b/madgraph/iolibs/export_python.py index 2b1ee59244..7b438024fc 100755 --- a/madgraph/iolibs/export_python.py +++ b/madgraph/iolibs/export_python.py @@ -243,7 +243,10 @@ def get_jamp_lines(self, matrix_element): res_list = [] - for i, coeff_list in enumerate(matrix_element.get_color_amplitudes()): + # this writer emits no amplitude sums, so the quartic contributions + # have to stay in the JAMPs with their own colour coefficients + for i, coeff_list in enumerate(matrix_element.get_color_amplitudes( + merge_quartic_amplitudes=False)): res = "jamp[%d] = " % i diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 1fb735ea1b..e0da14bfcd 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -17,6 +17,7 @@ from madgraph.core import base_objects """Methods and classes to export matrix elements to v4 format.""" +import bisect import copy import math, cmath from io import StringIO @@ -41,12 +42,14 @@ import models import madgraph.core.base_objects as base_objects import madgraph.core.color_algebra as color +import madgraph.core.color_amp as color_amp import madgraph.core.helas_objects as helas_objects import madgraph.iolibs.drawing_eps as draw import madgraph.iolibs.files as files import madgraph.iolibs.group_subprocs as group_subprocs import madgraph.iolibs.file_writers as writers import madgraph.iolibs.gen_infohtml as gen_infohtml +import madgraph.iolibs.jamp_optimiser as jamp_optimiser import madgraph.iolibs.template_files as template_files import madgraph.iolibs.ufo_expression_parsers as parsers import madgraph.iolibs.helas_call_writers as helas_call_writers @@ -83,6 +86,96 @@ 'cpp':'g++'} +# Number of fortran statements per amplitude-chunk file. The HELAS call +# sequence of a high-multiplicity matrix element is one enormous basic block +# and gfortran's cost on it grows faster than linearly, so it is emitted as its +# own set of files (matrix_origamp.f / matrix_optimamp.f), one +# subroutine each, called in sequence. A matrix element whose sequence is +# shorter than this is written inline exactly as before, which keeps every +# small process byte-identical to the unchunked output. +# Overridable at output time with 'output madevent --amp_chunk_size=N' and, for +# the helicity-recycled copy, with the 'amp_chunk_size' run_card parameter. +# 0 (or a negative value) disables the split entirely. +# 2000 was measured on the recycled matrix element of g g > 5g (14 MB, 386k +# lines): serial compile 158 s at 500, 140 s at 1000, 111 s at 2000, 114 s at +# 10000, so the curve is flat past 2000 while the peak memory of one file keeps +# growing (70 / 115 / 204 / 779 MB). +AMP_CHUNK_SIZE_DEFAULT = 2000 + + +_AMP_COMMENT_RE = re.compile(r"^(\s*#|c\$|c$|(c\s+([^=]|$))|cf2py|c\-\-|c\*\*|\s*!|!\$)", + re.IGNORECASE) +_AMP_CONTINUATION_RE = re.compile(r"^(?: )[$&]") + + +def chunk_fortran_statements(lines, chunk_size, fixed_form=True): + """Group *lines* into slices of about *chunk_size* statements each, and + return the list of slices. + + With fixed_form=True the lines are already column-formatted fortran (what + hel_recycle produces); with fixed_form=False they are the raw HELAS calls + the exporter hands to the FortranWriter, one statement per entry and with + '#' comments. + + A slice boundary may only fall where a new statement starts at nesting + depth zero: continuation lines (5 blanks then '$' or '&') stay with their + statement, comments attach to the statement that follows them, and an + IF(...)THEN / DO block -- hel_recycle emits those around a flavor-masked + split amplitude -- is never cut in half. + """ + + def is_continuation(line): + return fixed_form and bool(_AMP_CONTINUATION_RE.match(line)) + + def is_comment(line): + if not line.strip(): + return True + if fixed_form: + return bool(_AMP_COMMENT_RE.search(line)) + return line.lstrip().startswith('#') + + def depth_change(line): + code = line.upper().split('!')[0].strip() + if code.startswith('IF') and code.endswith('THEN'): + return 1 + if code.startswith('DO ') or code == 'DO': + return 1 + if code.startswith('END IF') or code.startswith('ENDIF') or \ + code.startswith('END DO') or code.startswith('ENDDO'): + return -1 + return 0 + + chunks = [] + current = [] + pending = [] # comments waiting for the statement they annotate + nb_statements = 0 + depth = 0 + for line in lines: + if is_comment(line): + pending.append(line) + continue + if is_continuation(line): + # a continuation can only follow a statement already in flight + (current if current else pending).append(line) + continue + if depth == 0 and nb_statements >= chunk_size and current: + chunks.append(current) + current = [] + nb_statements = 0 + current.extend(pending) + pending = [] + current.append(line) + nb_statements += 1 + depth += depth_change(line) + if depth < 0: + depth = 0 + if pending: + (current if current else chunks[-1] if chunks else current).extend(pending) + if current: + chunks.append(current) + return chunks + + class VirtualExporter(object): #exporter variable who modified the way madgraph interacts with this class @@ -107,6 +200,13 @@ class VirtualExporter(object): exporter = 'v4' # language of the output 'v4' for Fortran output # 'cpp' for C++ output + support_ddm_color_basis = False + # True for the output formats which can use the (n-2)! Del Duca-Dixon- + # Maltoni basis for the color sum of multi-gluon processes. + ddm_needs_flow_basis = False + # True when the format also needs a color flow per event: the trace basis + # is then built next to the DDM one, and the trace JAMPs are obtained from + # the DDM ones through the Kleiss-Kuijf relations. default_vector_size = 0 @@ -211,10 +311,180 @@ def export_helas(self, HELAS_PATH): raise Exception("V4 model not supported by this type of exporter. Please use UFO model") return +#=============================================================================== +# ColorReflectionFolding +#=============================================================================== +class ColorReflectionFolding(object): + """Reversing every color basis element maps the basis onto itself, and for + a pure gluon process the two flows of a pair only differ by one overall + sign. Half the color flows then carry nothing of their own and |M|^2 can be + summed over one flow per pair, against a color matrix folded onto them. + + Shared by the fortran exporters and by the madmatrix (C++/GPU) one, which + only differ in when a folding is worth taking (jamp_fold_worthwhile).""" + + # sum |M|^2 over one color flow per reversal pair instead of over every one + # Folding the color matrix onto one line per reversal pair only works + # where the template sums over NCOLORFOLD. get_color_data_lines is shared + # by every fortran exporter, so this stays off unless the template agrees. + jamp_fold = False + + # Above this many entries the folded color matrix is not written out but + # rebuilt at run time, which only the sign +1 case can do (see + # jamp_fold_worthwhile). + color_fold_max_written = 300000 + + @staticmethod + def jamp_color_rows(matrix_element): + """The color coefficient of every amplitude, one dictionary per color + basis line. Same numbers get_JAMP_lines works from.""" + + rows = [] + powers = {} + for coeff_list in matrix_element.get_color_amplitudes(): + row = {} + for coefficient, amp in coeff_list: + if not coefficient: + continue + try: + power = powers[coefficient[3]] + except KeyError: + power = fractions.Fraction(3) ** coefficient[3] + powers[coefficient[3]] = power + value = (1j if coefficient[2] else 1) * coefficient[0] * \ + coefficient[1] * power + row[amp] = row.get(amp, 0) + value + rows.append(dict((amp, complex(v)) for amp, v in row.items() if v)) + return rows + + def get_jamp_reflection(self, matrix_element): + """Reversing every color basis element maps the basis onto itself, and + for a pure gluon process the color coefficients of a line and of its + reverse differ by one overall sign, so half the color flows carry no + information of their own: + + JAMP[reverse(i)] = sign * JAMP[i] + + Return (reverse, sign) or None. The relation is read off the color + coefficients themselves rather than assumed, so a process where it does + not hold -- a quark line, where reversing does not commute with the + fermion flow -- simply gets None.""" + + if not isinstance(matrix_element, helas_objects.HelasMatrixElement): + return None + color_basis = matrix_element.get('color_basis') + if not color_basis or len(color_basis) < 2: + return None + keys = sorted(color_basis.keys()) + position = dict((key, i) for i, key in enumerate(keys)) + + reverse = [] + for key in keys: + other = color_amp.reverse_immutable(key) + if other is None or other not in position: + return None + reverse.append(position[other]) + if any(reverse[reverse[i]] != i for i in range(len(keys))): + return None + + columns = self.jamp_color_rows(matrix_element) + + sign = None + for i in range(len(keys)): + here, there = columns[i], columns[reverse[i]] + if set(here) != set(there): + return None + for amp, value in here.items(): + ratio = there[amp] / value + if ratio not in (1, -1): + return None + if sign is None: + sign = int(ratio.real) + elif sign != int(ratio.real): + return None + if sign is None: + return None + return reverse, sign + + @staticmethod + def jamp_reflection_representatives(reverse): + """One line per pair, and for every line the pair it belongs to.""" + + representatives = [i for i in range(len(reverse)) if i <= reverse[i]] + slot = {} + for index, line in enumerate(representatives): + slot[line] = index + slot[reverse[line]] = index + return representatives, slot + + def jamp_fold_worthwhile(self, sign, nb_pairs): + """Whether a folding is taken once it has been found. + + With sign +1 every line of a pair enters with the same weight, so the + permutations leaving the color basis invariant carry over to the pairs + unchanged and the folded matrix can still be rebuilt at run time from + one line per orbit. With sign -1 a permutation may send a line onto its + own partner, which flips the weight, and the rebuilt form would need a + sign of its own; there the folded matrix is written out instead, which + is only affordable while it stays small.""" + + return sign > 0 or \ + nb_pairs * (nb_pairs + 1) // 2 <= self.color_fold_max_written + + def get_jamp_folding(self, matrix_element): + """Whether to sum |M|^2 over one line per reversal pair, and the + (reverse, sign, representatives, slot) that goes with it.""" + + if not self.jamp_fold: + return None + found = self.get_jamp_reflection(matrix_element) + if not found: + return None + reverse, sign = found + representatives, slot = self.jamp_reflection_representatives(reverse) + if not self.jamp_fold_worthwhile(sign, len(representatives)): + return None + return {'reverse': reverse, 'sign': sign, + 'representatives': representatives, 'slot': slot} + + def jamp_folded_color_matrix(self, matrix_element, reverse, sign): + """The color matrix over one line per reversal pair. Summing |M|^2 over + the pairs instead of over every line gives the same number, since the + two lines of a pair only differ by the overall sign: + + C'[a][b] = sum over the two lines of a and the two of b, each + weighted by its sign relative to the line kept + + Returns (denominator, rows) with rows[a][b] integer, a and b indexing + the representatives.""" + + color_matrix = matrix_element.get('color_matrix') + representatives, _slot = self.jamp_reflection_representatives(reverse) + denominator = max(color_matrix.get_line_denominators()) + full = [color_matrix.get_line_numerators(i, denominator) + for i in range(len(reverse))] + + def pair(a): + return [(a, 1)] if reverse[a] == a else [(a, 1), (reverse[a], sign)] + + rows = [] + for a in representatives: + row = [] + for b in representatives: + total = 0 + for i, ci in pair(a): + for j, cj in pair(b): + total += ci * cj * full[i][j] + assert int(total) == total + row.append(int(total)) + rows.append(row) + return denominator, rows + #=============================================================================== # ProcessExporterFortran #=============================================================================== -class ProcessExporterFortran(VirtualExporter): +class ProcessExporterFortran(ColorReflectionFolding, VirtualExporter, + jamp_optimiser.JampOptimiser): """Class to take care of exporting a set of matrix elements to Fortran (v4) format.""" @@ -224,9 +494,46 @@ class ProcessExporterFortran(VirtualExporter): 'output_options':{} } grouped_mode = False - jamp_optim = False + # jamp_fold (sum |M|^2 over one color flow per reversal pair) comes from + # ColorReflectionFolding and stays off unless the template sums over + # NCOLORFOLD: get_color_data_lines is shared by every fortran exporter. + # jamp_optim, myjamp_count and jamp_integer_walk come from JampOptimiser, + # and so does the orbit equivariant optimisation itself: jamp_orbit, + # jamp_greedy_tail and jamp_compare_max_size. What is left here is how the + # definitions it produces are written out. + # BLAS-3 for the color sum: all helicities at once as one right hand side. + # None means take it when the library is there and the process is big + # enough for it to pay. + blas = None + blas_min_ncolor = 100 + # How the definitions reach memory: 'recipes' rebuilds them at the first + # call from one recipe per orbit, 'tables' writes the operand indices out + # as DATA. Both run the very same loop, and both start from the orbit + # equivariant optimisation, so they only differ in the source they need. + jamp_emit = 'tables' + # Read the amplitudes of the current helicity into a buffer before running + # the definitions over it, instead of holding the definitions at the end of + # AMP. Needed where AMP is indexed by helicity, which is what madevent does + # once it rewrites the matrix element for helicity recycling. + jamp_gather = False + # Below this many definitions writing them out is both smaller and faster: + # the lines still fit in the instruction cache, while the loop reading the + # operands from a table pays for the two indirections whatever the size. + # Measured on g g > n g, the two cost the same at about five thousand + # definitions (795 definitions: 0.47 us written out against 1.40 us; + # 9990 definitions: 39.4 us against 26.4 us). + jamp_orbit_min_def = 5000 + # how much smaller the compressed color matrix has to be before it is used + # instead of writing every entry out (see get_color_matrix_encoding) + color_encoding_margin = 4 run_card_class = None use_flavor_mask = True + # Whether this exporter can honor the --use_crossing of the generate/add + # command, i.e. emit a matrix element whose FLAV_IDX carries a crossing. + # Only the fortran standalone implements the machinery, so every other + # exporter must refuse the request rather than silently write code that + # cannot answer a crossed FLAV_IDX (see _check_crossing_support). + supports_crossing = False def __init__(self, dir_path = "", opt=None): """Initiate the ProcessExporterFortran with directory information""" @@ -234,12 +541,13 @@ def __init__(self, dir_path = "", opt=None): self.dir_path = dir_path self.model = None self.beam_polarization = [True,True] - + self.opt = dict(self.default_opt) if opt: self.opt.update(opt) self.cmd_options = self.opt['output_options'] self._configure_flavor_mask_from_cmd_options() + self._check_crossing_support() #place holder to pass information to the run_interface self.proc_characteristic = banner_mod.ProcCharacteristic() @@ -438,6 +746,89 @@ def _build_flav_table_flat(self, matrix_element): p, pdg_to_group_pos, max_group_size)) return (n_flavors, flav_table_flat) + def _build_flav_pdg_tables(self, matrix_element): + """Return (n_flavors, pdg_flat, antipdg_flat) for this matrix element. + + The FLAVOR array threaded through matrix.f holds unsigned group + *positions* (see _build_flav_table_flat), which is all the matrix + element needs: every member of a flavor group shares the couplings, so + the position alone selects the mask. A caller working in PDG codes -- + the f2py layer -- cannot use that: a position means nothing without + knowing which group and which leg it belongs to, and nothing in the + generated code maps one back to a PDG. These tables are that missing + map, and they are the only thing standing between an f2py caller and + being able to ask for a process by its PDG codes. + + Two tables are emitted rather than one, both column-major + (leg-fastest, matching FLAV_TABLE): + + - pdg_flat: the signed PDG of each leg for each flavor. + - antipdg_flat: the PDG of the *antiparticle* of that same leg. + + The antiparticle table exists because a crossing conjugates every leg + that swaps between the initial and the final state, and conjugation is + NOT "negate the PDG": a self-conjugate particle (the gluon, 21) must + stay itself. Tabulating both here lets the generated fortran pick one + or the other by the sign of SGN(k) -- which GET_CROSS_PERM already + computes -- instead of trying to re-derive the model's conjugation rule + at runtime. It is the same get_anti_pdg_code() that + get_iden_cross_lines uses to build BASEPID_CROSS_TABLE, so the two stay + consistent by construction. + + The per-leg sign comes from the process's own leg id (e.g. -81 for an + incoming anti-quark), while the magnitude comes from the group member + sitting at that position; a leg that is not part of a merged group + (a gluon) keeps its own PDG whatever the flavor. + """ + + allowed_flavors = matrix_element.compute_flavor_masks() + process = matrix_element.get('processes')[0] + model = process.get('model') + # compute_flavor_masks() is indexed by the FULL external legs, so for a + # decay chain (p p > w+ w-, w+ > j j, w- > j j) the flavor tuple spans the + # 6 decay leaves, not the 4 core legs of process.get('legs'). Expand the + # decays so leg_ids lines up with the flavor tuple (a no-op without decays). + legs = process.get_legs_with_decays() if hasattr(process, 'get_legs_with_decays') \ + else process.get('legs') + leg_ids = [leg.get('id') for leg in legs] + nexternal = len(leg_ids) + + if not allowed_flavors: + allowed_flavors = [tuple([1] * nexternal)] + + merged_particles = (model.get('merged_particles') or {}) if model else {} + + def leg_pdg(leg_id, pos): + """The signed PDG of a leg whose flavor sits at group position pos.""" + members = merged_particles.get(abs(leg_id)) + if not members: + # Not a merged leg: its PDG does not depend on the flavor. + return int(leg_id) + try: + magnitude = int(members[int(pos) - 1]) + except (IndexError, ValueError, TypeError): + return int(leg_id) + # The group id carries the particle/antiparticle sign of the leg. + return magnitude if leg_id > 0 else -magnitude + + pdg_flat = [] + antipdg_flat = [] + for flavor in allowed_flavors: + for leg, pos in enumerate(flavor): + pdg = leg_pdg(leg_ids[leg], pos) + pdg_flat.append(pdg) + try: + antipdg_flat.append( + model.get('particle_dict')[pdg].get_anti_pdg_code()) + except KeyError: + # No such particle in the model (should not happen): fall + # back to the naive conjugation rather than crash the + # export. A wrong entry here can only mis-*match* a PDG + # request, never corrupt a matrix element. + antipdg_flat.append(-pdg) + + return (len(allowed_flavors), pdg_flat, antipdg_flat) + def _build_flav_index_lookup(self, matrix_element, n_flavors, flav_table_flat): """Build the expanded GET_FLAVOR_INDEX lookup for decay-chain MEs. @@ -580,13 +971,57 @@ def _make_flavor_array_fortran_function(self, func_name, n_flavors, 'flav_table_data': ', '.join(str(v) for v in flav_table_flat), } + def _make_flavor_pdg_fortran_function(self, func_name, n_flavors, pdg_flat, + antipdg_flat, cross_snippets, + nexternal_decl='include'): + """Return the complete Fortran GET_PDG_FOR_FLAVOR routine as a string. + + Emitted via the %(flavor_pdg_function)s placeholder. It is the inverse + of the GET_FLAVOR/GET_FLAVOR_INDEX pair in the PDG vocabulary: those two + only ever speak group positions, so without this an f2py caller has no + way to learn which physical process a FLAV_IDX denotes -- let alone + which one a *crossed* FLAV_IDX denotes. + + *cross_snippets* is the (decl, decode, apply) triple filled by + fill_crossing_replace_dict: with crossing on it defers to + GET_CROSS_PERM so the permutation/conjugation follows exactly the same + code path the matrix element itself uses; with crossing off there is no + crossing to decode and the plain table lookup is emitted. + Same args/convention as _make_flavor_index_fortran_function. + """ + template_path = pjoin(_file_path, 'iolibs', 'template_files', + 'fortran_matrix_flavor_pdg_fct.inc') + template = open(template_path).read() + + if nexternal_decl == 'include': + nexternal_lines = " include 'nexternal.inc'" + else: + nexternal_lines = (' INTEGER NEXTERNAL\n' + ' PARAMETER (NEXTERNAL=%d)' % int(nexternal_decl)) + + decl, decode, apply_block = cross_snippets + return template % { + 'func_name': func_name, + 'nexternal_decl': nexternal_lines, + 'nflav': n_flavors, + 'pdg_table_data': ', '.join(str(v) for v in pdg_flat), + 'antipdg_table_data': ', '.join(str(v) for v in antipdg_flat), + 'pdg_cross_decl': decl, + 'pdg_cross_decode': decode, + 'pdg_cross_apply': apply_block, + } + #=========================================================================== # process exporter fortran switch between group and not grouped #=========================================================================== def export_processes(self, matrix_elements, fortran_model, second_exporter=None, second_helas=None): """Make the switch between grouped and not grouped output""" - + calls = 0 + self._crossgroup = {} # (group_idx, me_idx) -> base info; Track B below + self._router_base_mes = set() # id(me) of the within-group (Track A) bases + self._crossgroup_dirs = [] # (dependent_dir, base_dir) for the parallel makefile + self._crossgroup_helperms = {} # base_dir -> {base_proc_id -> [hel perms]} if isinstance(matrix_elements, group_subprocs.SubProcessGroupList): # check handling for the polarization for m in matrix_elements: @@ -599,11 +1034,39 @@ def export_processes(self, matrix_elements, fortran_model, second_exporter=None, self.beam_polarization[beamid-1] = False break + # Cross-group crossing (Track B): a group whose matrix element is a + # crossing of another group's reuses (symlinks) that base group's + # compiled matrix element. Detect it here, where every group is + # visible, and hand the per-group routing to generate_subprocess_ + # directory (keyed by the same enumerate index it receives). + self._crossgroup = self.compute_crossgroup_routing(matrix_elements) + # The MEs that serve as a cross-group base must publish their per-flow + # JAMP2 (so dependents can reselect colour natively); gate that emission + # to these MEs only, keeping every other madevent ME byte-identical. + self._crossgroup_base_mes = set( + id(cg['base_me']) for cg in self._crossgroup.values()) + if self._crossgroup: + logger.info('Cross-group crossing: %d subprocess(es) will reuse ' + 'a base group\'s matrix element via crossing.' + % len({k[0] for k in self._crossgroup})) + # A shared matrix element now spans physically distinct (crossed) + # initial states, so a per-beam property is ill-defined. Tag it so + # check_card_consistency blocks beam polarisation / EVA (same guard + # as the within-group case; see fill of 'limitations' there). + if 'crossing' not in self.proc_characteristic['limitations']: + self.proc_characteristic['limitations'].append('crossing') + for (group_number, me_group) in enumerate(matrix_elements): calls = calls + self.generate_subprocess_directory(\ me_group, fortran_model, group_number, second_exporter=second_exporter, second_helas=second_helas ) + if self._crossgroup_dirs: + self.write_crossgroup_parallel_makefile( + pjoin(self.dir_path, 'SubProcesses')) + if self._crossgroup_helperms: + self.write_crossgroup_helunion( + pjoin(self.dir_path, 'SubProcesses')) else: # check handling for the polarization self.beam_polarization = [True,True] @@ -974,6 +1437,35 @@ def write_matrix_element_v4(self): """ pass + def _check_crossing_support(self): + """Note that this output cannot read folded crossings. + + `--use_crossing` (on by default) tells the generation not to write out + the crossed subprocesses separately, because the matrix element is + expected to reach them through an extended FLAV_IDX instead. Only the + folding-capable standalone backends implement that decoding. + + This used to refuse the export and ask the user to regenerate with + --use_crossing=False. It no longer does: the crossed subprocesses are + recorded as metadata at generation, so an output that cannot read them + gets them expanded back into explicit subprocesses automatically (see + MadGraphCmd._expand_recorded_crossings, applied on both the grouped and + the ungrouped path). Erroring out here would additionally be wrong for + the many processes that fold NO crossing at all -- nothing would be + missing from their output -- and it fired on the flag rather than on the + data. --use_crossing=False stays available, but is no longer needed just + to reach a non-folding output. + """ + + if self.supports_crossing: + return + if not self.opt.get('use_crossing', False): + return + logger.debug("The '%s' output does not read folded crossings; any " + "recorded crossed subprocess will be expanded back into " + "an explicit subprocess.", + self.opt.get('export_format', 'unknown')) + def _configure_flavor_mask_from_cmd_options(self): """Honor `--mask=True|False` from the output command line.""" @@ -1676,11 +2168,22 @@ def write_leshouche_file(self, writer, matrix_element): #=========================================================================== # get_leshouche_lines #=========================================================================== - def get_leshouche_lines(self, matrix_element, numproc): - """Write the leshouche.inc file for MG4""" + def get_leshouche_lines(self, matrix_element, numproc, drop_icolup=False): + """Write the leshouche.inc file for MG4 + + With *drop_icolup* the ICOLUP table is omitted for any ME whose colour + flows have a canonical code: the consumer (addmothers) rebuilds the tags + from colorflow.inc instead, so the table would be dead weight. It is + still written for an ME without a usable code, which is what addmothers + falls back to. Only the madevent exporters set this -- MadWeight ships + no addmothers.f and keeps reading ICOLUP.""" # Extract number of external particles (nexternal, ninitial) = matrix_element.get_nexternal_ninitial() + if drop_icolup and self._color_code_tables(matrix_element): + drop_icolup = True + else: + drop_icolup = False lines = [] real_iproc = -1 @@ -1714,7 +2217,7 @@ def get_leshouche_lines(self, matrix_element, numproc): # Here goes the color connections corresponding to the JAMPs # Only one output, for the first subproc! - if iproc == 0: + if iproc == 0 and not drop_icolup: # If no color basis, just output trivial color flow if not matrix_element.get('color_basis'): for i in [1, 2]: @@ -1731,8 +2234,8 @@ def get_leshouche_lines(self, matrix_element, numproc): * (-1)**(1+l.get('state')) # Get the list of color flows color_flow_list = \ - matrix_element.get('color_basis').color_flow_decomposition(repr_dict, - ninitial) + matrix_element.get('color_basis').get_flow_basis().\ + color_flow_decomposition(repr_dict, ninitial) # And output them properly for cf_i, color_flow_dict in enumerate(color_flow_list): for i in [0, 1]: @@ -1967,6 +2470,73 @@ def get_helicity_lines(self, matrix_element,array_name='NHEL', add_nb_comb=False return "\n".join(helicity_line_list) + @staticmethod + def _fortran_data_stmt(name, values, per_line=10): + """Emit a fixed-form 'DATA name /v1,v2,.../' statement with + continuation lines (column-6 '&') so long value lists stay within the + Fortran line-length limit. name may contain an implied-DO, e.g. + '(STATES(I,1),I=1,3)'.""" + strs = ["%d" % v for v in values] + if len(strs) <= per_line: + return " DATA %s /%s/" % (name, ",".join(strs)) + lines = [" DATA %s /" % name] + for i in range(0, len(strs), per_line): + seg = strs[i:i + per_line] + tail = "," if i + per_line < len(strs) else "/" + lines.append(" & %s%s" % (",".join(seg), tail)) + return "\n".join(lines) + + def _helstate_data(self, matrix_element): + """Return the Fortran DATA blocks for the canonical helicity + encoder/decoder that replaces the explicit NHEL config table. + + A helicity configuration is encoded as a single mixed-radix integer + (the 'canonical code') over the per-leg helicity states, with the last + external leg as the least-significant digit -- matching the + itertools.product ordering used by get_helicity_matrix(). For a + non-polarized process this makes the code of the i-th row exactly i, so + HELALLOW is simply [1..NCOMB] and nothing is relabelled; a polarization + restriction ({0}/{L}/...) keeps the *full* per-leg multiplicity as the + radix (so helicity 0 / longitudinal stays a first-class state) and + leaves HELALLOW as the selected, non-contiguous subset of codes. + + Returns a dict with keys: + maxhel - max per-leg helicity multiplicity (STATES 1st dim) + nhstate_data - DATA for NHSTATE(NEXTERNAL) (states per leg) + states_data - DATA for STATES(MAXHEL,NEXTERNAL) (helicity values) + hel_allow_data - DATA for HELALLOW(NCOMB) (allowed codes) + """ + model = matrix_element.get('processes')[0].get('model') + pdict = model.get('particle_dict') + ext = matrix_element.get_external_wavefunctions() + # Full per-leg helicity states, allow_reverse=True so the value order + # matches get_helicity_matrix() for non-polarized legs (code==row). + states = [pdict[wf.get('pdg_code')].get_helicity_states(True) + for wf in ext] + nstate = [len(s) for s in states] + nexternal = len(ext) + maxhel = max(nstate) if nstate else 1 + + # Allowed canonical codes: encode each enumerated helicity row. + allowed = [] + for row in matrix_element.get_helicity_matrix(): + code = 0 + for k, val in enumerate(row): + code = code * nstate[k] + states[k].index(val) + allowed.append(code + 1) + + states_lines = [] + for k in range(nexternal): + vals = [states[k][i] if i < nstate[k] else 0 + for i in range(maxhel)] + states_lines.append(self._fortran_data_stmt( + '(STATES(I,%d),I=1,%d)' % (k + 1, maxhel), vals)) + + return {'maxhel': maxhel, + 'nhstate_data': self._fortran_data_stmt('NHSTATE', nstate), + 'states_data': "\n".join(states_lines), + 'hel_allow_data': self._fortran_data_stmt('HELALLOW', allowed)} + def get_ic_line(self, matrix_element): """Return the IC definition line coming after helicities, required by switchmom in madevent""" @@ -2057,13 +2627,170 @@ def format_integer_list(self, list, name, n=5): - def get_color_data_lines(self, matrix_element, n=128): + @staticmethod + def jamp_fold_spanning_tree(permutations, size): + """Same walk as ColorBasisSymmetry.spanning_tree, over an index set + given directly as permutations rather than as color basis keys.""" + + keep = [] + parent_uf = list(range(size)) + + def find(x): + while parent_uf[x] != x: + parent_uf[x] = parent_uf[parent_uf[x]] + x = parent_uf[x] + return x + + for perm in permutations: + used = False + for i, j in enumerate(perm): + ri, rj = find(i), find(j) + if ri != rj: + parent_uf[ri] = rj + used = True + if used: + keep.append(perm) + + representative = [-1] * size + parent = [None] * size + representatives = [] + for start in range(size): + if representative[start] != -1: + continue + representatives.append(start) + representative[start] = start + queue = collections.deque([start]) + while queue: + current = queue.popleft() + for local, perm in enumerate(keep): + image = perm[current] + if representative[image] == -1: + representative[image] = start + parent[image] = (current, local) + queue.append(image) + return representatives, representative, parent, keep + + def get_color_matrix_encoding(self, matrix_element): + """Describe the color matrix by one line per orbit of the index + permutations leaving the color basis invariant, plus the permutations + needed to reach every other line from it (see ColorBasisSymmetry). + + Every line of the matrix is one of those lines with its columns + permuted, so this replaces the N*(N+1)/2 entries by (nrep+ngen+3)*N + numbers. That is only a gain once the basis is large enough, and None + is returned otherwise so that the entries are written out as before.""" + + color_matrix = matrix_element.get('color_matrix') + if not color_matrix: + return None + # an asymmetric matrix does not have the line structure exploited here + if color_matrix._col_basis1 is not color_matrix._col_basis2: + return None + + keys = color_matrix._sorted_keys1 + nb_color = len(keys) + symmetry = color_amp.ColorBasisSymmetry(keys) + if not symmetry.has_symmetry(): + return None + + folding = self.get_jamp_folding(matrix_element) + if folding and folding['sign'] < 0: + # The rebuilt form cannot carry the weight a permutation picks up + # when it sends a line onto its own partner. The sum runs over the + # folded matrix either way, so there is no falling back to the + # unfolded encoding here: it has to be written out instead. + return None + if folding: + # reversing commutes with permuting the indices, so a permutation + # of the lines is also a permutation of the pairs + slot = folding['slot'] + nb_color = len(folding['representatives']) + induced = [[slot[perm[line]] + for line in folding['representatives']] + for perm in symmetry.generators1] + representatives, representative, parent, gens = \ + self.jamp_fold_spanning_tree(induced, nb_color) + else: + representatives, representative, parent, gens = \ + symmetry.spanning_tree() + + # Writing the entries out is well trodden and the compressed form + # carries a routine of its own, so only take it when it pays clearly. + # In practice this leaves everything below about a hundred color + # structures alone, which is where the matrix is not the bulk of the + # generated file anyway. + size = (len(representatives) + len(gens) + 3) * nb_color + if size * self.color_encoding_margin > nb_color * (nb_color + 1) // 2: + return None + + place = dict((line, index) for index, line in enumerate(representatives)) + if folding: + denominator, folded = self.jamp_folded_color_matrix( + matrix_element, folding['reverse'], folding['sign']) + rows = [folded[line] for line in representatives] + else: + denominator = max(color_matrix.get_line_denominators()) + rows = [] + for line in representatives: + num_list = color_matrix.get_line_numerators(line, denominator) + assert all(int(i) == i for i in num_list) + rows.append([int(i) for i in num_list]) + + return {'denom': denominator, + 'nb_color': nb_color, + 'rows': rows, + 'gens': gens, + # for each line, the line it comes from and the generator + # reaching it, or (0,0) when the line is a representative + 'parent': [(0, 0) if p is None else (p[0] + 1, p[1] + 1) + for p in parent], + 'slot': [place[representative[i]] + 1 + for i in range(nb_color)]} + + def get_color_data_lines(self, matrix_element, n=128, plain=False): """Return the color matrix definition lines for this matrix element. Split - rows in chunks of size n.""" + rows in chunks of size n. + + Two of the forms written here are not plain DATA the reader can simply + sum over: the compressed encoding leaves the entries to be rebuilt at + run time by INIT_CF, and the folded form writes one row per JAMP + reversal pair. Both need the template being written to agree -- an + INIT_CF call for the first, the JFOLD/COLREP gather for the second -- + and this method is shared by every fortran exporter, several of which + write into templates carrying neither. Those callers pass plain=True + and get every entry of the upper triangle written out.""" if not matrix_element.get('color_matrix'): return ["DATA %(proc_prefix)sDenom/1/", "DATA %(proc_prefix)sCF/1/"] + if not plain and self.get_color_matrix_encoding(matrix_element): + # the entries are rebuilt at run time by INIT_CF, only the overall + # denominator is still needed here + denominator = max(matrix_element.get('color_matrix').\ + get_line_denominators()) + return ["DATA %%(proc_prefix)sDenom/%(denom)i/" % \ + {'denom': denominator}] + + folding = None if plain else self.get_jamp_folding(matrix_element) + if folding: + denominator, folded = self.jamp_folded_color_matrix( + matrix_element, folding['reverse'], folding['sign']) + ret_list = ["DATA %%(proc_prefix)sDenom/%(denom)i/" % + {'denom': denominator}] + cf_index = 0 + for index in range(len(folded)): + row = folded[index] + for k in range(index, len(row), n): + chunk = row[k:k + n] + ret_list.append( + "DATA (%%(proc_prefix)sCF(i),i=%3r,%3r) /%s/" % + (cf_index + 1, cf_index + len(chunk), + ','.join("%i" % ((1 if (k == index and pos == 0) + else 2) * int(v)) + for pos, v in enumerate(chunk)))) + cf_index += len(chunk) + return ret_list + ret_list = [] my_cs = color.ColorString() denominator = max(matrix_element.get('color_matrix').get_line_denominators()) @@ -2096,11 +2823,1662 @@ def get_color_data_lines(self, matrix_element, n=128): return ret_list + @staticmethod + def get_int_data_lines(name, values, n=128, var='i'): + """DATA statements filling the one dimensional integer array name.""" + + lines = [] + for start in range(0, len(values), n): + chunk = values[start:start + n] + lines.append(" DATA (%s(%s),%s=%d,%d) /%s/" % \ + (name, var, var, start + 1, start + len(chunk), + ','.join(str(int(v)) for v in chunk))) + return lines + + def get_color_init_routine(self, matrix_element, proc_prefix, + suffix=''): + """Fortran source rebuilding the color matrix from its compressed + description, or an empty routine when the entries are written out.""" + + encoding = self.get_color_matrix_encoding(matrix_element) + nb_color = encoding['nb_color'] if encoding else \ + (len(matrix_element.get('color_matrix')._sorted_keys1) + if matrix_element.get('color_matrix') else 0) + header = [" SUBROUTINE %sINIT_CF%s()" % (proc_prefix, suffix)] + if not encoding: + return header + [" RETURN", " END"] + + nb_rep = len(encoding['rows']) + nb_gen = len(encoding['gens']) + body = header + [ + "C Rebuild the color matrix from one line per", + "C orbit of the index permutations leaving the", + "C color basis invariant. Every other line is one", + "C of those with its columns permuted, which is", + "C what following CFPAR back to the representative", + "C line gives. Done once, on the first call.", + " IMPLICIT NONE", + " INTEGER NCOLOR, NCFREP, NCFGEN", + " PARAMETER (NCOLOR=%d)" % nb_color, + " PARAMETER (NCFREP=%d)" % nb_rep, + " PARAMETER (NCFGEN=%d)" % nb_gen, + " INTEGER %sCF(NCOLOR*(NCOLOR+1)/2)" % proc_prefix, + " INTEGER %sDENOM" % proc_prefix, + " COMMON /%scolor_matrix%s/ %sCF,%sDENOM" % \ + (proc_prefix, suffix, proc_prefix, proc_prefix), + " INTEGER CFROW(NCOLOR*NCFREP)", + " INTEGER CFGEN(NCOLOR*NCFGEN)", + " INTEGER CFPAR(2*NCOLOR)", + " INTEGER CFSLOT(NCOLOR)", + " INTEGER PERM(NCOLOR)", + " INTEGER I,J,NODE,G,CF_INDEX,BASE", + " LOGICAL CF_DONE", + " DATA CF_DONE/.FALSE./", + " SAVE CF_DONE", + ] + body += self.get_int_data_lines("CFROW", + sum(encoding['rows'], [])) + body += self.get_int_data_lines("CFGEN", + sum(([x + 1 for x in g] for g in encoding['gens']), + [])) + body += self.get_int_data_lines("CFPAR", + sum(([p[0], p[1]] for p in encoding['parent']), [])) + body += self.get_int_data_lines("CFSLOT", encoding['slot']) + body += [ + " IF (CF_DONE) RETURN", + " CF_DONE = .TRUE.", + " CF_INDEX = 0", + " DO I = 1, NCOLOR", + " DO J = 1, NCOLOR", + " PERM(J) = J", + " ENDDO", + " NODE = I", + " DO WHILE (CFPAR(2*NODE-1) .NE. 0)", + " G = (CFPAR(2*NODE)-1)*NCOLOR", + " DO J = 1, NCOLOR", + " PERM(J) = CFGEN(G+PERM(J))", + " ENDDO", + " NODE = CFPAR(2*NODE-1)", + " ENDDO", + " BASE = (CFSLOT(NODE)-1)*NCOLOR", + " CF_INDEX = CF_INDEX + 1", + " %sCF(CF_INDEX) = CFROW(BASE+PERM(I))" % proc_prefix, + " DO J = I+1, NCOLOR", + " CF_INDEX = CF_INDEX + 1", + " %sCF(CF_INDEX) = 2*CFROW(BASE+PERM(J))" % proc_prefix, + " ENDDO", + " ENDDO", + " END", + ] + return body + def get_den_factor_line(self, matrix_element): """Return the denominator factor line for this matrix element""" return "DATA IDEN/%2r/" % \ matrix_element.get_denominator_factor() + def get_flow_jamp_lines(self, projection, JAMP_format, AMP_format): + """The Kleiss-Kuijf definitions of the trace JAMPs in terms of the DDM + ones. The common subexpression pass is skipped: the map has only a + handful of terms per line and its temporaries would collide with the + ones of the JAMP definitions proper.""" + + cmd_options = dict(self.cmd_options) + self.cmd_options['jamp_optim'] = False + try: + lines, nb_temp = self.get_JAMP_lines(projection, + JAMP_format=JAMP_format, + AMP_format=AMP_format) + finally: + self.cmd_options = cmd_options + + return lines + + def set_color_flow_lines_sa(self, matrix_element, replace_dict, ncolor): + """Same as set_color_flow_lines, for the standalone template: the JAMPs + are not split per amplitude order there, and the flow JAMPs get their + own routine so that they can be timed on their own.""" + + prefix = replace_dict['proc_prefix'] + color_basis = matrix_element.get('color_basis') + flow_basis = color_basis.get_flow_basis() if color_basis else None + + if flow_basis is None or flow_basis is color_basis: + replace_dict['ncolor_flow'] = ncolor + replace_dict['jampflow_decl'] = '' + replace_dict['jampflow_call'] = '' + replace_dict['jampflow_routine'] = '' + return ncolor + + ncolor_flow = max(1, len(flow_basis)) + projection = color_basis.get_flow_projection() + lines = self.get_flow_jamp_lines(projection, JAMP_format="JAMPF(%s)", + AMP_format="JAMP(%s)") + + replace_dict['ncolor_flow'] = ncolor_flow + replace_dict['jampflow_decl'] = "\n".join([ + " INTEGER NCOLOR_FLOW", + " PARAMETER (NCOLOR_FLOW=%d)" % ncolor_flow, + " COMPLEX*16 JAMPF(NCOLOR_FLOW)", + " DOUBLE PRECISION %sJAMP2(NCOLOR_FLOW)" % prefix, + " COMMON /%sJAMP2_COMMON/ %sJAMP2" % (prefix, prefix)]) + # accumulated exactly like madevent does, so that the work is real + replace_dict['jampflow_call'] = "\n".join([ + " CALL %sGET_JAMPF(JAMP,JAMPF)" % prefix, + " DO I = 1, NCOLOR_FLOW", + " %sJAMP2(I) = %sJAMP2(I)" % (prefix, prefix), + " $ + DABS(DBLE(JAMPF(I)*DCONJG(JAMPF(I))))", + " ENDDO"]) + replace_dict['jampflow_routine'] = "\n".join([ + " SUBROUTINE %sGET_JAMPF(JAMP,JAMPF)" % prefix, + "CF2PY INTENT(OUT) :: JAMPF", + "CF2PY INTENT(IN) :: JAMP", + " IMPLICIT NONE", + " INTEGER NCOLOR, NCOLOR_FLOW", + " PARAMETER (NCOLOR=%d)" % ncolor, + " PARAMETER (NCOLOR_FLOW=%d)" % ncolor_flow, + " COMPLEX*16 IMAG1", + " PARAMETER (IMAG1=(0D0,1D0))", + " COMPLEX*16 JAMP(NCOLOR), JAMPF(NCOLOR_FLOW)"] + lines + + [" END"]) + + logger.debug('Color sum on %d DDM structures, color flow on %d trace ' + 'structures (%d Kleiss-Kuijf terms)', + ncolor, ncolor_flow, + sum(len(row) for row in projection)) + + return ncolor_flow + + def set_color_flow_lines(self, matrix_element, replace_dict, ncolor): + """Fill in replace_dict everything the matrix element template needs to + know about the color flow basis, and return its size. + + For a fully adjoint (multi-gluon) process the color sum can be done on + the (n-2)! Del Duca-Dixon-Maltoni basis, but a color flow still has to + be picked among the (n-1)! trace structures. The trace JAMPs are then + not built from the amplitudes but obtained from the DDM ones through + the Kleiss-Kuijf relations, which is (n-1) times cheaper.""" + + color_basis = matrix_element.get('color_basis') + flow_basis = color_basis.get_flow_basis() if color_basis else None + + if flow_basis is None or flow_basis is color_basis: + replace_dict['ncolor_flow'] = ncolor + replace_dict['jampflow_decl'] = '' + replace_dict['jampflow_lines'] = '' + replace_dict['jamp_flow'] = 'JAMP' + return ncolor + + ncolor_flow = max(1, len(flow_basis)) + projection = color_basis.get_flow_projection() + + # The Kleiss-Kuijf map only acts on color, so it is the same for every + # split order + lines = [] + for iso in range(replace_dict['nAmpSplitOrders']): + lines.extend(self.get_flow_jamp_lines(projection, + JAMP_format="JAMPF(%%s,%d)" % (iso + 1), + AMP_format="JAMP(%%s,%d)" % (iso + 1))) + + replace_dict['ncolor_flow'] = ncolor_flow + replace_dict['jampflow_decl'] = \ + ' COMPLEX*16 JAMPF(NCOLOR_FLOW,NAMPSO)' + replace_dict['jampflow_lines'] = '\n'.join(lines) + replace_dict['jamp_flow'] = 'JAMPF' + + logger.debug('Color sum on %d DDM structures, color flow on %d trace ' + 'structures (%d Kleiss-Kuijf terms)', + ncolor, ncolor_flow, + sum(len(row) for row in projection)) + + return ncolor_flow + + @staticmethod + def get_crossing_permutation(cross, nexternal): + """Return (perm, ic, valid) for the crossing code CROSS. + + CROSS decomposes as I*(NEXTERNAL+1)+J, with I and J the crossing + partners of particle 1 and particle 2 (0 meaning "leave that particle + alone"). The base is NEXTERNAL+1, not NEXTERNAL, so that I and J range + over 0..NEXTERNAL and can therefore designate the last particle too. + perm[slot] is the 0-based index of the original leg sitting in that + slot, and ic[slot] is -1 for a leg that changed between the initial and + the final state. This mirrors exactly what APPLY_CROSSING does in the + generated fortran, so both stay in sync. + + *valid* is False for the overlapping-swap codes, which must not be used. + CROSS asks for two independent transpositions, (particle1, I) and + (particle2, J). When BOTH are active and they share a slot they no + longer compose into an involution but into a 3-cycle, and the two code + paths that consume this permutation (GET_PDG_FOR_FLAVOR building the + signature, and APPLY_CROSSING_TABLE evaluating the matrix element) then + disagree, one applying the permutation and the other its inverse -- + invisible for disjoint swaps (all involutions) but wrong for a cycle. + Such a code is pure redundancy: every physical process it could reach is + also reached by a DISJOINT swap, so it is marked invalid and its callers + refuse it (SPINCOL_CROSS_TABLE gets 0, which SMATRIX and + GET_PDG_FOR_FLAVOR both map to a null result). The two transpositions + {1,I} and {2,J} are both active iff I not in {0,1} and J not in {0,2} + (I==1 / J==2 swap a particle with itself, a no-op like 0), and they + overlap iff I==2 or J==1 or I==J. + """ + base = nexternal + 1 + i_part = cross // base + j_part = cross % base + perm = list(range(nexternal)) + ic = [1] * nexternal + + valid = not (i_part not in (0, 1) and j_part not in (0, 2) + and (i_part == 2 or j_part == 1 or i_part == j_part)) + + def swap(slot_a, slot_b): + perm[slot_a], perm[slot_b] = perm[slot_b], perm[slot_a] + ic[slot_a] = -ic[slot_a] + ic[slot_b] = -ic[slot_b] + + # I==1 (resp. J==2) would swap a particle with itself: degenerate, so + # treated as "no crossing" just like 0. + if i_part not in (0, 1): + swap(0, i_part - 1) + if j_part not in (0, 2): + swap(1, j_part - 1) + return perm, ic, valid + + @staticmethod + def breaks_crossing_symmetry(process): + """True if `process` constrains a specific s-channel propagator. + + Crossing permutes legs between the initial and the final state, so a + channel that is s-channel in the generated process is not s-channel in + its crossings. A constraint naming a specific s-channel therefore does + not survive the crossing and the crossing machinery must not be emitted: + - required_s_channels (the `> A >` syntax) + - forbidden_s_channels (the `$$` syntax, diagram removed) + `forbidden_onsh_s_channels` (a single `$`) only forbids the on-shell + *region* of a kept diagram, so it does not break crossing symmetry and + is deliberately not listed here. + + Works for both Process and ProcessDefinition (same attributes), and + recurses into decay chains, whose constraints bind just as much. A decay + chain itself does NOT break crossing: p p > t t~ j j, t > ... still + crosses at the production level (force-onshell decays ride along on the + legs they hang off), so crossing stays enabled -- the crossing tables + just have to be built over the full decay leaves (see + compute_crossing_tables) so the identical-particle/denominator factors + reflect the real final state. + """ + if process.get('required_s_channels') or \ + process.get('forbidden_s_channels'): + return True + # Crossing is a tree-level construction; a perturbative (loop / loop- + # induced) process must not go through it. Its matrix element has no + # flavor/PDG crossing tables (compute_crossing_pdg_entries would index + # past the end), so treat it as crossing-breaking to keep every + # crossing gate -- and the crossed-group detection -- clear of it. + if process.get('perturbation_couplings'): + return True + return any(ProcessExporterFortran.breaks_crossing_symmetry(decay) + for decay in process.get('decay_chains')) + + def fill_crossing_replace_dict(self, matrix_element, replace_dict, + use_crossing): + """Fill the crossing-machinery holes of matrix_standalone_v4.inc. + + The extended FLAV_IDX (a flavor *and* a crossing) and everything + decoding it are only written out when the process was generated with + --use_crossing=True (the default) *and* the process definition pins no + specific s-channel (see breaks_crossing_symmetry). Otherwise the + crossed subprocesses are generated as separate matrix elements instead, + so the crossing machinery would be dead code: the tables, the + APPLY_CROSSING/GET_CROSS_PERM/GET_SPINCOL_CROSS/GET_IDENT_CROSS routines + are not emitted at all and each hole below gets the plain code path + (FLAV_IDX is then a bare flavor index in [1,NFLAV]). + + Requires proc_prefix, nflav and den_factor_line to be set already. + """ + prefix = replace_dict['proc_prefix'] + + if not use_crossing: + replace_dict.update({ + 'crossing_routines': '', + 'iden_cross_lines': '', + 'smatrix_cross_decl': + 'C Generated without crossing symmetry: FLAV_IDX is a plain' + '\nC flavor index, there is no crossing to decode.', + 'smatrix_cross_decode': '', + 'smatrix_cross_apply': '', + 'smatrix_goodhel_gate': + ' IF (GOODHEL(IHEL,FLAV_USE) .OR. NTRY(FLAV_USE)' + ' .LT. 20.OR.USERHEL.NE.-1) THEN', + 'smatrix_goodhel_train': + ' IF (T .NE. 0D0 .AND. .NOT. ' + 'GOODHEL(IHEL,FLAV_USE)) THEN\n' + ' GOODHEL(IHEL,FLAV_USE)=.TRUE.\n' + ' ENDIF', + 'smatrix_matrix_call': + ' T=%sMATRIX(P ,NHEL(1,IHEL),JC(1),FLAV_USE)' + % prefix, + 'smatrix_iden_line': + 'C IDEN carries the identical-particle factor of the' + ' representative\nC flavor; BROKEN_SYM corrects it for' + ' the actual one.' + '\n ANS=ANS/DBLE(IDEN)*%sBROKEN_SYM(FLAVOR)' % prefix, + 'inter_rescale_decl': '', + 'inter_rescale_body': + 'C The static IDEN GET_INTER divides by carries the' + ' identical-particle\nC factor of the representative' + ' flavor, so BROKEN_SYM must correct it for\nC the actual' + ' one, exactly as SMATRIX does with ANS/IDEN*BROKEN_SYM.' + '\n RESCALE = DBLE(%sBROKEN_SYM(FLAVOR))' % prefix, + 'density_cross_apply': self.CROSS_PASSTHROUGH % { + 'nhel_copy': 'NHELUSE(:,:) = NHEL(:,:)'}, + 'allinter_cross_apply': ' IC(:)=1\n' + self.CROSS_PASSTHROUGH % { + 'nhel_copy': 'NHELUSE(:) = NHEL(:)'}, + 'pdg_cross_snippets': self.PDG_CROSS_SNIPPETS_OFF, + 'nhel_idx_decl': + 'C Generated without crossing symmetry: FLAV_IDX_IN is a' + ' plain\nC flavor index, so only BROKEN_SYM can move the' + ' denominator.', + 'nhel_idx_body': + 'C Mirrors SMATRIX exactly: ANS=ANS/IDEN*BROKEN_SYM means' + ' the effective\nC denominator is IDEN/BROKEN_SYM. The' + ' division is exact -- BROKEN_SYM is\nC the ratio of the' + ' representative to the actual identical-particle\nC count,' + ' and IDEN carries the representative one as a factor.' + '\n IDEN_STAR = IDEN_STAR / %sBROKEN_SYM(FLAVOR)' % prefix, + }) + return + + replace_dict['iden_cross_lines'] = \ + self.get_iden_cross_lines(matrix_element) + replace_dict['ident_resonance'] = \ + self.compute_crossing_tables(matrix_element)['ident_resonance'] + replace_dict.update(dict( + (key, value % {'proc_prefix': prefix, + 'den_factor_line': replace_dict['den_factor_line']}) + for key, value in self.CROSSING_SNIPPETS.items())) + # CROSS_GHIDX (in the crossing routines below) recomputes the crossed + # -> identity helicity row map at runtime; it needs only the small + # per-crossing GHFILT flag plus the STATES/NHSTATE the encoder uses (in + # get_helicity_matrix()'s default allow_reverse=True order, so the map is + # built in the same order the NHEL table is emitted). + hel_data = self._helstate_data(matrix_element) + replace_dict['maxhel'] = hel_data['maxhel'] + replace_dict['nhstate_data'] = hel_data['nhstate_data'] + replace_dict['states_data'] = hel_data['states_data'] + replace_dict['ghfilt_data'] = self.format_integer_data_lines( + 'GHFILT', self.compute_ghfilt(matrix_element, allow_reverse=True)) + replace_dict['pdg_cross_snippets'] = tuple( + snippet % {'proc_prefix': prefix} + for snippet in self.PDG_CROSS_SNIPPETS_ON) + replace_dict['nhel_idx_decl'] = ( + ' INTEGER %(prefix)sGET_SPINCOL_CROSS\n' + ' INTEGER %(prefix)sGET_IDENT_CROSS' % {'prefix': prefix}) + replace_dict['nhel_idx_body'] = ( + 'C Mirrors SMATRIX branch for branch: IDEN/BROKEN_SYM uncrossed,\n' + 'C GET_SPINCOL_CROSS*GET_IDENT_CROSS crossed. Keeping the CROSS=0\n' + 'C branch on the old path (rather than letting the crossed formula\n' + 'C cover it) is what guarantees no change for existing callers.\n' + ' IF (NHI_CROSS .EQ. 0) THEN\n' + ' IDEN_STAR = IDEN_STAR / %(prefix)sBROKEN_SYM(FLAVOR)\n' + ' ELSE\n' + ' IDEN_STAR = %(prefix)sGET_SPINCOL_CROSS(NHI_CROSS)\n' + ' & * %(prefix)sGET_IDENT_CROSS(NHI_CROSS, FLAVOR)\n' + ' ENDIF' % {'prefix': prefix}) + crossing_template = pjoin(_file_path, 'iolibs', 'template_files', + 'matrix_standalone_crossing_v4.inc') + replace_dict['crossing_routines'] = \ + open(crossing_template).read() % replace_dict + + def fill_crossing_replace_dict_me(self, matrix_element, replace_dict, + use_crossing, proc_id, xgrow_map=None): + """Fill the crossing holes of matrix_madevent_group_v4.inc. + + The madevent group SMATRIX differs structurally from the standalone one + (runtime IFLAV, GOODHEL/NTRY carry a flavor dimension, IVEC, and the NSF + flags are baked into the helas calls rather than read from an IC array), + so it gets its own holes and OFF fills. With crossing off every hole + reproduces the historical madevent code, so a non-crossing output is + unchanged; the extended-FLAV_IDX decode / APPLY_CROSSING path is only + written out when use_crossing is True (added in the ON slice). + + ``xgrow_map`` (Track-A bases only) is ``{cross: (dep_proc_id, cmap)}`` + for every within-group router flavor routed here: which subprocess the + crossed call is FOR, and that subprocess's diagram -> this module's + diagram map under the crossing. It drives the multi-channel row; see + the ``me_confsub_j`` fill below. + """ + pid = str(proc_id) + if not use_crossing: + replace_dict.update({ + 'smatrix_me_cross_decl': + 'C Generated without crossing symmetry: IFLAV is a plain' + '\nC flavor index, there is no crossing to decode.', + 'smatrix_me_cross_decode': '', + 'me_flav_key': 'IFLAV', + 'me_goodhel_idx': 'I', + 'me_goodhel_train_guard': '', + 'smatrix_me_goodhel_or': '', + 'me_matrix_args': 'P ,NHEL(1,I),IFLAV,I,AMP2, JAMP2, IVEC', + 'smatrix_me_iden_line': + ' ANS=ANS/DBLE(IDEN)*BROKEN_SYM%s(FLAVOR_FOR_SYM)' % pid, + 'crossing_routines_me': '', + 'me_matrix_ic_param': '', + 'me_matrix_ic_decl': '', + # Multi-channel row: without crossing this matrix element is + # only ever called for its own subprocess, so its own CONFSUB + # row is the right one and AMP2 is already in its numbering. + 'me_confsub_j': 'CONFSUB(%s, I)' % pid, + # helicity-recycling template variant (matrix_hel): + 'smatrix_hel_cross_decl': + 'C Generated without crossing symmetry: IFLAV is a plain' + '\nC flavor index, there is no crossing to decode.', + 'smatrix_hel_cross_decode': '', + 'hel_matrix_call_args': 'P ,IFLAV, TS, AMP2, JAMP2, IVEC', + 'hel_matrix_ic_param': '', + # No crossing: every call is the uncrossed base process, so the + # C-parity de-duplication is always applicable. + 'me_csym_cross_ok': '.TRUE.', + 'hel_csym_cross_ok': '.TRUE.', + }) + return + + # ON path. The crossing routines must not collide across the matrix.f + # files linked into one group executable, so they are named with a + # per-proc_id qualifier (GET_CROSS_PERM stays prefix-less in standalone). + # + # NFLAV must be the count that the madevent GET_FLAVOR table is sized by, + # i.e. get_external_flavors_with_iden() (== replace_dict 'max_flavor', + # what MAXFLAVPERPROC/FLAVOR(NEXTERNAL,max_flavor) use), NOT the standalone + # _build_flav_table_flat() (compute_flavor_masks): for a merged group ME + # the two differ (e.g. Q Q~ > g g: iden 1 vs masks 4), and the extended + # FLAV_IDX decode CROSS=(IFLAV-1)/NFLAV, FLAV=mod(IFLAV-1,NFLAV)+1 must + # land FLAV in [1, max_flavor]. This also matches compute_crossing_pdg_ + # entries (used by partition_crossing_classes), so the routed FLAV_IDX + # decodes the same way here. + nflav = len(matrix_element.get_external_flavors_with_iden()) + cp = 'CR%s_' % pid + crossing_template = pjoin(_file_path, 'iolibs', 'template_files', + 'matrix_standalone_crossing_v4.inc') + hel_data = self._helstate_data(matrix_element) + crossing_routines = open(crossing_template).read() % { + 'proc_prefix': cp, + 'nflav': nflav, + 'iden_cross_lines': self.get_iden_cross_lines(matrix_element), + 'ident_resonance': + self.compute_crossing_tables(matrix_element)['ident_resonance'], + 'maxhel': hel_data['maxhel'], + 'nhstate_data': hel_data['nhstate_data'], + 'states_data': hel_data['states_data'], + 'ghfilt_data': self.format_integer_data_lines( + 'GHFILT', self.compute_ghfilt(matrix_element, + allow_reverse=True))} + # ---- multi-channel row for calls routed here by a within-group router. + # CHANNEL and AMP2 are both in THIS module's diagram numbering (the + # router already translated CHANNEL through the crossing), but the loop + # that builds XTOT must enumerate the configs of the subprocess the call + # is FOR: GET_CHANNEL_CUT(P, I) is evaluated on the DEPENDENT's momenta, + # so I has to be a config of the dependent's row, and the AMP2 slot + # paired with it is the dependent's diagram sent through the crossing. + # Walking our own row instead pairs each amplitude with a different + # config's cut -- a bijective relabel, so the weights still sum to 1 and + # the cross section is unchanged, but the importance sampling is + # mis-paired. Both lookups are resolved from CROSSUSE through baked + # tables rather than a common block set by the router: madevent runs + # vectorised (IVEC/warps) and mutable shared state would race. + # + # Safe to key on the crossing code alone: a base that serves a + # within-group router is never also a cross-group (Track B) base -- + # compute_crossgroup_routing skips any group that has within-group + # routing -- so no foreign crossing can reach these tables. + ngraphs_me = len(matrix_element.get('diagrams')) + nxc = (matrix_element.get_nexternal_ninitial()[0] + 1) ** 2 - 1 + xg_rows, xg_cols = {}, {} + xg_cfg = [list(range(0, ngraphs_me + 1))] # column 1 = identity + for cross in sorted(xgrow_map or {}): + dep_pid, cmap = xgrow_map[cross] + # Only a clean permutation of our own diagrams is usable: anything + # else (a fallback map, or a dependent with a different diagram + # count) keeps our own row, i.e. the historical behaviour. + if not 1 <= cross <= nxc or \ + sorted(cmap) != list(range(1, ngraphs_me + 1)): + continue + col = [0] + list(cmap) + if col not in xg_cfg: + xg_cfg.append(col) + xg_rows[cross] = dep_pid + xg_cols[cross] = xg_cfg.index(col) + 1 + if xg_rows: + def _data2d(name, icol, values, per_line=10): + out = [] + for s in range(0, len(values), per_line): + chunk = values[s:s + per_line] + out.append(' DATA (%s(I,%d),I=%d,%d) /%s/' + % (name, icol, s, s + len(chunk) - 1, + ','.join(str(v) for v in chunk))) + return out + xg_lines = [' INTEGER IXROW, IXR', + ' INTEGER XGROWT(0:%d), XGCOLT(0:%d)' % (nxc, nxc), + self.format_integer_data_lines( + 'XGROWT', [xg_rows.get(c, int(pid)) + for c in range(nxc + 1)]), + self.format_integer_data_lines( + 'XGCOLT', [xg_cols.get(c, 1) + for c in range(nxc + 1)]), + ' INTEGER XGCFG(0:%d,%d)' + % (ngraphs_me, len(xg_cfg))] + for icol, col in enumerate(xg_cfg): + xg_lines += _data2d('XGCFG', icol + 1, col) + xg_decl = '\n' + '\n'.join(xg_lines) + xg_decode = ('\n IXROW = XGROWT(CROSSUSE)' + '\n IXR = XGCOLT(CROSSUSE)') + # Slot 0 of every XGCFG column is 0, so a config this subprocess has + # no diagram for still reads back as 0 and is skipped as before. + confsub_j = 'XGCFG(CONFSUB(IXROW, I), IXR)' + elif id(matrix_element) in getattr(self, '_crossgroup_base_mes', ()): + # Cross-group (Track B) base: same defect, but this object is + # SYMLINKED into the dependent P directories (write_crossgroup_mk), + # so one binary serves them all and the row cannot be baked here -- + # a dependent's configs live in ITS directory's config_subproc_map, + # and GET_CHANNEL_CUT already resolves to the dependent's genps.o. + # Take the row from XGROW, which every directory defines for + # itself in its own auto_dsig.f (see write_xgrow_routines): the + # identity (our own CONFSUB row) where we are generated, the routed + # subprocess's row composed with the crossing map in a dependent's. + # LMAXCONFIGS is a single global maximum (Source/maxconfigs.inc, + # symlinked), so the loop bound is the same in every directory. + xg_decl = '\n INTEGER XGJROW(LMAXCONFIGS)' + xg_decode = '\n CALL XGROW%s(CROSSUSE, XGJROW)' % pid + confsub_j = 'XGJROW(I)' + else: + xg_decl, xg_decode = '', '' + confsub_j = 'CONFSUB(%s, I)' % pid + replace_dict.update({ + 'me_confsub_j': confsub_j, + 'smatrix_me_cross_decl': ( + ' INTEGER NFLAV\n' + ' PARAMETER (NFLAV=%(nflav)d)\n' + ' INTEGER FLAV_USE, CROSSUSE, IDENUSE, XKCR\n' + ' INTEGER IC(NEXTERNAL), IC0(NEXTERNAL)\n' + ' REAL*8 PUSE(0:3,NEXTERNAL)\n' + ' INTEGER NHELUSE(NEXTERNAL,NCOMB)\n' + ' INTEGER %(cp)sGET_SPINCOL_CROSS\n' + ' INTEGER %(cp)sGET_IDENT_CROSS\n' + # runtime good-helicity remap: GHIDXA(I) is the identity row that + # gates crossed row I (0 = not filterable), precomputed once per + # SMATRIX call from the crossing permutation XGPERM/XGSGN. + ' INTEGER GHIDXA(NCOMB), XGPERM(NEXTERNAL)\n' + ' INTEGER XGSGN(NEXTERNAL), XGDUM, XGH' + '%(xg_decl)s' + ) % {'nflav': nflav, 'cp': cp, 'xg_decl': xg_decl}, + # Decode the crossing and build the crossed P/NHEL/IC once, before the + # helicity loop. An unusable crossing (spin*color = 0) has a zero ME. + 'smatrix_me_cross_decode': ( + ' CROSSUSE = (IFLAV-1) / NFLAV\n' + ' IDENUSE = %(cp)sGET_SPINCOL_CROSS(CROSSUSE)\n' + ' IF (IDENUSE.EQ.0) THEN\n' + ' ANS = 0D0\n' + ' IHEL = 1\n' + ' ICOL = 1\n' + ' RETURN\n' + ' ENDIF\n' + ' DO XKCR=1,NEXTERNAL\n' + ' IC0(XKCR) = 1\n' + ' ENDDO\n' + ' CALL %(cp)sAPPLY_CROSSING_TABLE(IFLAV, NCOMB, P, NHEL,\n' + ' & IC0, PUSE, NHELUSE, IC, FLAV_USE)\n' + # Precompute the crossed->identity helicity-row map once (the + # crossing permutation does not depend on the row), so the shared + # GOODHEL filter (keyed by the reduced FLAV_USE) can gate crossed + # rows through it just like the standalone. CROSS=0 gives + # GHIDXA(I)=I, i.e. the historical unfiltered-flavor behaviour. + ' CALL %(cp)sGET_CROSS_PERM(IFLAV, XGPERM, XGSGN, XGDUM)\n' + ' DO XGH=1,NCOMB\n' + ' CALL %(cp)sCROSS_GHIDX(CROSSUSE, XGPERM, XGSGN,\n' + ' & NHEL(1,XGH), GHIDXA(XGH))\n' + ' ENDDO' + '%(xg_decode)s' + ) % {'cp': cp, 'xg_decode': xg_decode}, + 'me_flav_key': 'FLAV_USE', + # The shared GOODHEL filter (keyed by the reduced flavor) is gated + # and trained through the runtime remap GHIDXA: crossed row I is good + # iff identity row GHIDXA(I) is. GHIDXA(I)=0 (non-filterable crossing) + # forces the row to be computed (.OR. GHIDXA(I).EQ.0) and never + # trained (GHIDXA(I).NE.0 guard). The index is clamped with MAX(...,1) + # because the gate reads GOODHEL before the .EQ.0 guard and fortran + # does not short-circuit .OR.; the clamped value is only ever read + # when GHIDXA(I).EQ.0 already forces the branch true, so it is inert. + 'me_goodhel_idx': 'MAX(GHIDXA(I),1)', + 'me_goodhel_train_guard': 'GHIDXA(I).NE.0 .AND. ', + 'smatrix_me_goodhel_or': ' .OR. GHIDXA(I).EQ.0', + 'me_matrix_args': + 'PUSE ,NHELUSE(1,I),IC,FLAV_USE,I,AMP2, JAMP2, IVEC', + # Uncrossed keeps IDEN/BROKEN_SYM; crossed rebuilds the denominator + # as initial spin*color (per crossing) times the identical-final + # factor of the actual flavors (per flavor). + 'smatrix_me_iden_line': ( + ' IF (CROSSUSE.EQ.0) THEN\n' + ' ANS=ANS/DBLE(IDEN)*BROKEN_SYM%(pid)s(FLAVOR_FOR_SYM)\n' + ' ELSE\n' + ' ANS=ANS/DBLE(IDENUSE*%(cp)sGET_IDENT_CROSS(CROSSUSE,\n' + ' & FLAVOR_FOR_SYM))\n' + ' ENDIF' + ) % {'pid': pid, 'cp': cp}, + 'crossing_routines_me': crossing_routines, + 'me_matrix_ic_param': 'IC,', + 'me_matrix_ic_decl': ' INTEGER IC(NEXTERNAL)', + # Helicity-recycling variant (matrix_hel -> matrix_optim). The + # recycled MATRIX bakes its helicity set; feeding it the crossed + # momenta PUSE and IC evaluates that set at the crossed kinematics, + # which is exactly the crossed ME -- no NHEL table (nor a helicity + # remap) is needed here. What the set must BE is the catch: IC carries + # the crossing's sign flips but nothing carries its slot permutation, + # so the set has to cover tau(G_base) as well (see + # write_crossgroup_helunion / _crossgroup_base_helsignmap). + 'smatrix_hel_cross_decl': ( + ' INTEGER NFLAV\n' + ' PARAMETER (NFLAV=%(nflav)d)\n' + ' INTEGER FLAV_USE, CROSSUSE, IDENUSE, XKCR\n' + ' INTEGER PERM(NEXTERNAL), SGN(NEXTERNAL), IC(NEXTERNAL)\n' + ' REAL*8 PUSE(0:3,NEXTERNAL)\n' + ' INTEGER %(cp)sGET_SPINCOL_CROSS\n' + ' INTEGER %(cp)sGET_IDENT_CROSS' + '%(xg_decl)s' + ) % {'nflav': nflav, 'cp': cp, 'xg_decl': xg_decl}, + 'smatrix_hel_cross_decode': ( + ' CROSSUSE = (IFLAV-1) / NFLAV\n' + ' IDENUSE = %(cp)sGET_SPINCOL_CROSS(CROSSUSE)\n' + ' IF (IDENUSE.EQ.0) THEN\n' + ' ANS = 0D0\n' + ' IHEL = 1\n' + ' ICOL = 1\n' + ' RETURN\n' + ' ENDIF\n' + ' CALL %(cp)sGET_CROSS_PERM(IFLAV, PERM, SGN, FLAV_USE)\n' + ' DO XKCR=1,NEXTERNAL\n' + ' PUSE(0,XKCR) = P(0,PERM(XKCR))\n' + ' PUSE(1,XKCR) = P(1,PERM(XKCR))\n' + ' PUSE(2,XKCR) = P(2,PERM(XKCR))\n' + ' PUSE(3,XKCR) = P(3,PERM(XKCR))\n' + ' IC(XKCR) = SGN(XKCR)\n' + ' ENDDO' + '%(xg_decode)s' + ) % {'cp': cp, 'xg_decode': xg_decode}, + 'hel_matrix_call_args': 'PUSE ,IC, FLAV_USE, TS, AMP2, JAMP2, IVEC', + 'hel_matrix_ic_param': 'IC,', + # C-parity de-duplication only for the uncrossed base process + # (CROSSUSE 0): a crossing permutes/sign-flips the helicities, so a + # base-row FLIP is not the crossed C-parity partner. Crossed + # dependents keep the full helicity sum. + 'me_csym_cross_ok': 'CROSSUSE.EQ.0', + 'hel_csym_cross_ok': 'CROSSUSE.EQ.0', + }) + + # (decl, decode, apply) for GET_PDG_FOR_FLAVOR without crossing: FLAV_IDX_IN + # is a bare flavor index, so there is nothing to permute or conjugate. + PDG_CROSS_SNIPPETS_OFF = ( + 'C Generated without crossing symmetry: FLAV_IDX_IN is a plain\n' + 'C flavor index, so the PDGs are read straight off the table.', + ' FP_FLAV = FLAV_IDX_IN', + """ DO FP_I = 1, NEXTERNAL + PDGS(FP_I) = FP_PDG_TABLE(FP_I, FP_FLAV) + ENDDO""") + + # The same three holes with crossing on. GET_CROSS_PERM is reused rather + # than re-deriving I/J here, so the PDGs reported can never disagree with + # the legs the matrix element actually evaluates: PERM(K) is the input slot + # landing in crossed slot K and SGN(K)=-1 marks exactly the legs that + # swapped between the initial and the final state, which are the ones the + # crossed process sees as their own antiparticle. + PDG_CROSS_SNIPPETS_ON = ( + """ INTEGER FP_PERM(NEXTERNAL), FP_SGN(NEXTERNAL) + INTEGER FP_CROSS + INTEGER %(proc_prefix)sGET_SPINCOL_CROSS""", + ' CALL %(proc_prefix)sGET_CROSS_PERM(FLAV_IDX_IN, FP_PERM, FP_SGN,\n' + ' & FP_FLAV)', + """C A crossing with a null spin*color entry is one SMATRIX itself maps +C to a zero matrix element (out of range, or not applicable). Report no +C PDGs for it rather than a signature that cannot be evaluated. + FP_CROSS = (FLAV_IDX_IN-1) / NFLAV + IF (%(proc_prefix)sGET_SPINCOL_CROSS(FP_CROSS) .EQ. 0) THEN + RETURN + ENDIF + DO FP_I = 1, NEXTERNAL + IF (FP_SGN(FP_I) .EQ. 1) THEN + PDGS(FP_I) = FP_PDG_TABLE(FP_PERM(FP_I), FP_FLAV) + ELSE + PDGS(FP_I) = FP_ANTI_TABLE(FP_PERM(FP_I), FP_FLAV) + ENDIF + ENDDO""") + + # Copy the arguments through unchanged: same shape as the crossing block it + # replaces, so its (single) caller does not have to know which is which. + CROSS_PASSTHROUGH = """C No crossing to apply: the arguments go through unchanged. + PUSE(:,:) = P(:,:) + %(nhel_copy)s + ICUSE(:) = IC(:) + DO IPART=1,N_CHANGING + CPOS(IPART) = POS(IPART) + ENDDO""" + + # The crossing-aware variants of the same holes. Kept here rather than in + # the template because the template can only hold one variant per hole. + CROSSING_SNIPPETS = { + 'smatrix_cross_decl': """C CROSSUSE is the crossing carried by FLAV_IDX and IDENUSE the initial +C state spin*color average of the process it crosses into. + INTEGER IDENUSE, CROSSUSE + INTEGER %(proc_prefix)sGET_SPINCOL_CROSS + INTEGER %(proc_prefix)sGET_IDENT_CROSS +C Crossed copies of the arguments, built ONCE per SMATRIX call (see the +C BEGIN CODE section). They are only touched when a crossing is actually +C requested, so the uncrossed path pays nothing for them. + REAL*8 PUSE(0:3,NEXTERNAL) + INTEGER NHELUSE(NEXTERNAL,NCOMB) + INTEGER ICUSE(NEXTERNAL) + INTEGER DUMFLAV +C GHIDX is the identity row whose shared GOODHEL bit gates the current +C crossed row, recomputed at runtime by CROSS_GHIDX (which owns the small +C per-crossing GHFILT flag table); XGPERM/XGSGN are the crossing's slot +C permutation and NSF signs, fetched once per call (see smatrix_cross_apply). + INTEGER GHIDX + INTEGER XGPERM(NEXTERNAL), XGSGN(NEXTERNAL), XGDUM""", + + 'smatrix_cross_decode': """C CROSS = (FLAV_IDX-1)/NFLAV is the crossing to apply. IDENUSE is 0 for a +C crossing that cannot be applied, whose matrix element is identically zero. + CROSSUSE = (FLAV_IDX-1) / NFLAV + IDENUSE = %(proc_prefix)sGET_SPINCOL_CROSS(CROSSUSE) + IF (IDENUSE.EQ.0) THEN + ANS = 0D0 + RETURN + ENDIF""", + + 'smatrix_cross_apply': """C Fetch the crossing's slot permutation / NSF signs once (the good-helicity +C gate below reuses them per helicity via CROSS_GHIDX). Cheap, and the +C identity crossing returns the identity permutation. + CALL %(proc_prefix)sGET_CROSS_PERM(FLAV_IDX, XGPERM, XGSGN, XGDUM) +C Apply the crossing ONCE, here, rather than once per helicity: the whole +C NHEL table is permuted in one go (the crossing is a fixed slot +C permutation, identical for every row) together with the momenta and the +C NSF/NSV flags. When CROSSUSE is 0 nothing is copied at all and the loop +C below passes the original arrays straight through, exactly as it did +C before crossings existed. + IF (CROSSUSE.NE.0) THEN + CALL %(proc_prefix)sAPPLY_CROSSING_TABLE(FLAV_IDX, NCOMB, P, NHEL, + & JC, PUSE, NHELUSE, ICUSE, DUMFLAV) + ENDIF""", + + 'smatrix_goodhel_gate': """C The good-helicity filter (GOODHEL) is shared by every crossing of a +C flavor, but a crossing permutes and flips helicities, so a crossed row +C and its identity counterpart are different rows. CROSS_GHIDX sends crossed +C row IHEL to the identity row that gates it (sigma^-1, recomputed from the +C config); GHIDX=0 means the crossing is not filterable (an initial-initial +C swap, or a crossing that cannot be applied) so its every helicity is +C computed. For CROSSUSE=0 it returns IHEL, exactly the historical gate. + CALL %(proc_prefix)sCROSS_GHIDX(CROSSUSE, XGPERM, XGSGN, + & NHEL(1,IHEL), GHIDX) + IF (GHIDX.EQ.0 .OR. GOODHEL(GHIDX,FLAV_USE) .OR. NTRY(FLAV_USE).LT.20 .OR. USERHEL.NE.-1) THEN""", + + 'smatrix_goodhel_train': """C Train the SHARED filter through the same map: mark the IDENTITY row +C GHIDX good, so GOODHEL always stores the identity pattern whatever +C crossing is being evaluated. GHIDX=0 (non-filterable crossing) never +C trains. For CROSSUSE=0 GHIDX=IHEL, so this is the historical training. + IF (T .NE. 0D0 .AND. GHIDX.NE.0 .AND. .NOT.GOODHEL(GHIDX,FLAV_USE)) THEN + GOODHEL(GHIDX,FLAV_USE)=.TRUE. + ENDIF""", + + 'smatrix_matrix_call': """ IF (CROSSUSE.EQ.0) THEN + T=%(proc_prefix)sMATRIX(P ,NHEL(1,IHEL),JC(1),FLAV_USE) + ELSE + T=%(proc_prefix)sMATRIX(PUSE,NHELUSE(1,IHEL),ICUSE(1) + & ,FLAV_USE) + ENDIF""", + + 'smatrix_iden_line': """C Uncrossed: keep the historical path untouched (IDEN carries the +C representative's identical factor and BROKEN_SYM corrects it per flavor). +C Crossed: BROKEN_SYM's tables describe the uncrossed final state and +C cannot express the crossed one, so rebuild the denominator instead as +C initial state spin*color (per crossing) times the identical final state +C factor of the actual crossed flavors (per flavor). + IF (CROSSUSE.EQ.0) THEN + ANS=ANS/DBLE(IDEN)*%(proc_prefix)sBROKEN_SYM(FLAVOR) + ELSE + ANS=ANS/DBLE(IDENUSE*%(proc_prefix)sGET_IDENT_CROSS(CROSSUSE, + & FLAVOR)) + ENDIF""", + + 'inter_rescale_decl': """ INTEGER CROSS, DCROSS, IDEN + INTEGER %(proc_prefix)sGET_SPINCOL_CROSS + INTEGER %(proc_prefix)sGET_IDENT_CROSS + %(den_factor_line)s""", + + 'inter_rescale_body': """ CROSS = (FLAV_IDX-1)/NFLAV + IF (CROSS.EQ.0) THEN +C Uncrossed: the static IDEN carries the identical-particle factor of the +C representative flavor, so BROKEN_SYM must correct it for the actual one, +C exactly as SMATRIX does with ANS/IDEN*BROKEN_SYM. + RESCALE = DBLE(%(proc_prefix)sBROKEN_SYM(FLAVOR)) + ELSE +C Crossed: BROKEN_SYM's tables describe the uncrossed final state and are +C useless here; rebuild the whole denominator instead (see SMATRIX) and +C undo the IDEN that GET_INTER divided by. + DCROSS = %(proc_prefix)sGET_SPINCOL_CROSS(CROSS) + & * %(proc_prefix)sGET_IDENT_CROSS(CROSS, FLAVOR) + IF (DCROSS.EQ.0) THEN + RESCALE = 0D0 + ELSE + RESCALE = DBLE(IDEN)/DBLE(DCROSS) + ENDIF + ENDIF""", + + 'density_cross_apply': """ CALL %(proc_prefix)sAPPLY_CROSSING_TABLE(FLAV_IDX, NB_NHEL, P, NHEL, + & IC, PUSE, NHELUSE, ICUSE, DUMFLAV) +C POS is given in uncrossed slots; PERM(K) is the uncrossed slot sitting in +C crossed slot K, so invert it to move POS into the crossed numbering. + CALL %(proc_prefix)sGET_CROSS_PERM(FLAV_IDX, PERM, SGN, DUMFLAV) + DO IPART=1,N_CHANGING + DO I=1,NEXTERNAL + IF (PERM(I).EQ.POS(IPART)) CPOS(IPART) = I + ENDDO + ENDDO""", + + 'allinter_cross_apply': """C IC starts at +1 everywhere; APPLY_CROSSING flips it for the legs that the +C crossing carried by FLAV_IDX moves across. + IC(:)=1 + CALL %(proc_prefix)sAPPLY_CROSSING(FLAV_IDX, P, NHEL, IC, PUSE, + & NHELUSE, ICUSE, DUMFLAV) + CALL %(proc_prefix)sGET_CROSS_PERM(FLAV_IDX, PERM, SGN, DUMFLAV) + DO IPART = 1, N_CHANGING + DO I = 1, NEXTERNAL + IF (PERM(I).EQ.POS(IPART)) CPOS(IPART) = I + ENDDO + ENDDO""", + } + + def get_iden_cross_lines(self, matrix_element): + """Return the DATA lines backing the crossing-dependent denominator. + + SMATRIX must divide by the averaging/symmetry factor of the *crossed* + process. That factor splits in two, and the two halves must be handled + differently: + + - the initial state spin*color average changes with the crossing (a + gluon pulled into the initial state takes the color average from 3 to + 8) but NOT with the flavor, since every particle of a flavor group + shares its spin and color. It is emitted as SPINCOL_CROSS_TABLE, + indexed by CROSS. + - the identical final state factor changes with the FLAVOR: e.g. + d d~ > g u u~ crossed gives d g > d u u~ (nothing identical) while + d d~ > g d d~ crossed gives d g > d d d~ (two identical d). It cannot + be tabulated on CROSS alone, and the existing BROKEN_SYM cannot help: + its tables describe the *uncrossed* final state, so for this process + it emits COMP_OLD=1 and returns 1 whatever flavor array it is given. + It is therefore computed at runtime by GET_IDENT_CROSS, from the two + per-particle tables below. + + The per-slot representative PDG (BASEPID) and FLAVOR source slot (SRC) + GET_IDENT_CROSS needs are not tabulated per crossing: they follow from + the crossing's own PERM/IC (the same GET_SPINCOL_CROSS decodes) applied + to two NEXTERNAL-long base tables. IDS_BASE is the base process PDG of + each leg; ANTIPID_BASE is its charge conjugate (used for a leg that + swapped between the initial and the final state). Slot k of crossing + CROSS then reads leg PERM(k), conjugated when IC(k) flipped, and looks + up FLAVOR(PERM(k)); two crossed final legs are identical iff they share + both. This drops the two NCROSS*NEXTERNAL-long tables. + + A crossing that cannot be applied gets a 0 spin*color entry, which + SMATRIX maps to a null matrix element. + """ + tables = self.compute_crossing_tables(matrix_element) + + return '\n'.join([ + self.format_integer_data_lines('SPINCOL_PART', tables['spincol_part']), + self.format_integer_data_lines('IDS_BASE', tables['ids_base']), + self.format_integer_data_lines('ANTIPID_BASE', tables['antipid_base']), + self.format_integer_data_lines('COUNTABLE', tables['countable'])]) + + @staticmethod + def _leaf_block_sizes(process): + """Per core leg, the number of decay leaves it expands to. + + A decay chain's matrix element runs over the decay *leaves*, but a + crossing acts at the *production* level: it may only permute whole + production legs, and a decaying production leg carries its whole decay + block (all its leaves) as one unit. This returns a list parallel to + ``process.get('legs')`` giving each core leg's leaf count -- 1 for a + non-decaying leg (a single leaf that a crossing may move), >1 for a + decaying resonance (a block a crossing must never split or pull into the + initial state). Non-decay processes get all 1s, so every downstream use + is a no-op for them. Mirrors base_objects.get_legs_with_decays exactly: + decays are matched to final legs in leg order, first id-match wins. + """ + decays = list(process.get('decay_chains')) + sizes = [] + for leg in process.get('legs'): + if not leg.get('state') or not decays: + sizes.append(1) + continue + ids = [d.get('legs')[0].get('id') for d in decays] + if leg.get('id') in ids: + decay = decays.pop(ids.index(leg.get('id'))) + sizes.append(len(decay.get_legs_with_decays()) - 1) + else: + sizes.append(1) + return sizes + + def compute_crossing_tables(self, matrix_element): + """Build the crossing tables as plain python int lists (model-agnostic). + + Returns a dict with, for every crossing code CROSS in + 0..(NEXTERNAL+1)**2-1: + 'spincol' : SPINCOL_CROSS_TABLE[CROSS], the initial-state spin*color + average of the crossed process (0 = crossing that must not + be applied: out of range, impossible, or an overlapping + swap, see get_crossing_permutation); + 'basepid' : flattened CROSS*NEXTERNAL+slot -> representative signed PDG + of the particle landing in that crossed slot (conjugated + when the leg swapped between the initial and the final + state); + 'source' : flattened CROSS*NEXTERNAL+slot -> 0-based index of the + original leg that moved into that slot (FLAVOR is NOT + permuted, so this says which FLAVOR entry a slot reads); + 'perm' : flattened CROSS*NEXTERNAL+slot -> 0-based perm[slot]; + 'ic' : flattened CROSS*NEXTERNAL+slot -> +-1 NSF sign of that slot; + 'nexternal', 'ninitial'. + + Both the fortran (get_iden_cross_lines) and the C++ standalone exporter + consume this, so the two backends can never disagree about a crossing. + """ + process = matrix_element.get('processes')[0] + model = process.get('model') + # For a decay chain the crossing acts at the production level but the + # matrix element (and its NEXTERNAL) is over the decay *leaves*, so the + # crossing tables must span the leaves too: the two z of e+ e- > z z + # look like an identical pair on the core legs, yet z > mu+ mu- and + # z > e+ e- make the real final state non-identical (denominator 4, not + # 8). get_legs_with_decays() is the plain legs for a non-decay process. + legs = process.get_legs_with_decays() \ + if hasattr(process, 'get_legs_with_decays') else process.get('legs') + nexternal = len(legs) + leg_ids = [leg.get('id') for leg in legs] + # polarization restricts the number of helicity states of a leg; it is + # attached to the leg, and a crossing moves legs around, so carry it. + polarizations = [leg.get('polarization') for leg in legs] + + # Per LEAF: the size of the production block it belongs to, and whether + # it is 'countable' for the identical-final factor. A crossing permutes + # production legs, so a decaying leg's whole block (its >1 leaves) moves + # as a unit; the CROSS codes can only transpose single leaves, so any + # crossing that would carry a block leaf into the initial state (splitting + # the block, or making a decaying resonance an initial particle) is + # rejected below. block_size is 1 for every leaf of a non-decay process, + # so decay chains are the only ones this constrains. + block_size = [] + # Referenced through the class, not self: the C++/mg7 exporters call + # compute_crossing_tables unbound with a non-Fortran self (see the + # get_iden_cross_lines docstring), which has no _leaf_block_sizes. + for size in ProcessExporterFortran._leaf_block_sizes(process): + block_size.extend([size] * size) + assert len(block_size) == nexternal, \ + 'leaf block sizes %s do not span NEXTERNAL %d' % (block_size, + nexternal) + # A block leaf (size > 1) is a decay product locked inside a resonance: + # it never counts toward the identical-final factor at the leaf level + # (that factor is resonance-level, see ident_resonance below). A single + # leaf (size 1) is a genuine external and does count. + countable = [1 if size == 1 else 0 for size in block_size] + + def particle(pdg): + return model.get('particle_dict')[pdg] + + ninitial = len([leg for leg in legs if not leg.get('state')]) + + spincol = [] + basepid = [] + source = [] + perm_flat = [] + ic_flat = [] + # CROSS = I*(NEXTERNAL+1)+J with I and J both in 0..NEXTERNAL. + for cross in range((nexternal + 1) * (nexternal + 1)): + perm, ic, valid = ProcessExporterFortran.get_crossing_permutation( + cross, nexternal) + if not valid: + # Overlapping-swap code: pure redundancy, and inconsistent + # between GET_PDG_FOR_FLAVOR and APPLY_CROSSING (see + # get_crossing_permutation). A 0 spin*color marks it as a + # crossing that must not be applied, exactly as for one that + # genuinely cannot be; both SMATRIX and GET_PDG_FOR_FLAVOR then + # refuse it via GET_SPINCOL_CROSS==0. + spincol.append(0) + slot_ids = list(leg_ids) + else: + try: + # A leg that swapped between the initial and the final state + # is seen as its own antiparticle by the crossed process. + slot_ids = [leg_ids[perm[slot]] if ic[slot] == 1 + else particle(leg_ids[perm[slot]]).get_anti_pdg_code() + for slot in range(nexternal)] + + # Two codes that name no crossing, rejected exactly like an + # impossible one: a 0 spin*color makes SMATRIX and + # GET_PDG_FOR_FLAVOR both return a null result. slot_ids is + # still the permuted signature so the IDS_BASE/BASEPID + # rebuild sanity below stays consistent. GET_CROSS_PERM + # applies the same two rules at runtime. + # + # 1. A leg conjugated without changing side. The two legs of + # a same-side transposition are both conjugated while + # neither moves across, which is no crossing at all: for + # 2 -> N that is the beam swap (XI==2 / XJ==1), giving + # e.g. u~ g > e+ ve d, not even charge conserving; for + # 1 -> N it is every XJ swap. + # 2. A decay-block leaf carried across the initial/final + # line: it would split the block (pull one decay product + # into the initial state) or make a decaying resonance an + # initial particle. For a non-decay process every + # block_size is 1, so this one never fires. + if any(ic[slot] == -1 and + ((slot < ninitial) == (perm[slot] < ninitial) + or block_size[perm[slot]] > 1) + for slot in range(nexternal)): + spincol.append(0) + else: + # The crossing always keeps slots 1..ninitial initial. + factor = 1 + for slot in range(ninitial): + pol = polarizations[perm[slot]] + factor *= len(pol) if pol else \ + len(particle(slot_ids[slot]).get_helicity_states()) + # get('color') is signed for antiparticles; only the + # size of the representation matters for the average. + factor *= abs(particle(slot_ids[slot]).get('color')) + spincol.append(factor) + except (KeyError, IndexError): + spincol.append(0) + slot_ids = list(leg_ids) + + basepid.extend(slot_ids) + source.extend(perm[slot] for slot in range(nexternal)) + perm_flat.extend(perm) + ic_flat.extend(ic) + + # Per-particle spin*color (states * |color repr|), for every base leg. + # It is conjugation-invariant (a particle and its antiparticle share + # both), so a crossing's initial-state spin*color is just the product of + # these over the legs that land in the initial slots -- which is how + # GET_SPINCOL_CROSS recomputes SPINCOL_CROSS_TABLE at runtime from the + # NEXTERNAL-long SPINCOL_PART instead of the NCROSS-long table. + spincol_part = [] + for slot in range(nexternal): + pol = polarizations[slot] + nspin = len(pol) if pol else \ + len(particle(leg_ids[slot]).get_helicity_states()) + spincol_part.append(nspin * abs(particle(leg_ids[slot]).get('color'))) + + # Per-particle base PDG and its charge conjugate, one entry per base + # leg. GET_IDENT_CROSS rebuilds BASEPID_CROSS_TABLE / SRC_CROSS_TABLE at + # runtime from these two NEXTERNAL-long tables plus the crossing PERM/IC, + # instead of storing the two NCROSS*NEXTERNAL-long tables. + ids_base = list(leg_ids) + antipid_base = [particle(pid).get_anti_pdg_code() for pid in leg_ids] + + # ident_resonance: the part of the identical-final factor a crossing + # leaves untouched. A crossing only ever permutes the single-leaf + # (countable) legs -- decay blocks stay put -- so the crossed identical + # factor is (n! over the crossed countable final legs) times this + # constant. It collects everything a leaf-level count over the crossed + # legs cannot see: identical resonances decaying identically, and the + # identical particles locked inside each decay block. base_non_chain is + # the identical factor of the base's own countable final legs, so + # dividing it out of the resonance-level identical_particle_factor leaves + # exactly that constant. For a non-decay process every final leg is + # countable and there are no resonances, so base_non_chain equals the + # whole identical factor and ident_resonance is 1 -- GET_IDENT_CROSS then + # reduces to the historical plain leaf count. + final_countable = collections.defaultdict(int) + for slot in range(ninitial, nexternal): + if countable[slot]: + final_countable[(leg_ids[slot], + tuple(polarizations[slot] or []))] += 1 + base_non_chain = 1 + for count in final_countable.values(): + base_non_chain *= math.factorial(count) + identical = matrix_element.get('identical_particle_factor') + assert identical % base_non_chain == 0, \ + 'Countable identical factor %d does not divide the identical-' \ + 'particle factor %d' % (base_non_chain, identical) + ident_resonance = identical // base_non_chain + + # Sanity: for the identity crossing, spin*color times the identical + # factor must rebuild the static IDEN, else this and + # get_denominator_factor have drifted apart. A decay chain's identical + # factor is resonance-level (two z decaying the same way count once, + # differently not at all), so it is checked through + # identical_particle_factor rather than a leaf count; the initial + # spin*color (which may carry a sign from an antiparticle beam in + # get_denominator_factor but not in the abs-based spincol) is only + # required to divide IDEN. + if process.get('decay_chains'): + assert matrix_element.get_denominator_factor() % spincol[0] == 0, \ + 'Crossing initial spin*color does not divide IDEN: ' \ + '%s vs %s' % (spincol[0], + matrix_element.get_denominator_factor()) + else: + assert spincol[0] * identical == \ + matrix_element.get_denominator_factor(), \ + 'Crossing denominator disagrees with get_denominator_factor: ' \ + '%s*%s vs %s' % (spincol[0], identical, + matrix_element.get_denominator_factor()) + # Sanity: the small per-particle tables reproduce the per-crossing + # tables the runtime routines used to read. SPINCOL_PART -> the + # initial-state spin*color; IDS_BASE/ANTIPID_BASE plus the crossing + # PERM/IC -> BASEPID_CROSS_TABLE / SRC_CROSS_TABLE (checked for the + # applicable crossings, the only ones GET_IDENT_CROSS is ever asked). + for cross in range((nexternal + 1) * (nexternal + 1)): + perm, ic, valid = \ + ProcessExporterFortran.get_crossing_permutation(cross, nexternal) + expect = 0 if not valid else 1 + if valid: + for slot in range(ninitial): + expect *= spincol_part[perm[slot]] + assert expect == spincol[cross] or spincol[cross] == 0, \ + 'SPINCOL_PART product %s != SPINCOL_CROSS_TABLE %s at CROSS %d' \ + % (expect, spincol[cross], cross) + if not valid: + continue + for slot in range(nexternal): + bp = ids_base[perm[slot]] if ic[slot] == 1 \ + else antipid_base[perm[slot]] + assert bp == basepid[cross * nexternal + slot] and \ + perm[slot] == source[cross * nexternal + slot], \ + 'IDS_BASE/ANTIPID_BASE rebuild != BASEPID/SRC at CROSS ' \ + '%d slot %d' % (cross, slot) + + return {'spincol': spincol, 'spincol_part': spincol_part, + 'ids_base': ids_base, 'antipid_base': antipid_base, + 'basepid': basepid, 'source': source, + 'perm': perm_flat, 'ic': ic_flat, + 'countable': countable, 'ident_resonance': ident_resonance, + 'nexternal': nexternal, 'ninitial': ninitial} + + def _flavor_rep_rows(self, matrix_element): + """PDG-table row representing each madevent / C++ / mg7 flavor index. + + The two tables involved are indexed differently and only look alike: + + * ``_build_flav_pdg_tables`` is indexed by ``compute_flavor_masks()`` -- + ONE ROW PER PHYSICAL FLAVOR COMBINATION (15 rows for ``Q Q~ > t t~ + Q Q~`` with three quark flavors). + * those backends' flavor index counts the COUPLING-EQUIVALENCE CLASSES + of ``get_external_flavors_with_iden()`` (3 for the same matrix + element), and the FLAVOR table they read is built from each class's + representative ``flav[0]`` -- see the ``get_flavor_matrix`` fills. + + Row ``f`` of the first table is the representative of class ``f`` only + while the leading masks rows happen to BE the representatives, which + stops holding from three merged flavors on: for ``Q Q~ > t t~ Q Q~`` + class 2 (``q q~' > t t~ q q~'``, the mixed t-channel one) is masks row 3, + while row 2 is ``q q~ > t t~ q'' q~''``, a member of class 1. Taking the + ordinal therefore names a process the flavor index does not select, and + the consumers (partition_crossing_classes' routing, the recorded-crossing + intersection behind crossed_flavors.dat, the C++ demo_pdg table) match on + exactly that signature. + + So look the representative up instead of assuming it. Returns one + 0-based row per flavor class. The ordinal is kept as a fall-back for a + representative that cannot be located -- not expected, decay chains span + the leaves on both sides and do line up, but a wrong row is a better + outcome than a traceback in a table this deep in the exporter. + """ + masks = matrix_element.compute_flavor_masks() + classes = list(matrix_element.get_external_flavors_with_iden()) + rowof = {tuple(mask): row for row, mask in enumerate(masks)} + rows = [] + for flav0, members in enumerate(classes): + row = rowof.get(tuple(members[0])) if members else None + if row is None: + logger.debug( + 'Crossing: flavor class %d of %s has no row in the flavor ' + 'mask table; falling back to the ordinal.' + % (flav0, matrix_element.get('processes')[0].shell_string())) + row = flav0 if flav0 < len(masks) else 0 + rows.append(row) + return rows + + def compute_crossing_pdg_entries(self, matrix_element, zero_based=True): + """Enumerate the reachable extended flavor indices and their crossed PDG. + + Returns a list of ``(index, cross, flav0, pdg_tuple)`` for every crossing + code CROSS that can actually be applied (SPINCOL_CROSS_TABLE[CROSS] != 0, + i.e. skipping the out-of-range / impossible / overlapping-swap codes) and + every flavor ``flav0`` in ``0..NFLAV-1``: + + * ``index`` -- the extended flavor index that selects (CROSS, flav0), + decoded 0-based as ``cross*NFLAV + flav0`` (``zero_based=False`` gives + the 1-based fortran form). **NFLAV here is the madevent / C++ / mg7 + one**, ``get_external_flavors_with_iden()`` -- the count those backends + size their flavor table by, deliberately not the STANDALONE fortran + NFLAV, which comes from _build_flav_table_flat (compute_flavor_masks) + and is a different, usually larger number: 1 vs 2 for + ``p p > w+ j, w+ > e+ ve``, 2 vs 4 for ``p p > z j``, 1/1/9 vs 1/4/12 + for ``p p > j j``. See the NFLAV comment in get_crossing_routines. + So ``index`` is meaningful to partition_crossing_classes (madevent + routing) and to the C++ demo_pdg table, and NOT to the standalone + fortran PY_GET_PDG_FOR_FLAVOR: a caller holding a standalone + module must take NFLAV from PY_GET_FLAVOR_LAYOUT and build the + index itself (reweight_interface.build_cross_resolve does). ``cross`` + and ``pdg_tuple`` carry no such convention and are good everywhere. + * ``cross`` -- the crossing code (0 == identity). + * ``flav0`` -- the 0-based reduced flavor. + * ``pdg_tuple`` -- the *signed physical* PDG of each leg, in the leg order + the momenta must be supplied in for that index (legs permuted and + conjugated where they swapped between the initial and the final state). + + This is the python twin of the fortran runtime GET_PDG_FOR_FLAVOR *for + the signature*: the C++ and mg7 standalones have no runtime PDG + accessor, so their crossed PDG signatures are computed here instead (the + same logic that fills the check_sa demo table). The backends agree on + which PDG tuple a (CROSS, flavor) names; they do NOT share one index + convention, see ``index`` above. Both helpers are referenced through the + class so a non-Fortran ``self`` (the C++/mg7 exporter, or a throwaway) + can reuse them unbound. + """ + tables = ProcessExporterFortran.compute_crossing_tables( + self, matrix_element) + spincol = tables['spincol'] + perm = tables['perm'] + ic = tables['ic'] + nx = tables['nexternal'] + ncross = len(spincol) + n_flav = len(matrix_element.get_external_flavors_with_iden()) + _, pdg_flat, antipdg_flat = \ + ProcessExporterFortran._build_flav_pdg_tables(self, matrix_element) + # The pdg tables are indexed by physical flavor combination, not by + # flavor index; _flavor_rep_rows bridges the two. + rep_rows = ProcessExporterFortran._flavor_rep_rows( + self, matrix_element) + + entries = [] + for cross in range(ncross): + if spincol[cross] == 0: + continue + for flav0 in range(n_flav): + row = rep_rows[flav0] + pdg = [] + for k in range(nx): + src = perm[cross * nx + k] + if ic[cross * nx + k] == 1: + pdg.append(pdg_flat[row * nx + src]) + else: + pdg.append(antipdg_flat[row * nx + src]) + index = cross * n_flav + flav0 + if not zero_based: + index += 1 + entries.append((index, cross, flav0, tuple(pdg))) + return entries + + def find_reorder_candidates(self, matrix_elements): + """Modules that keep their own matrix.f ONLY because one flavor class + is listed with its final legs the other way round. + + Pure analysis -- it changes no routing and no output. It names the work a + split would have to do, and it is the check that says whether a split is + worth attempting for a given process at all. + + A module drops its matrix.f only when EVERY flavor routes + (partition_crossing_classes), so one stubborn class keeps a whole 14- + diagram matrix element alive. For ``Q Q~ > t t~ Q Q~`` off + ``Q Q > t t~ Q Q`` that class is the flavor-changing annihilation + ``q q~ > t t~ q' q~'``: the crossing (I=0, J=5) delivers it as + ``(q~', q')`` while the module lists ``(q', q~')``. The module cannot fix + that by relabelling itself -- its leg pattern is shared by all its rows, + the FLAVOR table carrying unsigned group POSITIONS -- and no single + ordering suits all three of its classes anyway: flipping it repairs the + annihilation class and breaks the mixed t-channel one. + + Peeling the class out into its own subprocess, GENERATED in the order the + crossing reaches, removes the conflict: written that way the process + keeps its diagrams (7 either way) and its signature matches the crossing + exactly, so it routes with no permutation applied anywhere at run time. + That is the point of doing it at generation rather than at the call site: + diagrams, configs, colour basis, helicity table, leshouche and flavor + table are then all built together in one order, and none of the + base->dependent maps needs composing with anything. + + Returns ``{me_index: [(flav0, sigma, base_index, iflav), ...]}`` naming, + per module, the classes that need peeling; ``sigma`` is the final-leg + permutation their signature needs (0-based, indexed by the base's crossed + slot). Modules absent from the dict are already fine -- either they route + as they are, or a reorder would not save them either. + """ + n = len(matrix_elements) + if not n: + return {} + nini = matrix_elements[0].get_nexternal_ninitial()[1] + + def canon(pdg): + return (tuple(pdg[:nini]), tuple(sorted(pdg[nini:]))) + + def reorder(crossed, sig): + if tuple(crossed[:nini]) != tuple(sig[:nini]): + return None + nx = len(sig) + sigma = list(range(nx)) + free = [k for k in range(nini, nx) if crossed[k] != sig[k]] + taken = set(range(nini)) | set(k for k in range(nini, nx) + if k not in free) + for k in free: + for j in range(nini, nx): + if j not in taken and sig[j] == crossed[k]: + sigma[k] = j + taken.add(j) + break + else: + return None + return tuple(sigma) + + sig_by_flav, exact, loose = [], [], [] + for me in matrix_elements: + sbf, cm_e, cm_l = {}, {}, {} + for idx, cross, flav0, pdg in \ + self.compute_crossing_pdg_entries(me, zero_based=False): + if cross == 0: + sbf[flav0] = pdg + cm_e.setdefault(pdg, (cross, idx, pdg)) + cm_l.setdefault(canon(pdg), (cross, idx, pdg)) + nflav = (max(sbf) + 1) if sbf else 0 + sig_by_flav.append([sbf[f] for f in range(nflav)]) + exact.append(cm_e) + loose.append(cm_l) + + # Replay the real (exact-match) partition so the answer reflects the + # bases routing actually picks. + bases, blocked = [], {} + for i in range(n): + hits, ok = [], bool(bases) + for flav0, sig in enumerate(sig_by_flav[i]): + hit = None + for b in bases: + cx = exact[b].get(sig) + if cx is not None and cx[0] != 0: + hit = True + break + if hit is None: + ok = False + blocked.setdefault(i, []).append(flav0) + if not ok: + bases.append(i) + + out = {} + for i, blocked_flavs in blocked.items(): + if i not in bases: + continue # already routes; nothing to peel + peel, savable = [], True + for flav0 in blocked_flavs: + sig = sig_by_flav[i][flav0] + found = None + for b in bases: + if b >= i: + continue # only earlier modules are bases + cx = loose[b].get(canon(sig)) + if cx is None or cx[0] == 0: + continue + sigma = reorder(cx[2], sig) + if sigma is not None: + found = (flav0, sigma, b, cx[1]) + break + if found is None: + savable = False # a reorder would not save it + break + peel.append(found) + if savable and peel: + out[i] = peel + return out + + def partition_crossing_classes(self, matrix_elements): + """Route each subprocess *flavor* to a base matrix element via crossing. + + The crossing relates whole flavor combinations, not whole modules: a + flavor-merged matrix element bundles flavors that cross to *different* + bases (e.g. within a group ``u u~ > u u~`` is a crossing of ``u u > u u`` + while its module-mate ``d d~ > u u~`` is not). So the sharing that lets + one matrix.f serve several subprocesses is decided per flavor: a + module can drop its own matrix.f only when EVERY one of its flavors is + a genuine crossing (cross != 0) of some *base* module's flavor; otherwise + it stays a base and keeps its own matrix.f. + + Bases are chosen greedily in order. Returns ``(bases, routing)``: + + * ``bases`` -- the matrix_element indices that keep their own + matrix.f (their SMATRIX, driven by an extended FLAV_IDX, also serves + the flavors routed to them). + * ``routing`` -- a list parallel to ``matrix_elements``; ``routing[i]`` + has one ``(base_index, iflav)`` per flavor of member ``i`` (in flavor + order), naming the base module whose ``SMATRIX`` evaluates that flavor + and the 1-based extended ``FLAV_IDX`` to call it with. A base routes + each of its own flavors to itself with the plain (cross 0) index. + + Signatures are the crossed physical PDG tuples of compute_crossing_pdg_ + entries, the same key check_crossing matches on, so the momentum order a + member supplies already matches what the base SMATRIX expects for that + index. + """ + n = len(matrix_elements) + # Per ME: identity signature of each flavor (flavor order) and the map + # from any crossed signature it can reach to (cross, 1-based FLAV_IDX). + sig_by_flav = [] + crossmap = [] + for me in matrix_elements: + sbf = {} + cm = {} + for idx, cross, flav0, pdg in \ + self.compute_crossing_pdg_entries(me, zero_based=False): + if cross == 0: + sbf[flav0] = pdg + cm.setdefault(pdg, (cross, idx)) + nflav = (max(sbf) + 1) if sbf else 0 + sig_by_flav.append([sbf[f] for f in range(nflav)]) + crossmap.append(cm) + + bases = [] + routing = [None] * n + for i in range(n): + cover = [] + coverable = bool(bases) # nothing to route to before the first base + for sig in sig_by_flav[i]: + hit = None + for b in bases: + cx = crossmap[b].get(sig) + if cx is not None and cx[0] != 0: # a genuine crossing of b + hit = (b, cx[1]) + break + if hit is None: + coverable = False + break + cover.append(hit) + if coverable: + routing[i] = cover # drop i's matrix.f; route each flavor + else: + bases.append(i) # i keeps its own matrix.f (a base) + routing[i] = [(i, crossmap[i][sig][1]) for sig in sig_by_flav[i]] + return bases, routing + + def compute_crossgroup_routing(self, subproc_groups): + """Cross-group crossing (Track B): find whole subprocess GROUPS whose + matrix element is a crossing of another group's, so the dependent group + can REUSE (symlink) the base group's compiled matrix element instead of + generating and compiling its own. Used for e.g. lepton/photon beams where + each initial state lands in its own single-process P directory and the + crossings relate different P directories (partition_crossing_classes is + group-agnostic -- it clusters by crossed-PDG signature -- so it is fed the + flat list of every group's matrix elements). + + Returns a dict keyed by ``(group_enum_idx, me_idx)`` for the DEPENDENT + members only; each value carries the base group's directory, the base + SMATRIX's proc_id, the base matrix_element (for the COLMAP/CONFIGMAP + remaps) and the crossed 1-based FLAV_IDX per flavor. Bases are absent + (they keep their own matrix element). Only a dependent whose EVERY flavor + crosses to a SINGLE base matrix element is routed; anything else keeps its + own matrix element (so the sharing is always a clean whole-ME reuse). + """ + if not self.opt.get('use_crossing', False): + return {} + # Consider only groups whose every member is a within-group BASE (no + # router). A group that ALREADY has within-group crossing routing (the + # hadronic p p groups where several crossings co-locate under a `j` + # multiparticle) is left to Track A -- mixing its base(s) with those + # routers is fragile, so it is excluded here. The lepton/photon single- + # process groups are all bases; a p p run additionally exposes the cross- + # P-directory crossings that within-group routing cannot reach (e.g. + # g g > q q~ vs q q~ > g g, in their own P directories). + flat = [] # (group_enum_idx, me_idx, matrix_element) + for gi, group in enumerate(subproc_groups): + mes_g = group.get('matrix_elements') + # A group that breaks crossing (pinned s-channel, or a perturbative + # / loop-induced matrix element) has no crossing tables -- skip it + # before partition_crossing_classes, which would index past the end. + if any(self.breaks_crossing_symmetry(proc) + for me in mes_g for proc in me.get('processes')): + continue + g_bases, _ = self.partition_crossing_classes(mes_g) + if len(g_bases) < len(mes_g): + continue # within-group routing -> leave to Track A + for mi, me in enumerate(mes_g): + flat.append((gi, mi, me)) + if not flat: + return {} + # A pinned s-channel does not survive crossing (see breaks_crossing_ + # symmetry): fall back to independent matrix elements. + if any(self.breaks_crossing_symmetry(proc) + for (_, _, me) in flat for proc in me.get('processes')): + return {} + mes = [me for (_, _, me) in flat] + bases, routing = self.partition_crossing_classes(mes) + result = {} + for flat_i, (gi, mi, me) in enumerate(flat): + if flat_i in bases: + continue + route = routing[flat_i] # per flavor: (base_flat, iflav) + base_flats = set(bflat for (bflat, _) in route) + if len(base_flats) != 1: + # flavors cross to different bases (a merged group): no single ME + # to symlink, keep this member's own matrix element. + continue + base_gi, base_mi, base_me = flat[base_flats.pop()] + base_group = subproc_groups[base_gi] + result[(gi, mi)] = { + 'base_dir': 'P%d_%s' % (base_group.get('number'), + base_group.get('name')), + 'base_proc_id': base_mi + 1, + 'base_me': base_me, + 'flav_idx': [iflav for (_, iflav) in route], + } + return result + + def compute_ghremap(self, matrix_element, allow_reverse=True): + """Build the good-helicity remap table for the crossing filter. + + The good-helicity filter (GOODHEL) is shared by all crossings of a + flavor, but a crossing permutes and flips helicities, so identity and + crossed have different good-helicity SETS. The crossed set is the + identity set transformed by the crossing's own helicity-row permutation + sigma, where sigma sends identity row h to the row whose config is + (ic[k]*nhel[perm[k], h])_k -- permute the legs and flip the helicity of + the swapped ones, with (perm, ic) from get_crossing_permutation. A + crossed row H is therefore good iff the identity row sigma^-1(H) is + good, so the filter can stay shared as long as it is consulted (and + trained) through sigma^-1. See standalone-cross-symmetry memory. + + Returns a flat list of length NCROSS*NCOMB indexed CROSS*NCOMB + H (H + the 0-based helicity row), each entry being the 0-based identity row + sigma^-1(H) that gates crossed row H, or None when the crossing must + not be filtered (compute every helicity, never train): + - CROSS==0 -> the identity (entry == H): the uncrossed path is + completely unchanged; + - a genuine crossing whose active partners are all final particles -> + sigma^-1(H); + - an initial-initial swap, or an invalid / inapplicable crossing -> + None. The sigma relation only holds when the active partners are + final; an initial-initial swap breaks it (it overcounts at 2->3), + so those disable the filter and keep the full-computation result. + + allow_reverse must match the order the NHEL table is emitted in for the + backend consuming the result (True for the fortran get_helicity_lines, + False for the C++ get_helicity_matrix). + """ + # Reference the class explicitly (not self) so the C++ standalone + # exporter can reuse this via ProcessExporterFortran.compute_ghremap + # with a non-Fortran self, exactly like compute_crossing_tables. + tables = ProcessExporterFortran.compute_crossing_tables( + self, matrix_element) + spincol = tables['spincol'] + nexternal = tables['nexternal'] + ninitial = tables['ninitial'] + base = nexternal + 1 + ncross = base * base + hel_matrix = [tuple(row) for row in + matrix_element.get_helicity_matrix(allow_reverse)] + ncomb = len(hel_matrix) + row_index = {row: h for h, row in enumerate(hel_matrix)} + + remap = [] + for cross in range(ncross): + perm, ic, valid = \ + ProcessExporterFortran.get_crossing_permutation(cross, nexternal) + i_part, j_part = cross // base, cross % base + final_only = ((i_part in (0, 1) or i_part > ninitial) and + (j_part in (0, 2) or j_part > ninitial)) + derivable = (valid and spincol[cross] != 0 and + (cross == 0 or final_only)) + block = [None] * ncomb + if derivable: + for h in range(ncomb): + config = tuple(ic[k] * hel_matrix[h][perm[k]] + for k in range(nexternal)) + big_h = row_index.get(config) + if big_h is None: + # The permuted config is not a table row: the crossing + # is not a bijection on the rows, so it cannot be + # derived. Disable the filter for it (safe fallback). + block = [None] * ncomb + break + block[big_h] = h + remap.extend(block) + return remap + + def compute_ghfilt(self, matrix_element, allow_reverse=True): + """Per-crossing filterability flags for the runtime good-helicity remap. + + Returns a list of length NCROSS: 1 if crossing CROSS is filterable (its + helicity-row permutation sigma is a clean bijection -- see + compute_ghremap), 0 otherwise (initial-initial swap, inapplicable, or a + non-bijection). This is the small flag table that replaces the full + GHREMAP(NCROSS*NCOMB) row table: at runtime the row map itself is + recomputed by permuting+sign-flipping the config and re-encoding it (see + the CROSS_GHIDX routine), so only the per-crossing yes/no survives as + DATA. A whole compute_ghremap block is either fully derivable or fully + None, so this loses nothing.""" + # Reference the class explicitly (not self) so a non-Fortran self (the + # C++ standalone exporter) can reuse this via + # ProcessExporterFortran.compute_ghfilt, exactly like compute_ghremap. + remap = ProcessExporterFortran.compute_ghremap( + self, matrix_element, allow_reverse) + nexternal = matrix_element.get_nexternal_ninitial()[0] + ncross = (nexternal + 1) * (nexternal + 1) + ncomb = len(remap) // ncross + return [0 if all(x is None for x in remap[c * ncomb:(c + 1) * ncomb]) + else 1 for c in range(ncross)] + + @staticmethod + def format_integer_data_lines(name, values, per_line=10): + """Emit 'DATA (name(I),I=a,b) /.../' lines for a 0-based table.""" + lines = [] + for start in range(0, len(values), per_line): + chunk = values[start:start + per_line] + lines.append(' DATA (%s(I),I=%d,%d) /%s/' % + (name, start, start + len(chunk) - 1, + ','.join(str(value) for value in chunk))) + return '\n'.join(lines) + def get_icolamp_lines(self, mapconfigs, matrix_element, num_matrix_element): """Return the ICOLAMP matrix, showing which JAMPs contribute to which configs (diagrams).""" @@ -2123,20 +4501,21 @@ def get_icolamp_lines(self, mapconfigs, matrix_element, num_matrix_element): # There is a color basis - create a list showing which JAMPs have # contributions to which configs - # Only want to include leading color flows, so find max_Nc - color_basis = matrix_element.get('color_basis') - + # Only want to include leading color flows, so find max_Nc. This is + # about color flows, so always the trace basis + color_basis = matrix_element.get('color_basis').get_flow_basis() + # We don't want to include the power of Nc's which come from the potential # loop color trace (i.e. in the case of a closed fermion loop for example) # so we subtract it here when computing max_Nc - max_Nc = max(sum([[(v[4]-v[5]) for v in val] for val in + max_Nc = max(sum([[(v[4]-v[5]) for v in val] for val in color_basis.values()],[])) # Crate dictionary between diagram number and JAMP number diag_jamp = {} for ijamp, col_basis_elem in \ - enumerate(sorted(matrix_element.get('color_basis').keys())): - for diag_tuple in matrix_element.get('color_basis')[col_basis_elem]: + enumerate(sorted(color_basis.keys())): + for diag_tuple in color_basis[col_basis_elem]: # Only use color flows with Nc == max_Nc. However, notice that # we don't want to include the Nc power coming from the loop # in this counting. @@ -2304,7 +4683,8 @@ def get_JAMP_coefs(self, color_amplitudes, color_basis=None, tag_letter="",\ def get_JAMP_lines_split_order(self, col_amps, split_order_amps, - split_order_names=None, JAMP_format="JAMP(%s,{0})", AMP_format="AMP(%s)"): + split_order_names=None, JAMP_format="JAMP(%s,{0})", AMP_format="AMP(%s)", + orbit=False, proc_prefix=''): """Return the JAMP = sum(fermionfactor * AMP(i)) lines from col_amps defined as a matrix element or directly as a color_amplitudes dictionary. The split_order_amps specifies the group of amplitudes sharing the same @@ -2373,22 +4753,34 @@ def get_JAMP_lines_split_order(self, col_amps, split_order_amps, JAMP_format=JAMP_format.format(str(i+1)), JAMP_formatLC="LN"+JAMP_format.format(str(i+1)))[0]) else: + # Only one set of definitions fits in the arrays the + # template declares, so the orbit version is only used when + # there is a single order to compute. toadd, nb_tmp = self.get_JAMP_lines(col_amps_order, - JAMP_format=JAMP_format.format(str(i+1))) + JAMP_format=JAMP_format.format(str(i+1)), + orbit=orbit and len(split_order_amps) == 1, + proc_prefix=proc_prefix, + symmetry_source=col_amps if isinstance( + col_amps, + helas_objects.HelasMatrixElement) else None) res_list.extend(toadd) max_tmp = max(max_tmp, nb_tmp) return res_list, max_tmp - def get_JAMP_lines(self, col_amps, JAMP_format="JAMP(%s)", AMP_format="AMP(%s)", - split=-1): - """Return the JAMP = sum(fermionfactor * AMP(i)) lines from col_amps + def get_JAMP_lines(self, col_amps, JAMP_format="JAMP(%s)", AMP_format="AMP(%s)", + split=-1, orbit=False, proc_prefix='', + symmetry_source=None): + """Return the JAMP = sum(fermionfactor * AMP(i)) lines from col_amps defined as a matrix element or directly as a color_amplitudes dictionary, - Jamp_formatLC should be define to allow to add LeadingColor computation + Jamp_formatLC should be define to allow to add LeadingColor computation (usefull for MatchBox) The split argument defines how the JAMP lines should be split in order - not to be too long.""" + not to be too long. + With orbit on, the common sub-expressions are looked for in a way which + respects the permutations leaving the color basis invariant, so that + they can be written as one recipe per orbit (see optimise_jamp).""" # Let the user call get_JAMP_lines directly from a MatrixElement or from # the color amplitudes lists. @@ -2402,7 +4794,9 @@ def get_JAMP_lines(self, col_amps, JAMP_format="JAMP(%s)", AMP_format="AMP(%s)", else: raise MadGraph5Error("Incorrect col_amps argument passed to get_JAMP_lines") - all_element = {} + # the coefficient matrix the optimisation below works on, built once + # from the same color amplitudes the expanded lines are written from + all_element = self.jamp_matrix(color_amplitudes) res_list = [] for i, coeff_list in enumerate(color_amplitudes): # It might happen that coeff_list is empty if this function was @@ -2437,11 +4831,6 @@ def get_JAMP_lines(self, col_amps, JAMP_format="JAMP(%s)", AMP_format="AMP(%s)", for (coefficient, amp_number) in coefs: if not coefficient: continue - value = (1j if coefficient[2] else 1)* coefficient[0] * coefficient[1] * fractions.Fraction(3)**coefficient[3] - if (i+1, amp_number) not in all_element: - all_element[(i+1, amp_number)] = value - else: - all_element[(i+1, amp_number)] += value if common_factor: res = (res + "%s" + AMP_format) % \ (self.coeff(coefficient[0], @@ -2460,32 +4849,37 @@ def get_JAMP_lines(self, col_amps, JAMP_format="JAMP(%s)", AMP_format="AMP(%s)", res = res + ')' res_list.append(res) - if 'jamp_optim' in self.cmd_options: - jamp_optim = banner_mod.ConfigFile.format_variable(self.cmd_options['jamp_optim'], bool, 'jamp_optim') - else: - # class default - jamp_optim = self.jamp_optim - - if not jamp_optim: + if not self.jamp_optim_enabled(): return res_list, 0 else: saved = list(res_list) - + if len(all_element) > 1000: logger.info("Computing Color-Flow optimization [%s term]", len(all_element)) start_time = time.time() - else: + else: start_time = 0 - + res_list = [] - + self.myjamp_count = 0 - for key in all_element: - all_element[key] = complex(all_element[key]) - new_mat, defs = self.optimise_jamp(all_element) + # The optimisation itself is language neutral (see jamp_optimiser); it + # is run one step at a time here rather than through + # optimise_jamp_matrix because the color basis symmetry has to be read + # off the matrix once the phase has been taken out of it. + phase = self.jamp_walk_integers(all_element) + self.jamp_orbits = None + # the color basis is read from the matrix element, which is not always + # what is passed here: the split order version hands over one list of + # color amplitudes per order and says where they came from + symmetry = self.get_jamp_symmetry( + col_amps if symmetry_source is None else symmetry_source, + all_element) if orbit and self.jamp_orbit else None + new_mat, defs = self.optimise_jamp(all_element, symmetry=symmetry) + self.jamp_apply_phase(new_mat, phase) if start_time: logger.info("Color-Flow passed to %s term in %ss. Introduce %i contraction", len(new_mat), int(time.time()-start_time), len(defs)) - + #misc.sprint("number of iteration", self.myjamp_count) def format(frac): @@ -2504,30 +4898,104 @@ def format(frac): - for i, amp1, amp2, frac, nb in defs: - if amp1 > 0: - amp1 = AMP_format % amp1 + # One recipe per orbit rather than one line per definition, when the + # symmetry allows it and there are enough definitions for the routine + # rebuilding them to be worth its own code. + recipes = None + if symmetry and len(defs) >= self.jamp_orbit_min_def \ + and self.jamp_tables_allowed(): + nb_amp = (col_amps if symmetry_source is None + else symmetry_source).get_number_of_amplitudes() + if self.jamp_emit == 'tables': + recipes = self.jamp_orbit_tables(defs, nb_amp) else: - amp1 = "TMP_JAMP(%d)" % -amp1 - if amp2 > 0: - amp2 = AMP_format % amp2 + recipes = self.jamp_orbit_recipes(defs, nb_amp) + self.jamp_recipes = recipes + + if recipes: + buffer = self.jamp_buffer() + tmp_name = lambda k: "%s(NGRAPHS+%d)" % (buffer, k) + defs = recipes['defs'] + res_list.append("C The definitions below come in orbits of the") + res_list.append("C permutations leaving the color basis") + res_list.append("C invariant: all of an orbit are the same") + res_list.append("C recipe with the amplitudes permuted, so") + res_list.append("C only their operands differ. INIT_JAMP works") + res_list.append("C those out once, from one recipe per orbit.") + if recipes.get('recipes'): + res_list.append(" CALL %sINIT_JAMP()" % proc_prefix) + res_list.append(" DO ITMP = 1, NB_TMP_JAMP") + res_list.append(" AMP(NGRAPHS+ITMP) = AMP(TMP_JAMP_A(ITMP))" + " + TMP_JAMP_F(ITMP)*AMP(TMP_JAMP_B(ITMP))") + res_list.append(" ENDDO") else: - amp2 = "TMP_JAMP(%d)" % -amp2 - - if frac not in [1., -1]: - res_list.append(' TMP_JAMP(%d) = %s + (%s) * %s ! used %d times' % (i,amp1, format(frac), amp2, nb)) - elif frac == 1.: - res_list.append(' TMP_JAMP(%d) = %s + %s ! used %d times' % (i,amp1, amp2, nb)) - else: - res_list.append(' TMP_JAMP(%d) = %s - %s ! used %d times' % (i,amp1, amp2, nb)) + if self.jamp_gather: + res_list.append("C the amplitudes of this helicity are") + res_list.append("C read into one array first, so that") + res_list.append("C the definitions below take both") + res_list.append("C their operands from the same place") + res_list.append(" DO ITMP = 1, NGRAPHS") + res_list.append(" %s(ITMP) = AMP(ITMP)" % buffer) + res_list.append(" ENDDO") + res_list.append("C the definitions of one level use none") + res_list.append("C of each other, so they are sorted by") + res_list.append("C the factor in front of the second") + res_list.append("C operand and only the last group of") + res_list.append("C each level has to multiply") + res_list.append(" DO ILEV = 1, NB_LEVEL") + res_list.append(" DO ITMP = TMP_JAMP_L(5*ILEV-4)," + " TMP_JAMP_L(5*ILEV-3)") + res_list.append(" %s(NGRAPHS+ITMP) = %s(TMP_JAMP_A(ITMP))" + " + %s(TMP_JAMP_B(ITMP))" + % (buffer, buffer, buffer)) + res_list.append(" ENDDO") + res_list.append(" DO ITMP = TMP_JAMP_L(5*ILEV-3)+1," + " TMP_JAMP_L(5*ILEV-2)") + res_list.append(" %s(NGRAPHS+ITMP) = %s(TMP_JAMP_A(ITMP))" + " - %s(TMP_JAMP_B(ITMP))" + % (buffer, buffer, buffer)) + res_list.append(" ENDDO") + res_list.append(" DO ITMP = TMP_JAMP_L(5*ILEV-2)+1," + " TMP_JAMP_L(5*ILEV-1)") + res_list.append(" %s(NGRAPHS+ITMP) = %s(TMP_JAMP_A(ITMP))" + " + TMP_JAMP_F(TMP_JAMP_L(5*ILEV)+ITMP" + "-TMP_JAMP_L(5*ILEV-2))" + "*%s(TMP_JAMP_B(ITMP))" + % (buffer, buffer, buffer)) + res_list.append(" ENDDO") + res_list.append(" ENDDO") + else: + tmp_name = lambda k: "TMP_JAMP(%d)" % k + for i, amp1, amp2, frac, nb in defs: + if amp1 > 0: + amp1 = AMP_format % amp1 + else: + amp1 = tmp_name(-amp1) + if amp2 > 0: + amp2 = AMP_format % amp2 + else: + amp2 = tmp_name(-amp2) + + if frac not in [1., -1]: + res_list.append(' TMP_JAMP(%d) = %s + (%s) * %s ! used %d times' % (i,amp1, format(frac), amp2, nb)) + elif frac == 1.: + res_list.append(' TMP_JAMP(%d) = %s + %s ! used %d times' % (i,amp1, amp2, nb)) + else: + res_list.append(' TMP_JAMP(%d) = %s - %s ! used %d times' % (i,amp1, amp2, nb)) jamp_res = collections.defaultdict(list) max_jamp=0 for (jamp, var), factor in new_mat.items(): if var > 0: - name = AMP_format % var + name = ("%s(%%s)" % self.jamp_buffer()) % var \ + if (recipes and self.jamp_gather) else AMP_format % var else: - name = "TMP_JAMP(%d)" % -var + if recipes and recipes.get('factor_of'): + # the definitions were renumbered, and one of them can be + # the opposite of the one the optimisation had + where, scale = recipes['factor_of'][-var] + factor, var = factor * scale, -where + name = tmp_name(-var) if factor not in [1.]: jamp_res[jamp].append("(%s)*%s" % (format(factor), name)) elif factor ==1: @@ -2544,99 +5012,774 @@ def format(frac): return res_list, len(defs) - def optimise_jamp(self, all_element, nb_line=0, nb_col=0, added=0): - """ optimise problem of type Y = A X - A is a matrix (all_element) - X is the fortran name of the input. - The code iteratively add sub-expression jtemp[sub_add] - and recall itself (this is add to the X size) - """ - self.myjamp_count +=1 - - if not nb_line: - for i,j in all_element: - if i+1 > nb_line: - nb_line = i+1 - if j+1> nb_col: - nb_col = j+1 - if nb_col > 600 and added==0: - all_element1, all_element2 = {}, {} - for (k1,k2) in all_element: - if k2 >= nb_col//2: - all_element2[(k1,1+k2-(nb_col//2))] = all_element[(k1,k2)] - else: - all_element1[(k1,k2)] = all_element[(k1,k2)] - - all_element1, newdef1 = self.optimise_jamp(all_element1) - nb_added1 = len(newdef1) - - all_element2, newdef2 = self.optimise_jamp(all_element2) - - for (k1,k2) in all_element2: - if k2 >= 0: - all_element1[(k1,k2+(nb_col//2)-1)] = all_element2[(k1,k2)] - if k2 < 0: - all_element1[(k1,k2-nb_added1)] = all_element2[(k1,k2)] - # new_def format: added,j1,j2,R, max_count - for k, j1,j2, R, c in newdef2: - if j2 > 0: - k2 = j2+nb_col//2 -1 - else: - k2 = j2-nb_added1 - if j1 > 0: - k1 = j1+nb_col//2 -1 - else: - k1 = j1-nb_added1 - newdef1.append((k+nb_added1, k1, k2, R, c)) - if newdef1: - all_element, new_def = self.optimise_jamp(all_element1, nb_line=0, nb_col=0, added=len(newdef1)) - newdef1 = newdef1 + new_def - return all_element, newdef1 - - max_count = 0 - all_index = [] - operation = collections.defaultdict(lambda: collections.defaultdict(int)) - for (i,j1), v1 in all_element.items(): - ratios = [(j2,all_element.get((i,j2), 0)/v1) for j2 in range(j1+1, nb_col) if all_element.get((i,j2), 0)] - for j2, R in ratios: - operation[(j1,j2)][R] +=1 - if operation[(j1,j2)][R] > max_count: - max_count = operation[(j1,j2)][R] - all_index = [(j1,j2, R)] - elif operation[(j1,j2)][R] == max_count: - all_index.append((j1,j2, R)) - - if max_count <= 1: - return all_element, [] - - to_add = [] - for index in all_index: - j1,j2,R = index - first = True - for i in range(nb_line): - v1 = all_element.get((i,j1), 0) - v2 = all_element.get((i,j2), 0) - if not v1 or not v2: - continue - if v2/v1 == R: - if first: - first = False - added +=1 - to_add.append((added,j1,j2,R, max_count)) - - all_element[(i,-added)] = v1 - del all_element[(i,j1)] #= 0 - del all_element[(i,j2)] #= 0 - - logger.log(5,"Define %d new shortcut reused %d times", len(to_add), max_count) - new_element, new_def = self.optimise_jamp(all_element, nb_line=nb_line, nb_col=nb_col, added=added) - for one_def in to_add: - new_def.insert(0, one_def) - return new_element, new_def - - - - + _blas_available = None + + @classmethod + def blas_is_available(cls): + """Whether a BLAS carrying DSYMM can be linked, asked once.""" + + if cls._blas_available is None: + import subprocess, tempfile, shutil + probe = (" PROGRAM P\n" + " DOUBLE PRECISION A(1,1),B(1,1),C(1,1)\n" + " A=1D0\n B=1D0\n C=0D0\n" + " CALL DSYMM('L','U',1,1,1D0,A,1,B,1,0D0,C,1)\n" + " END\n") + work = tempfile.mkdtemp() + cls._blas_available = False + cls._blas_flags = '' + try: + src = os.path.join(work, 'p.f') + open(src, 'w').write(probe) + for flags in ('-framework Accelerate', '-lblas'): + try: + out = subprocess.call( + ['gfortran', src, '-o', os.path.join(work, 'p')] + + flags.split(), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + except OSError: + break + if out == 0: + cls._blas_available = True + cls._blas_flags = flags + break + finally: + shutil.rmtree(work, ignore_errors=True) + return cls._blas_available + + @classmethod + def blas_available_flags(cls): + """What a BLAS carrying DSYMM needs on the link line, empty when there + is none. Unlike blas_link_flags this does not ask whether BLAS was + wanted, only whether it is there, which is what a backend deciding for + itself (the C++ color sum) needs.""" + + if not cls.blas_is_available(): + return '' + return cls._blas_flags + + def blas_link_flags(self): + """What to link the color sum against, empty when BLAS is not taken.""" + + if self.blas is False or not self.blas_is_available(): + return '' + return self._blas_flags + + def blas_wanted(self, nfold): + """Take BLAS when asked for it, or when it is there and the color + matrix is big enough that the call is worth setting up.""" + + if self.blas is False: + return False + if not self.blas_is_available(): + return False + if self.blas is True: + return True + return nfold >= self.blas_min_ncolor + + # The two batched color sums below differ only in what they do with the + # DSYMM output, so everything up to and including the two calls is written + # once: the head (down to the last shared argument), then the caller's own + # result declaration, then the body. + BLAS_BATCH_HEAD = """ IMPLICIT NONE + INTEGER NFOLD, NCOMB + PARAMETER (NFOLD={n}) + PARAMETER (NCOMB={c}) + DOUBLE PRECISION JR(NFOLD,NCOMB), JI(NFOLD,NCOMB) + INTEGER NB +""" + + BLAS_BATCH_BODY = """ INTEGER I,J,K,CFI + DOUBLE PRECISION, ALLOCATABLE, SAVE :: CFULL(:,:) + DOUBLE PRECISION, ALLOCATABLE, SAVE :: TR(:,:), TI(:,:) + LOGICAL FIRST + DATA FIRST /.TRUE./ + SAVE FIRST + INTEGER {p}CF(NFOLD*(NFOLD+1)/2) + INTEGER {p}DENOM + common /{p}color_matrix/ {p}CF,{p}DENOM + IF (FIRST) THEN + CALL {p}INIT_CF() + ALLOCATE(CFULL(NFOLD,NFOLD)) + ALLOCATE(TR(NFOLD,NCOMB)) + ALLOCATE(TI(NFOLD,NCOMB)) +C What is written out is the upper triangle with its off diagonal +C doubled, since the scalar sum walks it once. BLAS wants the whole +C matrix with each entry counted once. + CFI = 0 + DO I = 1, NFOLD + DO J = I, NFOLD + CFI = CFI + 1 + IF (I.EQ.J) THEN + CFULL(I,J) = DBLE({p}CF(CFI)) + ELSE + CFULL(I,J) = DBLE({p}CF(CFI))/2D0 + CFULL(J,I) = CFULL(I,J) + ENDIF + ENDDO + ENDDO + FIRST = .FALSE. + ENDIF + CALL DSYMM('L','U',NFOLD,NB,1D0,CFULL,NFOLD,JR,NFOLD,0D0,TR,NFOLD) + CALL DSYMM('L','U',NFOLD,NB,1D0,CFULL,NFOLD,JI,NFOLD,0D0,TI,NFOLD) +""" + + @classmethod + def get_blas_routine(cls, prefix, nfold, ncomb): + """The color sum for every helicity at once. DSYMM is real, so the + two parts of JAMP go through separately; the color matrix is real and + symmetric so that is all it takes.""" + + return ("\n SUBROUTINE {p}GET_MATRIX_BATCH(JR,JI,NB,ANS)\n" + + cls.BLAS_BATCH_HEAD + + " DOUBLE PRECISION ANS\n" + + cls.BLAS_BATCH_BODY + """ ANS = 0D0 + DO K = 1, NB + DO I = 1, NFOLD + ANS = ANS + TR(I,K)*JR(I,K) + TI(I,K)*JI(I,K) + ENDDO + ENDDO + ANS = ANS / DBLE({p}DENOM) + END +""").format(p=prefix, n=nfold, c=ncomb) + + @classmethod + def get_blas_vector_routine(cls, prefix, nfold, ncomb): + """The same batch, keeping one |M|^2 per column instead of adding them + up. The helicity-recycled MATRIX needs a value per helicity row (the + polarization filter and SMATRIXHEL select on it), so it cannot use the + scalar GET_MATRIX_BATCH.""" + + return ("\n SUBROUTINE {p}GET_MATRIX_BATCHV(JR,JI,NB,TSB)\n" + + cls.BLAS_BATCH_HEAD + + " DOUBLE PRECISION TSB(NB)\n" + + " DOUBLE PRECISION T\n" + + cls.BLAS_BATCH_BODY + """ DO K = 1, NB + T = 0D0 + DO I = 1, NFOLD + T = T + TR(I,K)*JR(I,K) + TI(I,K)*JI(I,K) + ENDDO + TSB(K) = T / DBLE({p}DENOM) + ENDDO + END +""").format(p=prefix, n=nfold, c=ncomb) + + def get_color_fold_ampso(self, folding, ncolor): + """Template replacements for a color sum over one line per reversal + pair, where JAMP carries a second index for the split orders. Without a + folding the sum is left on JAMP itself, so nothing is copied.""" + + if not folding: + return {'ncolorfold': ncolor, + 'color_fold_decl': '', + 'color_fold_index': '', + 'color_fold_gather': '', + 'color_fold_array': 'JAMP'} + lines = [line + 1 for line in folding['representatives']] + return { + 'ncolorfold': len(lines), + 'color_fold_decl': ( + " COMPLEX*16 JFOLD(NCOLORFOLD,NAMPSO)\n" + " INTEGER COLREP(NCOLORFOLD)\n" + " INTEGER ICF"), + 'color_fold_index': "\n".join( + self.get_int_data_lines("COLREP", lines, var='ICF')), + 'color_fold_gather': " JFOLD(:,:) = JAMP(COLREP(:),:)", + 'color_fold_array': 'JFOLD'} + + def jamp_orbit_recipes(self, defs, nb_amp): + """Describe the definitions by one recipe per orbit: the amplitude + permutations, the first definition of every orbit, and the definitions + renumbered so that walking each orbit breadth first from its recipe, + with those permutations in that order, hands them out in that very + order. The generated code walks them the same way, so it only needs + the recipes. + + Returns None when the definitions cannot be described this way, and + the caller then writes them out one by one as before.""" + + orbits = self.jamp_orbits + if not orbits or not defs: + return None + actions = orbits['actions'] + nb_orbit = orbits['nb_orbit'] + + # Every factor has to be a power of i. They then form a group of four + # elements, so the walk can carry them as an exponent modulo four and + # stays integer arithmetic whatever the process. + for one_def in defs: + if self.jamp_i_power(one_def[3]) is None: + return None + for action in actions: + for column, (_image, factor) in action.items(): + if column < 0 and self.jamp_i_power(factor) is None: + return None + + # first definition of every orbit + first = [0] * nb_orbit + for (orbit, parent, _permutation), one_def in zip(orbits['tree'], defs): + if not parent: + first[orbit] = one_def[0] + + # the permutations which are really needed to reach every definition + # of every orbit: each of them costs one table of amplitude indices + chosen = [] + rest = list(range(len(actions))) + while self.jamp_orbit_reach(actions, chosen, first) < len(defs): + best, best_gain = None, -1 + for position in rest: + gain = self.jamp_orbit_reach(actions, chosen + [position], + first) + if gain > best_gain: + best, best_gain = position, gain + if best is None: + return None + chosen.append(best) + rest.remove(best) + + replay = self.jamp_orbit_replay(defs, first, chosen) + while replay is None and rest: + # the permutations kept do not reach every definition after all + chosen.append(rest.pop(0)) + replay = self.jamp_orbit_replay(defs, first, chosen) + if replay is None: + return None + new_defs, recipes, factor_of = replay + + permutations = [] + for permutation in chosen: + action = orbits['symmetry']['actions'][permutation] + row = [0] * nb_amp + for amp, (image, sign) in action.items(): + row[amp - 1] = image if sign > 0 else -image + if any(value == 0 for value in row): + return None + permutations.append(row) + + # an odd power of i anywhere means the factor in front of the second + # operand is not real, and the array holding it has to be complex + complex_factor = any(recipe[2] % 2 for recipe in recipes) or \ + any(self.jamp_i_power(one[3]) % 2 for one in new_defs) + return {'permutations': permutations, 'recipes': recipes, + 'defs': new_defs, 'nb_amp': nb_amp, 'factor_of': factor_of, + 'complex_factor': complex_factor} + + @staticmethod + def jamp_hash_size(nb_def): + """A prime comfortably larger than twice the number of definitions: + the routine which rebuilds them looks the operand pairs up in a table + of that size with linear probing.""" + + candidate = 2 * nb_def + 101 + while True: + candidate += 1 + for divisor in range(2, int(candidate ** 0.5) + 1): + if candidate % divisor == 0: + break + else: + return candidate + + def jamp_orbit_tables(self, defs, nb_amp): + """Describe the definitions by the plain list of their operands, to be + written out as DATA. This is the same loop as the recipes drive, only + with the table read from the source instead of rebuilt, so no factor is + out of reach. + + The definitions introduced together carry no dependency, so inside each + of those groups they are sorted by the factor in front of the second + operand: the ones adding it, then the ones subtracting it, then the + rest. The loop then runs over each group with the factor built in and + only the last one has to multiply.""" + + if not defs: + return None + by_index = dict((one[0], one) for one in defs) + levels = self.jamp_definition_levels(defs) + order, bounds, nb_general = [], [], 0 + for level in levels: + group = [[], [], []] + for index in level: + ratio = complex(by_index[index][3]) + group[0 if ratio == 1 else 1 if ratio == -1 else 2]\ + .append(index) + start = len(order) + order += group[0] + group[1] + group[2] + # first, last of the adding group, last of the subtracting group, + # last of the rest, and where the factors of that rest start + bounds.append((start + 1, start + len(group[0]), + start + len(group[0]) + len(group[1]), len(order), + nb_general)) + nb_general += len(group[2]) + renumber = dict((old, new + 1) for new, old in enumerate(order)) + + new_defs = [] + for old in order: + _k, left, right, ratio, count = by_index[old] + left = -renumber[-left] if left < 0 else left + right = -renumber[-right] if right < 0 else right + new_defs.append((renumber[old], left, right, ratio, count)) + + # only the definitions of the third group ever read the factor array + general = [one for level in bounds + for one in range(level[2] + 1, level[3] + 1)] + return {'defs': new_defs, 'nb_amp': nb_amp, 'recipes': [], + 'bounds': bounds, 'general': general, + 'factor_of': dict((old, (new, 1)) + for old, new in renumber.items()), + 'complex_factor': any(complex(new_defs[one - 1][3]).imag + for one in general)} + + @staticmethod + def jamp_number_data_lines(name, values, per_line, var='IJMP'): + """DATA statements filling one array with the given constants.""" + + lines = [] + for start in range(0, len(values), per_line): + chunk = values[start:start + per_line] + lines.append(" DATA (%s(%s),%s=%d,%d) /%s/" % + (name, var, var, start + 1, start + len(chunk), + ','.join(chunk))) + return lines + + def get_jamp_table_lines(self, recipes, proc_prefix): + """Declarations and DATA for the operand tables.""" + + nb_def = len(recipes['defs']) + nb_amp = recipes['nb_amp'] + + def where(column): + return column if column > 0 else nb_amp - column + + left = [where(one[1]) for one in recipes['defs']] + right = [where(one[2]) for one in recipes['defs']] + general = recipes['general'] + if recipes['complex_factor']: + factor = ['(%s,%s)' % + (self.jamp_number(complex(recipes['defs'][one-1][3]).real), + self.jamp_number(complex(recipes['defs'][one-1][3]).imag)) + for one in general] + else: + factor = [self.jamp_number(complex(recipes['defs'][one-1][3]).real) + for one in general] + + bounds = recipes['bounds'] + lines = [ + " INTEGER ITMP, ILEV", + "C IJMP is the loop variable of the DATA statements below", + " INTEGER IJMP", + " INTEGER NB_TMP_JAMP, NB_LEVEL, NB_GENERAL", + " PARAMETER (NB_TMP_JAMP=%d)" % nb_def, + " PARAMETER (NB_LEVEL=%d)" % len(bounds), + " PARAMETER (NB_GENERAL=%d)" % max(1, len(general)), + " INTEGER TMP_JAMP_A(NB_TMP_JAMP), TMP_JAMP_B(NB_TMP_JAMP)", + " INTEGER TMP_JAMP_L(5*NB_LEVEL)", + " %s TMP_JAMP_F(NB_GENERAL)" % self.jamp_factor_type(recipes), + ] + if self.jamp_gather: + lines.append(" COMPLEX*16 AMPBUF(%d+NB_TMP_JAMP)" % nb_amp) + lines += self.get_int_data_lines("TMP_JAMP_A", left, + var="IJMP") + lines += self.get_int_data_lines("TMP_JAMP_B", right, + var="IJMP") + lines += self.get_int_data_lines("TMP_JAMP_L", + sum((list(b) for b in bounds), []), + var="IJMP") + assert len(bounds[0]) == 5 + if general: + lines += self.jamp_number_data_lines("TMP_JAMP_F", factor, + 32 if recipes['complex_factor'] + else 64) + else: + lines.append(" DATA TMP_JAMP_F/%s/" % + ("(0D0,0D0)" if recipes['complex_factor'] else "0D0")) + return lines + + @staticmethod + def jamp_number(value): + """Shortest exact way of writing one of the factors.""" + + if value == int(value) and abs(value) < 1e15: + return "%dD0" % int(value) + return ("%.15e" % value).replace('e', 'd') + + def jamp_buffer(self): + """Array the definitions and their operands are read from.""" + + return 'AMPBUF' if self.jamp_gather else 'AMP' + + @staticmethod + def jamp_power_data(recipes): + """DATA statement for the four powers of i, real when none of them is + actually needed.""" + + if recipes['complex_factor']: + return " DATA IPOW/(1D0,0D0),(0D0,1D0),(-1D0,0D0)," \ + "(0D0,-1D0)/" + return " DATA IPOW/1D0,0D0,-1D0,0D0/" + + @staticmethod + def jamp_factor_type(recipes): + """The factor in front of the second operand is a power of i, so it is + only complex when one of those powers is odd.""" + + return "COMPLEX*16" if recipes['complex_factor'] \ + else "DOUBLE PRECISION" + + def get_jamp_decl_lines(self, recipes, proc_prefix): + """The declarations GET_JAMP needs to run the definitions.""" + + if not recipes: + return [] + if not recipes.get('recipes'): + return self.get_jamp_table_lines(recipes, proc_prefix) + return [ + " INTEGER ITMP", + " INTEGER NB_TMP_JAMP", + " PARAMETER (NB_TMP_JAMP=%d)" % len(recipes['defs']), + " INTEGER TMP_JAMP_A(NB_TMP_JAMP), TMP_JAMP_B(NB_TMP_JAMP)", + " %s TMP_JAMP_F(NB_TMP_JAMP)" % self.jamp_factor_type(recipes), + " COMMON /%sjamp_recipe/ TMP_JAMP_A,TMP_JAMP_B,TMP_JAMP_F" % \ + proc_prefix, + ] + + def get_jamp_init_routine(self, recipes, proc_prefix): + """Fortran source rebuilding the operands of every color flow + definition from one recipe per orbit, or nothing when the definitions + are written out.""" + + if not recipes or not recipes.get('recipes'): + return [] + nb_def = len(recipes['defs']) + nb_amp = recipes['nb_amp'] + nb_orbit = len(recipes['recipes']) + nb_perm = len(recipes['permutations']) + nb_hash = self.jamp_hash_size(nb_def) + + common = [ + " INTEGER NGRAPHS, NB_TMP_JAMP, NB_HASH", + " PARAMETER (NGRAPHS=%d)" % nb_amp, + " PARAMETER (NB_TMP_JAMP=%d)" % nb_def, + " PARAMETER (NB_HASH=%d)" % nb_hash, + " INTEGER TMP_JAMP_A(NB_TMP_JAMP), TMP_JAMP_B(NB_TMP_JAMP)", + " %s TMP_JAMP_F(NB_TMP_JAMP)" % self.jamp_factor_type(recipes), + " COMMON /%sjamp_recipe/ TMP_JAMP_A,TMP_JAMP_B,TMP_JAMP_F" % \ + proc_prefix, + " INTEGER NUSED", + " INTEGER TMP_JAMP_E(NB_TMP_JAMP)", + " INTEGER HVAL(NB_HASH)", + " INTEGER*8 HKEY(NB_HASH)", + " COMMON /%sjamp_build/ NUSED,TMP_JAMP_E,HVAL,HKEY" % \ + proc_prefix, + ] + + add = [ + " SUBROUTINE %sJAMP_ADD(A,B,F,M,SWAP)" % proc_prefix, + "C The definition A + i**F*B, added if it is not there yet.", + "C Its two operands the other way round, with the inverse", + "C factor, give the very same column times i**F, so that one", + "C is looked for too; SWAP is the exponent relating what was", + "C asked for to what was found.", + " IMPLICIT NONE", + " INTEGER A,B,F,M,SWAP", + ] + common + [ + " INTEGER H, FREE, SHIFT", + " INTEGER*8 KEY, OTHER, BASE", + " SHIFT = NB_TMP_JAMP + 1", + " BASE = NGRAPHS + NB_TMP_JAMP + 2", + " KEY = ((A+SHIFT)*BASE+(B+SHIFT))*4+F+1", + " OTHER = ((B+SHIFT)*BASE+(A+SHIFT))*4+MOD(4-F,4)+1", + " SWAP = 0", + " H = INT(MOD(KEY,INT(NB_HASH,8)))+1", + " DO WHILE (HKEY(H) .NE. 0)", + " IF (HKEY(H) .EQ. KEY) THEN", + " M = HVAL(H)", + " RETURN", + " ENDIF", + " H = H+1", + " IF (H .GT. NB_HASH) H = 1", + " ENDDO", + " FREE = H", + " H = INT(MOD(OTHER,INT(NB_HASH,8)))+1", + " DO WHILE (HKEY(H) .NE. 0)", + " IF (HKEY(H) .EQ. OTHER) THEN", + " M = HVAL(H)", + " SWAP = F", + " RETURN", + " ENDIF", + " H = H+1", + " IF (H .GT. NB_HASH) H = 1", + " ENDDO", + " NUSED = NUSED+1", + " M = NUSED", + " TMP_JAMP_A(M) = A", + " TMP_JAMP_B(M) = B", + " TMP_JAMP_E(M) = F", + " HKEY(FREE) = KEY", + " HVAL(FREE) = M", + " END", + "", + ] + + body = [ + " SUBROUTINE %sINIT_JAMP()" % proc_prefix, + "C Work out the operands of every color flow definition,", + "C starting from one recipe per orbit of the permutations", + "C leaving the color basis invariant and walking each orbit", + "C with those permutations. Done once, on the first call.", + " IMPLICIT NONE", + " INTEGER NB_ORBIT, NB_PERM", + " PARAMETER (NB_ORBIT=%d)" % nb_orbit, + " PARAMETER (NB_PERM=%d)" % nb_perm, + ] + common + [ + " INTEGER JPERM(NGRAPHS*NB_PERM)", + " INTEGER JREC(3*NB_ORBIT)", + " INTEGER JIMG(NB_TMP_JAMP*NB_PERM)", + " INTEGER JIMGE(NB_TMP_JAMP*NB_PERM)", + " INTEGER I,J,P,A,B,T,EA,EB,M,SWAP,BEGIN", + " %s IPOW(0:3)" % self.jamp_factor_type(recipes), + self.jamp_power_data(recipes), + " LOGICAL JAMP_DONE", + " DATA JAMP_DONE/.FALSE./", + " SAVE JAMP_DONE, JIMG, JIMGE", + ] + body += self.get_int_data_lines("JPERM", + sum(recipes['permutations'], [])) + body += self.get_int_data_lines("JREC", + sum((list(one) + for one in recipes['recipes']), + [])) + body += [ + " IF (JAMP_DONE) RETURN", + " JAMP_DONE = .TRUE.", + " DO I = 1, NB_HASH", + " HKEY(I) = 0", + " ENDDO", + " NUSED = 0", + " DO I = 1, NB_ORBIT", + " BEGIN = NUSED", + " CALL %sJAMP_ADD(JREC(3*I-2),JREC(3*I-1),JREC(3*I),M,SWAP)" + % proc_prefix, + " J = BEGIN", + " DO WHILE (J .LT. NUSED)", + " J = J+1", + " DO P = 1, NB_PERM", + "C an amplitude is permuted with a sign, which is the", + "C exponent 0 or 2; a definition brings its own exponent", + " A = TMP_JAMP_A(J)", + " IF (A .GT. 0) THEN", + " T = JPERM((P-1)*NGRAPHS+A)", + " A = ABS(T)", + " EA = (1-ISIGN(1,T))", + " ELSE", + " A = -JIMG((-A-1)*NB_PERM+P)", + " EA = JIMGE((-TMP_JAMP_A(J)-1)*NB_PERM+P)", + " ENDIF", + " B = TMP_JAMP_B(J)", + " IF (B .GT. 0) THEN", + " T = JPERM((P-1)*NGRAPHS+B)", + " B = ABS(T)", + " EB = (1-ISIGN(1,T))", + " ELSE", + " B = -JIMG((-B-1)*NB_PERM+P)", + " EB = JIMGE((-TMP_JAMP_B(J)-1)*NB_PERM+P)", + " ENDIF", + "C the image is A' + i**(e+eb-ea)*B', and the column it", + "C defines is i**ea times the image of this one", + " T = MOD(TMP_JAMP_E(J)+EB-EA+8,4)", + " CALL %sJAMP_ADD(A,B,T,M,SWAP)" % proc_prefix, + " JIMG((J-1)*NB_PERM+P) = M", + " JIMGE((J-1)*NB_PERM+P) = MOD(EA+SWAP,4)", + " ENDDO", + " ENDDO", + " ENDDO", + " IF (NUSED .NE. NB_TMP_JAMP) THEN", + " WRITE(*,*) 'ERROR: color flow recipes gave',NUSED,", + " $ ' definitions instead of',NB_TMP_JAMP", + " STOP 1", + " ENDIF", + "C the operands are read from one array holding the", + "C amplitudes first and the definitions after them", + " DO I = 1, NB_TMP_JAMP", + " IF (TMP_JAMP_A(I) .LT. 0) TMP_JAMP_A(I) = NGRAPHS" + "-TMP_JAMP_A(I)", + " IF (TMP_JAMP_B(I) .LT. 0) TMP_JAMP_B(I) = NGRAPHS" + "-TMP_JAMP_B(I)", + " TMP_JAMP_F(I) = IPOW(TMP_JAMP_E(I))", + " ENDDO", + " END", + ] + return add + body + + def jamp_tables_allowed(self): + """Whether the definitions may be read from a table rather than written + out. Both the standalone and the madevent templates declare what that + needs; madevent reads the amplitudes of the current helicity into a + buffer first, see jamp_gather.""" + + return True + + def jamp_greedy_tail_enabled(self): + """The tail of the plain scan is only within reach of the emission + which writes the operands out: the definitions it adds are not orbits + of anything, so INIT_JAMP could not rebuild them from the recipes.""" + + return self.jamp_greedy_tail and self.jamp_emit == 'tables' + + def jamp_orbit_allowed(self, matrix_element): + """Whether the orbit equivariant optimisation is used here: only the + templates which know how to write the definitions it produces.""" + + if not super().jamp_orbit_allowed(matrix_element): + return False + + if isinstance(self, ProcessExporterFortranME): + return self.matrix_file in ('matrix_madevent_v4.inc', + 'matrix_madevent_group_v4.inc') + + # matchbox and the loop exporters derive from the standalone one but + # write their own templates + if type(self) is not ProcessExporterFortranSA: + return False + if self.matrix_template != 'matrix_standalone_v4.inc': + return False + if self.opt.get('export_format') in ('standalone_msP', + 'standalone_msF', 'matchbox', + 'madloop_matchbox'): + return False + return not matrix_element.get('processes')[0].get('split_orders') + + def jamp_orbit_replay(self, defs, first, chosen): + """Walk every orbit from its first definition with the given + permutations, exactly as the generated routine does, and hand out the + definition numbers in that order. Returns the definitions in the new + numbering, the recipe of every orbit, and for each old definition the + new one it became with the factor between the two. None if the walk + does not reach every definition.""" + + amp_action = [self.jamp_orbits['symmetry']['actions'][position] + for position in chosen] + nb_perm = len(chosen) + by_index = dict((one_def[0], one_def) for one_def in defs) + # old definition -> (new definition, factor between the two columns) + factor_of = {} + left_of, right_of, ratio_of = [], [], [] + image_of, power_of = [], [] + known = {} + recipes = [] + + def store(left, right, ratio): + """The definition with those operands, added if it is new. The two + operands can also be the other way round, and the column is then + the same one up to the ratio, hence the second look up.""" + + found = known.get((left, right, ratio)) + if found is not None: + return found, 1 + # the two operands the other way round with the inverse ratio give + # the same column times the ratio + found = known.get((right, left, 1 / ratio)) + if found is not None: + return found, ratio + left_of.append(left) + right_of.append(right) + ratio_of.append(ratio) + image_of.extend([0] * nb_perm) + power_of.extend([0] * nb_perm) + known[(left, right, ratio)] = len(left_of) + return len(left_of), 1 + + def act(place, column): + """image of a column and the factor that goes with it""" + + if column > 0: + return amp_action[place][column] + where = (-column - 1) * nb_perm + place + return (-image_of[where], + (1, 1j, -1, -1j)[power_of[where]]) + + # the same walk is followed on the definitions of the optimisation, so + # that each of them is matched with the one the generated code builds + actions = self.jamp_orbits['actions'] + origin_of = [] + + for start in first: + _k, left, right, ratio = by_index[start][:4] + scale = 1 + if left < 0: + if -left not in factor_of: + return None + new, factor = factor_of[-left] + left, scale = -new, factor + if right < 0: + if -right not in factor_of: + return None + new, factor = factor_of[-right] + right, ratio = -new, ratio * factor + ratio = ratio / scale + if self.jamp_i_power(ratio) is None: + return None + begin = len(left_of) + new, factor = store(left, right, ratio) + if new != begin + 1: + # the first definition of an orbit has to be a new one + return None + origin_of.append(start) + factor_of[start] = (new, scale * factor) + recipes.append((left, right, self.jamp_i_power(ratio))) + + current = begin + while current < len(left_of): + current += 1 + previous = origin_of[current - 1] + for place in range(nb_perm): + image_left, sign_left = act(place, left_of[current - 1]) + image_right, sign_right = act(place, right_of[current - 1]) + image_ratio = ratio_of[current - 1] * sign_right / sign_left + if self.jamp_i_power(image_ratio) is None: + return None + where, swap = store(image_left, image_right, image_ratio) + sign = sign_left * swap + image_of[(current - 1) * nb_perm + place] = where + power_of[(current - 1) * nb_perm + place] = \ + self.jamp_i_power(sign) + if where > len(origin_of): + origin_of.append(None) + # follow the same step on the definitions of the + # optimisation to know which one this is + image, factor = actions[chosen[place]][-previous] + image = -image + if origin_of[where - 1] is None: + origin_of[where - 1] = image + if image not in factor_of: + factor_of[image] = (where, + factor_of[previous][1] * sign + / factor) + + if len(factor_of) != len(defs): + return None + + new_defs = [(i + 1, left_of[i], right_of[i], ratio_of[i], 0) + for i in range(len(left_of))] + return new_defs, recipes, factor_of + + @staticmethod + def jamp_orbit_reach(actions, chosen, first): + """How many definitions the given permutations reach from the first + definition of every orbit.""" + + seen = set(first) + queue = collections.deque(first) + while queue: + current = queue.popleft() + for permutation in chosen: + image = -actions[permutation][-current][0] + if image not in seen: + seen.add(image) + queue.append(image) + return len(seen) + + def get_pdf_lines(self, matrix_element, ninitial, subproc_group = False, vector=False): """Generate the PDF lines for the auto_dsig.f file""" @@ -3350,12 +6493,30 @@ class ProcessExporterFortranSA(ProcessExporterFortran): f2py_wrapper_all ="f2py_wrapper_all.inc" f2py_matrix_splitter = "f2py_splitter.py" jamp_optim = True + jamp_fold = True + jamp_orbit = True + # The only exporter implementing the extended FLAV_IDX decoding. The + # per-matrix-element cases it still cannot cross (msP/msF, matchbox, + # split orders) are handled by the use_crossing_ic gate in + # write_matrix_element_v4, which falls back to the uncrossed code. + supports_crossing = True default_vector_size = 0 + # standalone only squares the amplitude, so it can use the DDM basis. It + # still carries the Kleiss-Kuijf reconstruction of the trace JAMPs, because + # this is the mode used to time the matrix element and madevent pays it. + support_ddm_color_basis = True + ddm_needs_flow_basis = True # When True, emit per-call IAND(WF_FLAVOR_MASK/AMP_FLAVOR_MASK, # CURRENT_FLAV_BIT) guards in MATRIX so that wavefunctions and amplitudes # which contribute zero for the current input flavor are skipped at # runtime. Set to False to revert to the unconditional emission. use_flavor_mask = True + # When True, write the per-subprocess f2py wrapper (and the makefile rules + # building matrix2py from it). It is written against the entry points of + # the default matrix template, so an exporter whose template carries a + # different API has to turn it off rather than ship a file that cannot be + # compiled. + write_f2py_interface = True def __init__(self, *args,**opts): """add the format information compare to standard init""" @@ -3367,8 +6528,92 @@ def __init__(self, *args,**opts): self.format = 'standalone' self.prefix_info = {} + # proc_prefix -> (list of recorded CROSS codes, complete flag), filled + # per subprocess directory and written out by write_f2py_splitter; see + # recorded_crossing_codes. + self.crossing_records = {} ProcessExporterFortran.__init__(self, *args, **opts) + def get_proc_prefix(self, matrix_element, default=''): + """The prefix the entry points of this matrix element carry. + + The drivers written next to matrix.f (check_sa.f, + check_sa_born_splitOrders.f) call those entry points by name, so they + have to be given the very prefix write_matrix_element_v4 used -- + which is *not* always the one the caller passed in (matchbox derives + its own). Both go through here so they cannot drift apart. + """ + return default + + def get_matrix_template(self, matrix_element): + """The template write_matrix_element_v4 writes this matrix element from. + + Also asked by the drivers, which have to know which entry points the + file they link against actually contains: only the default template + carries the full standalone API, and the msP/msF, split-orders and + matchbox variants each carry a subset (see matrix_template_provides). + --hel_recycling is deliberately not special-cased: it rewrites + SMATRIX/MATRIX but appends every other routine verbatim from + self.matrix_template, so the answer is the same. + """ + if self.opt['export_format'] == 'standalone_msP': + return 'matrix_standalone_msP_v4.inc' + if self.opt['export_format'] == 'standalone_msF': + return 'matrix_standalone_msF_v4.inc' + if matrix_element.get('processes')[0].get('split_orders'): + if self.opt['export_format'] in ('madloop_matchbox', 'matchbox'): + return 'matrix_standalone_matchbox_splitOrders_v4.inc' + return 'matrix_standalone_splitOrders_v4.inc' + return self.matrix_template + + def matrix_template_provides(self, matrix_element, marker): + """True when the matrix element file carries the routine named by + *marker*, i.e. when the template it is written from mentions it. + + Nothing but the linker knows this for sure, and it only says so once + the driver is already broken -- and it never gets the chance, because + this exporter's `make` never compiles what it writes. Reading the + template is the next best thing, and it is the same string the writer + substitutes into. + """ + template = self.get_matrix_template(matrix_element) + if template not in self._matrix_template_cache: + self._matrix_template_cache[template] = open( + pjoin(_file_path, 'iolibs', 'template_files', template)).read() + return marker in self._matrix_template_cache[template] + + # template name -> text, so the lookup above costs one read per output + _matrix_template_cache = {} + + def get_jamp_folding(self, matrix_element): + """Only fold the color sum for a template that knows it is folded. + + get_color_data_lines writes the folded color matrix -- one row per + reversal pair, off-diagonal entries doubled -- for whatever template is + in use, but reading it back needs the JFOLD gather that only the + default standalone template has. Everything else here sums + CF(1..NCOLOR*(NCOLOR+1)/2) straight, so a folded matrix silently loses + the pairs it merged (matchbox g g > g g came out 47.68 instead of + 55.18) or, when the declaration is not oversized, runs off the end of + CF (the split-orders template with `set color_basis trace` returned + Infinity). + + This covers the exporters whose matrix element get_matrix_template + describes: standalone, matchbox and the MadLoop born_matrix.f (written + through write_bornmatrix, which is write_matrix_element_v4). It says + nothing about the files the FKS exporter fills itself -- born.f, + born_hel.f, matrix_.f, born_cnt_.f -- which come from templates + of their own with no gather; those ask for the matrix written out in + full instead, see get_fks_color_data_lines. madevent is a separate + class and keeps the mother's method: its templates do read a folded + matrix. + """ + if not self.matrix_template_provides(matrix_element, + '%(color_fold_gather)s'): + return None + return super(ProcessExporterFortranSA, self).get_jamp_folding( + matrix_element) + def copy_template(self, model): """Additional actions needed for setup of Template """ @@ -3411,14 +6656,17 @@ def copy_template(self, model): text = fsock.read() fsock.close() fsock = open(pjoin(self.dir_path, 'SubProcesses', 'makefileP'),'w') + text = text.replace('BLASLIBS =', 'BLASLIBS = %s' % self.blas_link_flags()) text = text.replace('LINKLIBS = -L../../lib/', 'LINKLIBS = -L../../lib/ -lrunning') text = text.replace('LIBS =', 'LIBS = $(LIBDIR)/librunning.$(libext)') fsock.write(text) fsock.close() else: # Add file in SubProcesses - shutil.copy(pjoin(self.mgme_dir, 'madgraph', 'iolibs', 'template_files', 'makefile_sa_f_sp'), - pjoin(self.dir_path, 'SubProcesses', 'makefileP')) + mk = open(pjoin(self.mgme_dir, 'madgraph', 'iolibs', + 'template_files', 'makefile_sa_f_sp')).read() + mk = mk.replace('BLASLIBS =', 'BLASLIBS = %s' % self.blas_link_flags()) + open(pjoin(self.dir_path, 'SubProcesses', 'makefileP'), 'w').write(mk) @@ -3524,12 +6772,17 @@ def finalize(self, matrix_elements, history, mg5options, flaglist, second_export self.compiler_choice(compiler) self.make() + # Standalone helicity recycling: now that libdhelas/libmodel are built, + # run the good-helicity / zero-amplitude warm-up probes and re-optimize + # each matrix.f (no-op unless --hel_recycling was requested). + self._run_hel_recycling_warmups(compiler.get('fortran')) + # Write command history as proc_card_mg5 if history and os.path.isdir(pjoin(self.dir_path, 'Cards')): output_file = pjoin(self.dir_path, 'Cards', 'proc_card_mg5.dat') history.write(output_file) - - ProcessExporterFortran.finalize(self, matrix_elements, + + ProcessExporterFortran.finalize(self, matrix_elements, history, mg5options, flaglist) open(pjoin(self.dir_path,'__init__.py'),'w') open(pjoin(self.dir_path,'SubProcesses','__init__.py'),'w') @@ -3542,14 +6795,18 @@ def finalize(self, matrix_elements, history, mg5options, flaglist, second_export pjoin(self.dir_path, 'Source', 'PDF')) self.write_pdf_opendata() - if self.prefix_info: + if not self.write_f2py_interface: + # no f2py_matrix_wrapper.f was written, so there is nothing for the + # matrix2py rules below to build + pass + elif self.prefix_info: self.write_f2py_splitter() self.write_f2py_makefile(self.model) self.write_f2py_check_sa(matrix_elements, pjoin(self.dir_path,'SubProcesses','check_sa.py')) else: # create a single makefile to compile all the subprocesses - text = '''\n# For python linking (require f2py part of numpy)\nifeq ($(origin MENUM),undefined)\n MENUM=2\nendif\n''' + text = '''\n# For python linking (require f2py part of numpy)\nifeq ($(origin MENUM),undefined)\n MENUM=2\nendif\n''' deppython = '' for Pdir in os.listdir(pjoin(self.dir_path,'SubProcesses')): if os.path.isdir(pjoin(self.dir_path, 'SubProcesses', Pdir)): @@ -3648,6 +6905,23 @@ def write_f2py_splitter(self): flavor_index_decl = '\n'.join(' integer %sget_flavor_index' % prefix for prefix in sorted(smatrixhel_prefixes)) + # smatrixhel_idx: the same dispatch keyed on the 1-based matrix-element + # slot (the get_pdg_order / get_prefix index) instead of on the PDG + # codes, taking the extended FLAV_IDX as given. A FOLDED crossed + # subprocess has no PDG entry of its own -- that is the whole point of + # folding -- so the PDG dispatch cannot reach it; a caller that resolved + # the crossing itself (through GET_PDG_FOR_FLAVOR) holds a slot and an + # extended index instead. It shares f77_smatrixhel's alphas/scale2 + # handling so that a crossed and an uncrossed evaluation of the same + # event use the exact same running couplings. + idxtext = [] + for i, prefix in enumerate(allprefix, 1): + keyword = 'if' if i == 1 else 'else if' + idxtext.append(' %s (procindex.eq.%i) then' % (keyword, i)) + idxtext.append(' call %ssmatrixhel(p, nhel, flav_idx, ans)' % prefix) + if idxtext: + idxtext.append(' endif') + all_prefix = set([k[0] for k in self.prefix_info.values()]) setpara_for_each_matrix = '' for prefix in all_prefix: @@ -3683,11 +6957,18 @@ def write_f2py_splitter(self): end """ nhel_template = """subroutine %(f2py_prefix)sf77_%(prefix)sget_nhel_entry(NHEL) - integer %(prefix)snhel(%(next)s,%(ncombs)s), NHEL(%(next)s,%(ncombs)s) - common/%(prefix)sPROCESS_NHEL/%(prefix)sNHEL - NHEL(:,:) = %(prefix)snhel(:,:) + integer NHEL(%(next)s,%(ncombs)s) + integer idendummy +C Fill NHEL through GET_NHEL rather than reading the PROCESS_NHEL common +C directly. With the canonical helicity encoder/decoder the table is +C materialized at runtime (GET_NHEL calls FILL_NHEL), so an early caller +C -- e.g. reweighting building its per-config helicity map at init, before +C any matrix-element evaluation -- would otherwise read a table of zeros. +C Every standalone matrix.f defines GET_NHEL (materializing or DATA-backed), +C so this also stays correct for split-order processes. + call %(prefix)sget_nhel(idendummy, NHEL) return - end + end """ f2py_prefix = '' @@ -3713,8 +6994,9 @@ def write_f2py_splitter(self): all_iden += ' idens(%s) = %s \n' % (i, iden) #misc.sprint(all_iden) - formatting = {'python_information':'\n'.join(info), + formatting = {'python_information':'\n'.join(info), 'smatrixhel': '\n'.join(smtext), + 'smatrixhel_idx': '\n'.join(idxtext), 'flavor_index_decl': flavor_index_decl, 'maxpart': max_nexternal, 'nb_me': len(allids), @@ -3742,9 +7024,59 @@ def write_f2py_splitter(self): fsock.close() formatting['nhel'] = all_nhel_f2py text = template2 % formatting - fsock = writers.FortranWriter(pjoin(self.dir_path, 'SubProcesses', 'f2py_wrapper.f'),'w') + f2py_wrapper_path = pjoin(self.dir_path, 'SubProcesses', 'f2py_wrapper.f') + fsock = writers.FortranWriter(f2py_wrapper_path,'w') fsock.writelines(text) - fsock.close() + fsock.close() + + # Expose the per-process crossing-aware f2py entry points + # (PY_GET_PDG_FOR_FLAVOR / GET_FLAVOR_LAYOUT / GET_NHEL_IDX / + # GET_DENSITY_IDX, etc.) in the COMBINED all_matrix module. They live in + # each subprocess' self-contained f2py_matrix_wrapper.f and call the + # M_* routines already linked into liball...me; the combined wrapper is + # otherwise base-only, so a crossing-aware python caller (MadSpin's + # density path) could not reach a folded crossed subprocess through it. + # Concatenate rather than add the files to the f2py command line: f2py's + # multi-file build leaves the extra wrappers' symbols undefined at + # dlopen on some platforms, whereas a single scanned source links them. + wrappers = sorted(glob.glob(pjoin(self.dir_path, 'SubProcesses', + '*', 'f2py_matrix_wrapper.f'))) + if wrappers: + with open(f2py_wrapper_path, 'a') as fsock: + for wpath in wrappers: + fsock.write('\nC crossing-aware f2py wrappers from %s\n' + % os.path.relpath(wpath, + pjoin(self.dir_path, 'SubProcesses'))) + fsock.write(open(wpath).read()) + + self.write_crossing_records() + + def write_crossing_records(self): + """List the folded crossed subprocesses for the python consumers of the + combined f2py module (see recorded_crossing_codes). + + GET_PDG_FOR_FLAVOR tells a caller what process an extended FLAV_IDX + evaluates, but not whether that crossing is a subprocess the generation + asked for: its CROSS space is dense and also holds crossings that are + merely applicable (a Z or a decay product pulled into the initial state). + Only generation knows the difference, so it is recorded here, one line + per matrix element, + + ... + + with 0 when a recorded crossed process could not be matched + to a runtime crossing -- the consumer must then not trust the list to + cover every folded subprocess. The file is always written (empty lists + included) so that its absence means "produced before this existed", and + a consumer can tell that apart from "nothing was folded".""" + path = pjoin(self.dir_path, 'SubProcesses', 'crossed_flavors.dat') + with open(path, 'w') as fsock: + fsock.write('# folded crossed subprocesses, written by MG5aMC\n') + fsock.write('# ...\n') + for prefix in sorted(self.crossing_records): + codes, complete = self.crossing_records[prefix] + fsock.write('%s %d%s\n' % (prefix, 1 if complete else 0, + ''.join(' %d' % c for c in codes))) def get_model_parameter(self, model): """ returns all the model parameter @@ -3910,18 +7242,30 @@ def color_dim_from_particle(p): ids = [l.get('id') for l in proc.get('legs_with_decays')] iden = compute_iden_from_pdgs(ids, ninitial, self.model) self.prefix_info[(tuple(ids), proc.get('id'))] = [proc_prefix, proc.get_tag(), ncomb, iden] + # Which CROSS codes of this matrix element name a crossed subprocess + # this generation actually requested. Only a python caller holding + # them can walk the folded crossings without also evaluating the + # merely-applicable ones; write_f2py_splitter exports them. + self.crossing_records[proc_prefix] = \ + self.recorded_crossing_codes(matrix_element) template = open(pjoin(self.mgme_dir, 'madgraph', 'iolibs', 'template_files', 'makefile_sa_f_sp'),'r') text = template.read() template.close() fsock = open(pjoin(self.dir_path, 'SubProcesses', 'makefileP'),'w') + text = text.replace('BLASLIBS =', 'BLASLIBS = %s' % self.blas_link_flags()) fsock.write(text) fsock.close() + # The drivers call the matrix element by name, so they need the prefix + # write_matrix_element_v4 will actually use -- which for matchbox is + # not the one passed in here. + driver_prefix = self.get_proc_prefix(matrix_element, proc_prefix) + #important to put that first if self.format == 'standalone': filename2 = pjoin(dirpath, 'check_sa.f') - self.write_check_sa(writers.FortranWriter(filename2), matrix_element, proc_prefix) + self.write_check_sa(writers.FortranWriter(filename2), matrix_element, driver_prefix) replace_dict = self.write_matrix_element_v4( @@ -3932,16 +7276,18 @@ def color_dim_from_particle(p): return_replace_dict=True) calls = replace_dict.get('return_value', 0) - self.write_f2py_matrix_wrapper( - writers.FortranWriter(pjoin(dirpath, 'f2py_matrix_wrapper.f')), - replace_dict=replace_dict) + if self.write_f2py_interface: + self.write_f2py_matrix_wrapper( + writers.FortranWriter(pjoin(dirpath, 'f2py_matrix_wrapper.f')), + replace_dict=replace_dict) - # Python convenience wrapper letting callers pass either a FLAVOR array - # or a single flavor index to the f2py matrix2py module (dispatches to - # the array or *_idx Fortran entry point). Static helper, copied as-is. - shutil.copy(pjoin(_file_path, 'iolibs', 'template_files', - 'f2py_flavor_dispatch.py'), - pjoin(dirpath, 'flavor_dispatch.py')) + # Python convenience wrapper letting callers pass either a FLAVOR + # array or a single flavor index to the f2py matrix2py module + # (dispatches to the array or *_idx Fortran entry point). Static + # helper, copied as-is. + shutil.copy(pjoin(_file_path, 'iolibs', 'template_files', + 'f2py_flavor_dispatch.py'), + pjoin(dirpath, 'flavor_dispatch.py')) if self.opt['export_format'] == 'standalone_msP': @@ -4000,7 +7346,7 @@ def color_dim_from_particle(p): filename = pjoin(dirpath, 'check_sa.f') self.write_check_sa(writers.FortranWriter(filename), matrix_element, - proc_prefix=proc_prefix) + proc_prefix=driver_prefix) linkfiles = ['coupl.inc'] @@ -4076,10 +7422,15 @@ def _format_flavor_rebuild_only(self, n_flavors, flav_table_flat): ]) return (decl, setup) - def _get_flavor_mask_blocks(self, matrix_element): + def _get_flavor_mask_blocks(self, matrix_element, append_amp_init=True): """Build the Fortran declaration / setup blocks injected into GET_AMP (or the monolithic MATRIX) for the always-on flavor machinery. + append_amp_init controls whether the setup block emits its own + rank-1 AMP zero-initialisation. The default standalone template relies + on it; the --hel_recycling templates zero AMP themselves (the recycled + driver's AMP is 2-D), so they pass append_amp_init=False. + The blocks *always* rebuild FLAVOR(NEXTERNAL) from the threaded FLAV_IDX via FLAV_TABLE, giving a uniform API (every matrix function takes FLAV_IDX). When the ME has merged flavors that select different diagrams @@ -4144,10 +7495,17 @@ def _get_flavor_mask_blocks(self, matrix_element): thread_flav_idx=True) setup_block = self._format_flavor_mask_setup( leading_comment='C Rebuild FLAVOR and select the per-flavor masks.', - append_amp_init=True, thread_flav_idx=True) + append_amp_init=append_amp_init, thread_flav_idx=True) return (decl_block, setup_block, n_flavors, active_flavor_mask) + def color_data_prefix(self, replace_dict): + """The prefix the CF / DENOM DATA statements are written with. It has + to name the same variables the template declares, which for the + standalone templates are prefixed one per subprocess.""" + + return replace_dict['proc_prefix'] + #=========================================================================== # write_matrix_element_v4 #=========================================================================== @@ -4168,9 +7526,35 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, # Set lowercase/uppercase Fortran code writers.FortranWriter.downcase = False + # Where the matrix element is being written, so that the files that + # belong beside it (the split-orders driver, nsqso_born.inc) land there + # too. Empty -- i.e. the current directory -- when the caller passed a + # bare filename, which is what MadLoop does after chdir'ing. + me_dir = os.path.dirname(writer.name) if writer else '' if 'sa_symmetry' not in self.opt: self.opt['sa_symmetry']=False + # --use_crossing of the generate command (default on); see + # fill_crossing_replace_dict. + if 'use_crossing' not in self.opt: + self.opt['use_crossing']=True + + # Helicity-recycling standalone (--hel_recycling): reuse the madevent + # DAG rewriter. The helas_calls / jamp_lines are produced in the + # *standard* (scalar, aloha-object) format that hel_recycle.py parses, + # so no special writer is needed here; the recycled matrix.f is produced + # at write time by _write_hel_recycling_matrix from the orig + driver + # templates. + hel_recycling = str(self.cmd_options.get('hel_recycling', False)).lower() \ + in ('true', '1', 'yes') + + # ... and gated off per matrix element for processes whose definition + # pins a specific s-channel, which no crossing of them preserves. This + # is decided here rather than in the interface so that one constrained + # `add process` does not disable crossing for the unconstrained ones. + use_crossing = self.opt['use_crossing'] and \ + not any(self.breaks_crossing_symmetry(proc) + for proc in matrix_element.get('processes')) # The proc_id is for MadEvent grouping which is never used in SA. @@ -4191,13 +7575,42 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, # HELAS IAND guards. The try/finally ensures we never leak the writer # state into the next matrix element. mask_decl, mask_setup, n_mask, active_flavor_mask = \ - self._get_flavor_mask_blocks(matrix_element) + self._get_flavor_mask_blocks(matrix_element, + append_amp_init=not hel_recycling) replace_dict['flavor_mask_decl'] = mask_decl replace_dict['flavor_mask_setup'] = mask_setup + # Word counts of the per-call masks, needed by anything that has to + # redeclare CURRENT_*_MASK outside the matrix element routine (the + # --hel_recycling chunk subroutines). 0 when there is no mask. + if n_mask > 0: + replace_dict['nwords_wf'] = \ + (len(matrix_element.get_all_wavefunctions()) + 63) // 64 + replace_dict['nwords_amp'] = \ + (len(matrix_element.get_all_amplitudes()) + 63) // 64 + else: + replace_dict['nwords_wf'] = 0 + replace_dict['nwords_amp'] = 0 fortran_model.use_flavor_mask = (n_mask > 0) fortran_model.me_n_flavors = n_mask fortran_model.me_active_flavor_mask = active_flavor_mask + # Only matrix_standalone_v4.inc hands GET_AMP the crossed IC built by + # APPLY_CROSSING, so it is the only one whose NSF/NSV flags may go + # through IC. The other variants selected below (msP, msF, matchbox, + # splitOrders) have no IC to read and must keep the bare flag. Mirror + # the template choice made further down; split_orders is only fetched + # again here, which is side-effect free. + # --hel_recycling writes its own templates, whose MATRIX also takes the + # crossed IC built by APPLY_CROSSING, so it threads IC just the same. + fortran_model.use_crossing_ic = ( + use_crossing + and (hel_recycling + or self.matrix_template == 'matrix_standalone_v4.inc') + and self.opt['export_format'] not in ('standalone_msP', + 'standalone_msF', + 'matchbox', + 'madloop_matchbox') + and not matrix_element.get('processes')[0].get('split_orders')) try: # Extract helas calls helas_calls = fortran_model.get_matrix_element_calls(\ @@ -4206,6 +7619,7 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, fortran_model.use_flavor_mask = False fortran_model.me_n_flavors = 0 fortran_model.me_active_flavor_mask = None + fortran_model.use_crossing_ic = False replace_dict['helas_calls'] = "\n".join(helas_calls) @@ -4226,9 +7640,18 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, ncomb = matrix_element.get_helicity_combinations() replace_dict['ncomb'] = ncomb - # Extract helicity lines + # Extract helicity lines. helicity_lines (the explicit NHEL config DATA + # table) is still consumed by the msP/msF/splitOrders standalone + # templates; matrix_standalone_v4.inc instead uses the canonical + # encoder/decoder tables below (NHSTATE/STATES/HELALLOW) and + # materializes PROCESS_NHEL at runtime via FILL_NHEL. helicity_lines = self.get_helicity_lines(matrix_element) replace_dict['helicity_lines'] = helicity_lines + hel_data = self._helstate_data(matrix_element) + replace_dict['maxhel'] = hel_data['maxhel'] + replace_dict['nhstate_data'] = hel_data['nhstate_data'] + replace_dict['states_data'] = hel_data['states_data'] + replace_dict['hel_allow_data'] = hel_data['hel_allow_data'] # Extract overall denominator # Averaging initial state color, spin, and identical FS particles @@ -4245,7 +7668,30 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, # Extract ncolor ncolor = max(1, len(matrix_element.get('color_basis'))) replace_dict['ncolor'] = ncolor - replace_dict['ncolortriang'] = ncolor * (ncolor + 1) // 2 + # |M|^2 is summed over one color flow per reversal pair when the basis + # allows it, so the color matrix is only over those + folding = self.get_jamp_folding(matrix_element) + self.jamp_folding = folding + nfold = len(folding['representatives']) if folding else ncolor + replace_dict['ncolorfold'] = nfold + replace_dict['ncolortriang'] = nfold * (nfold + 1) // 2 + replace_dict['color_fold_index'] = '\n'.join( + self.get_int_data_lines("COLREP", + [i + 1 for i in folding['representatives']], + var='ICF')) if folding else '' + replace_dict['color_fold_gather'] = ( + " DO ICF = 1, NCOLORFOLD\n" + " JFOLD(ICF) = JAMP(COLREP(ICF))\n" + " ENDDO" if folding else + " DO ICF = 1, NCOLOR\n" + " JFOLD(ICF) = JAMP(ICF)\n" + " ENDDO") + if not folding: + replace_dict['color_fold_decl'] = " INTEGER ICF" + if folding: + replace_dict['color_fold_decl'] = \ + " INTEGER COLREP(NCOLORFOLD)\n INTEGER ICF" + replace_dict['hel_avg_factor'] = matrix_element.get_hel_avg_factor() replace_dict['beamone_helavgfactor'], replace_dict['beamtwo_helavgfactor'] =\ @@ -4253,7 +7699,11 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, # Extract color data lines color_data_lines = self.get_color_data_lines(matrix_element) - replace_dict['color_data_lines'] = "\n".join(color_data_lines) % {'proc_prefix': replace_dict['proc_prefix']} + replace_dict['color_data_lines'] = "\n".join(color_data_lines) % \ + {'proc_prefix': self.color_data_prefix(replace_dict)} + replace_dict['color_init_routine'] = "\n".join( + self.get_color_init_routine(matrix_element, + replace_dict['proc_prefix'])) if self.opt['export_format']=='standalone_msP': # For MadSpin need to return the AMP2 @@ -4264,11 +7714,14 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, # JAMP definition, depends on the number of independent split orders split_orders=matrix_element.get('processes')[0].get('split_orders') + self.jamp_recipes = None if len(split_orders)==0: replace_dict['nSplitOrders']='' # Extract JAMP lines - jamp_lines, nb_tmp_jamp = self.get_JAMP_lines(matrix_element) + jamp_lines, nb_tmp_jamp = self.get_JAMP_lines(matrix_element, + orbit=self.jamp_orbit_allowed(matrix_element), + proc_prefix=replace_dict['proc_prefix']) # Consider the output of a dummy order 'ALL_ORDERS' for which we # set all amplitude order to weight 1 and only one squared order # contribution which is of course ALL_ORDERS=2. @@ -4301,25 +7754,164 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, # that explicitely writes out the contribution from each squared order. # The original driver still works and is compiled with 'make' while # the splitOrders one is compiled with 'make check_sa_born_splitOrders' - check_sa_writer=writers.FortranWriter('check_sa_born_splitOrders.f') + # It goes next to the matrix element it calls, not into whatever + # directory MG5 happens to be running from: MadLoop chdir's into the + # subprocess first (so me_dir is empty there and nothing changes), + # the standalone exporters do not. + check_sa_writer=writers.FortranWriter( + pjoin(me_dir, 'check_sa_born_splitOrders.f')) self.write_check_sa_splitOrders(squared_orders,split_orders, - nexternal,ninitial,proc_prefix,check_sa_writer) + nexternal,ninitial, + self.get_proc_prefix(matrix_element, proc_prefix), + check_sa_writer) if write: - writers.FortranWriter('nsqso_born.inc').writelines( + nsqso = pjoin(me_dir, 'nsqso_born.inc') + writers.FortranWriter(nsqso).writelines( """INTEGER NSQSO_BORN PARAMETER (NSQSO_BORN=%d)"""%replace_dict['nSqAmpSplitOrders']) - files.cp('nsqso_born.inc', '..') + files.cp(nsqso, pjoin(me_dir, '..')) - replace_dict['jamp_lines'] = '\n'.join(jamp_lines) + replace_dict['jamp_lines'] = '\n'.join(jamp_lines) - matrix_template = self.matrix_template - if self.opt['export_format']=='standalone_msP' : - matrix_template = 'matrix_standalone_msP_v4.inc' - elif self.opt['export_format']=='standalone_msF': - matrix_template = 'matrix_standalone_msF_v4.inc' - elif self.opt['export_format']=='matchbox': - replace_dict["proc_prefix"] = 'MG5_%i_' % matrix_element.get('processes')[0].get('id') + # The color flow JAMPs, rebuilt from the ones entering the color sum. + # Standalone does not need a color flow, but it is the mode used to + # time the matrix element, so it carries the same work as madevent. + self.set_color_flow_lines_sa(matrix_element, replace_dict, ncolor) + + # The definitions written as one recipe per orbit are held in one + # array together with the amplitudes, so that the loop running them + # reads its two operands from the same place. + recipes = getattr(self, 'jamp_recipes', None) + replace_dict['jamp_decl'] = '\n'.join( + self.get_jamp_decl_lines(recipes, replace_dict['proc_prefix'])) + replace_dict['jamp_init_routine'] = '\n'.join( + self.get_jamp_init_routine(recipes, replace_dict['proc_prefix'])) + if recipes: + replace_dict['namp_dim'] = 'NGRAPHS+%d' % replace_dict['nb_temp_jamp'] + replace_dict['jamp_tmp_decl'] = '' + else: + replace_dict['namp_dim'] = 'NGRAPHS' + replace_dict['jamp_tmp_decl'] = \ + " COMPLEX*16 TMP_JAMP(%i)" % replace_dict['nb_temp_jamp'] + + # BLAS-3 color sum: every helicity is one column of a single right + # hand side, so the whole sum is two DSYMM calls instead of one + # triangular loop per helicity. + prefix = replace_dict['proc_prefix'] + reps = ([line + 1 for line in folding['representatives']] if folding + else list(range(1, ncolor + 1))) + # The batch branch does not go through MATRIX, so it has to carry the + # color flow JAMPs itself, exactly like the per-helicity path does + nflow = replace_dict.get('ncolor_flow', ncolor) + flow_decl, flow_lines = [], [] + if replace_dict.get('jampflow_routine'): + flow_decl = [" COMPLEX*16 JAMPFB(%d)" % nflow, + " DOUBLE PRECISION %sJAMP2(%d)" % (prefix, nflow), + " COMMON /%sJAMP2_COMMON/ %sJAMP2" % (prefix, + prefix)] + flow_lines = [ + " CALL %sGET_JAMPF(JAMPB,JAMPFB)" % prefix, + " DO IBH = 1, %d" % nflow, + " %sJAMP2(IBH) = %sJAMP2(IBH)" % (prefix, prefix), + " $ + DABS(DBLE(JAMPFB(IBH)*DCONJG(JAMPFB(IBH" + "))))", + " ENDDO"] + + if self.blas_wanted(nfold): + # The batch bypasses MATRIX, so it has to repeat by hand whatever + # MATRIX's caller does per helicity -- including the crossing. + # GOODHEL is shared by every crossing of a flavor and indexed by + # the identity row, so the gate goes through CROSS_GHIDX exactly + # as %(smatrix_goodhel_gate)s does, and GET_AMP gets the crossed + # arrays APPLY_CROSSING_TABLE built once before this branch. + if use_crossing: + blas_gate = [ + " CALL %sCROSS_GHIDX(CROSSUSE, XGPERM, XGSGN," + % prefix, + " $ NHEL(1,IHEL), GHIDX)", + " IF (GHIDX.EQ.0 .OR. GOODHEL(GHIDX,FLAV_USE))" + " THEN"] + blas_amp = [ + " IF (CROSSUSE.EQ.0) THEN", + " CALL %sGET_AMP(P,NHEL(1,IHEL),JC(1)," + "FLAV_USE,AMPB)" % prefix, + " ELSE", + " CALL %sGET_AMP(PUSE,NHELUSE(1,IHEL)," + "ICUSE(1),FLAV_USE,AMPB)" % prefix, + " ENDIF"] + else: + blas_gate = [" IF (GOODHEL(IHEL,FLAV_USE)) THEN"] + blas_amp = [ + " CALL %sGET_AMP(P,NHEL(1,IHEL),JC(1)," + "FLAV_USE,AMPB)" % prefix] + replace_dict['blas_guard_open'] = "(" + replace_dict['blas_guard'] = ") .AND. .NOT.BLASDONE" + replace_dict['blas_decl'] = "\n".join([ + " LOGICAL BLASDONE", + " INTEGER NBHEL, IBH", + # NGRAPHS is not in scope here, so size the buffer outright + " COMPLEX*16 AMPB(%d), JAMPB(%d)" % ( + replace_dict['ngraphs'] + (replace_dict['nb_temp_jamp'] + if recipes else 0), ncolor), + " DOUBLE PRECISION, ALLOCATABLE, SAVE :: JRB(:,:)", + " DOUBLE PRECISION, ALLOCATABLE, SAVE :: JIB(:,:)", + " INTEGER COLREPB(%d)" % nfold] + flow_decl + + self.get_int_data_lines("COLREPB", reps, var='IBH')) + replace_dict['blas_branch'] = "\n".join([ + # NTRY_CSYM>=20 as well: while the C-parity scan is still + # running the per-helicity loop has to fill TSTORE, and the + # batch does not. Without crossings NTRY_CSYM tracks NTRY + # exactly, so this costs nothing. + " BLASDONE = .FALSE.", + " IF (USERHEL.EQ.-1 .AND. NTRY(FLAV_USE).GE.20", + " $ .AND. NTRY_CSYM(FLAV_USE).GE.20", + " $ .AND. POLARIZATIONS(0,0).EQ.-1) THEN", + " IF (.NOT.ALLOCATED(JRB)) THEN", + " ALLOCATE(JRB(%d,NCOMB))" % nfold, + " ALLOCATE(JIB(%d,NCOMB))" % nfold, + " ENDIF", + " NBHEL = 0", + " DO IHEL=1,NCOMB", + # The batch has to honour the C-parity de-duplication the + # scalar loop below applies, or it evaluates every good + # helicity where that loop evaluates one per mirror pair -- + # and GET_AMP, not the color sum, is what that costs. + # De-duplication is all-or-nothing per flavor, so every kept + # row is doubled by the same factor and one multiply on the + # total is exact (no per-column scaling, no sqrt(2)). + " IF (DEDUP.AND.IHEL.GT.FLIP(IHEL)) CYCLE"] + + blas_gate + [ + " NBHEL = NBHEL + 1"] + blas_amp + [ + " CALL %sGET_JAMP(AMPB,JAMPB)" % prefix] + + flow_lines + [ + " DO IBH = 1, %d" % nfold, + " JRB(IBH,NBHEL) = DBLE(JAMPB(COLREPB(IBH)))", + " JIB(IBH,NBHEL) = DIMAG(JAMPB(COLREPB(IBH)))", + " ENDDO", + " ENDIF", + " ENDDO", + " IF (NBHEL.GT.0) THEN", + " CALL %sGET_MATRIX_BATCH(JRB,JIB,NBHEL,ANS)" % prefix, + " IF (DEDUP) ANS = ANS + ANS", + " ENDIF", + " BLASDONE = .TRUE.", + " ENDIF"]) + replace_dict['blas_routine'] = self.get_blas_routine( + prefix, nfold, ncomb) + else: + # nothing added when BLAS is off, so what is written is exactly + # what was written before any of this existed + replace_dict['blas_guard_open'] = "" + replace_dict['blas_guard'] = "" + replace_dict['blas_decl'] = "" + replace_dict['blas_branch'] = "" + replace_dict['blas_routine'] = "" + + matrix_template = self.get_matrix_template(matrix_element) + if self.opt['export_format']=='matchbox': + replace_dict["proc_prefix"] = self.get_proc_prefix(matrix_element, + proc_prefix) replace_dict["color_information"] = self.get_color_string_lines(matrix_element) if len(split_orders)>0: @@ -4329,9 +7921,6 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, " Only the total ME will be computed.", self.opt['export_format']) elif self.opt['export_format'] in ['madloop_matchbox', 'matchbox']: replace_dict["color_information"] = self.get_color_string_lines(matrix_element) - matrix_template = "matrix_standalone_matchbox_splitOrders_v4.inc" - else: - matrix_template = "matrix_standalone_splitOrders_v4.inc" process = matrix_element.get('processes')[0] sym_data = self._get_broken_symmetry_data(process, ninitial) self._fill_broken_sym_replace_dict(replace_dict, sym_data) @@ -4372,9 +7961,62 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, fa_func_name, n_table, flav_table_flat, nexternal_decl=bs_nexternal) + # Per-crossing denominator and the routines decoding an extended + # FLAV_IDX. Only matrix_standalone_v4.inc has these holes, and they are + # left empty when the process was generated with --use_crossing=False. + self.fill_crossing_replace_dict(matrix_element, replace_dict, + use_crossing) + # The recycled driver has its own (smaller) set of crossing holes. + self.fill_crossing_replace_dict_hr(replace_dict, use_crossing) + + # GET_PDG_FOR_FLAVOR (extended FLAV_IDX -> per-leg PDG). Must come after + # fill_crossing_replace_dict, which decides whether it decodes a + # crossing or just reads the table. Only matrix_standalone_v4.inc has + # the hole; the key is set unconditionally since an unused replace_dict + # entry is harmless and the other templates then stay byte-identical. + n_pdg_flav, pdg_flat, antipdg_flat = \ + self._build_flav_pdg_tables(matrix_element) + replace_dict['flavor_pdg_function'] = \ + self._make_flavor_pdg_fortran_function( + replace_dict['proc_prefix'] + 'GET_PDG_FOR_FLAVOR', + n_pdg_flav, pdg_flat, antipdg_flat, + replace_dict['pdg_cross_snippets'], + nexternal_decl=bs_nexternal) + + # f2py entry points taking an extended FLAV_IDX (the only way a python + # caller can request a crossing, and reach GET_DENSITY_IDX / + # GET_ALL_INTER_IDX / GET_NHEL_IDX / GET_PDG_FOR_FLAVOR). Those routines + # only exist in matrix_standalone_v4.inc, so the wrappers are emitted + # only there; the other standalone templates get an empty hole (the + # placeholder lives in the shared matrix_standalone_f2py.inc). The + # snippet is pre-formatted here because the outer '% replace_dict' pass + # does not re-scan an inserted value for further %(...)s. + if matrix_template == 'matrix_standalone_v4.inc': + flav_idx_tmpl = open(pjoin(_file_path, 'iolibs', 'template_files', + 'matrix_standalone_f2py_flav_idx.inc')).read() + nexternal_val = int(replace_dict['nexternal']) + replace_dict['f2py_flav_idx_wrappers'] = flav_idx_tmpl % { + 'proc_prefix': replace_dict['proc_prefix'], + 'nexternal': nexternal_val, + 'nflav': replace_dict['nflav'], + 'ncomb': replace_dict['ncomb'], + 'ncross': (nexternal_val + 1) ** 2, + } + else: + replace_dict['f2py_flav_idx_wrappers'] = '' + replace_dict['template_file'] = pjoin(_file_path, 'iolibs', 'template_files', matrix_template) replace_dict['template_file2'] = pjoin(_file_path, \ 'iolibs/template_files/split_orders_helping_functions.inc') + if write and writer and hel_recycling: + # Standalone helicity recycling: produce matrix.f via the madevent + # DAG rewriter instead of a single template substitution. + self._write_hel_recycling_matrix(writer, replace_dict, matrix_element) + if return_replace_dict: + replace_dict['return_value'] = len([call for call in helas_calls if call.find('#') != 0]) + return replace_dict + else: + return len([call for call in helas_calls if call.find('#') != 0]) if write and writer: path = replace_dict['template_file'] content = open(path).read() @@ -4396,8 +8038,876 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, return replace_dict # for subclass update #=========================================================================== - # write_check_sa + # helicity recycling (--hel_recycling) + #=========================================================================== + def fill_crossing_replace_dict_hr(self, replace_dict, use_crossing): + """Fill the crossing holes of matrix_standalone_hel_v4.inc. + + The recycled driver cannot reuse the standard SMATRIX snippets: it has + no NHEL(NEXTERNAL,NCOMB) table to permute (its helicity rows are baked + into the HELAS calls and already play the role of the CROSSED + configurations), so it only needs the crossed momenta and NSF flags. + The union over crossings of the good rows is what makes that valid, and + it is the warm-up that measures it. + + Requires proc_prefix and nflav to be set already. + """ + prefix = replace_dict['proc_prefix'] + if not use_crossing: + replace_dict.update({ + 'hr_cross_decl': + 'C Generated without crossing symmetry: FLAV_IDX is a' + ' plain flavor index.', + 'hr_cross_decode': + ' FLAV_USE = FLAV_IDX\n' + ' IF (FLAV_IDX.LT.1 .OR. FLAV_IDX.GT.NFLAV) RETURN', + 'hr_cross_apply': '', + 'hr_matrix_call': + ' CALL %sMATRIX(P, JC, FLAV_USE, TS)' % prefix, + 'hr_iden_line': + ' ANS=ANS/DBLE(IDEN)*%sBROKEN_SYM(FLAVOR)' % prefix, + 'hr_helcheck': + ' IF (FLAV_IDX.LT.1 .OR. FLAV_IDX.GT.NFLAV) RETURN', + 'hr_warmup_ncross': '1', + 'hr_warmup_cross_decl': '', + 'hr_warmup_cross_skip': '', + 'hr_warmup_cross_apply': '', + }) + return + + replace_dict.update({ + 'hr_cross_decl': + 'C CROSSUSE is the crossing carried by FLAV_IDX and IDENUSE the' + ' initial\nC state spin*color average of the process it crosses' + ' into. PUSE/ICUSE are\nC the crossed momenta and NSF flags,' + ' built once per SMATRIX call.\n' + ' INTEGER IDENUSE, CROSSUSE\n' + ' INTEGER %(p)sGET_SPINCOL_CROSS\n' + ' INTEGER %(p)sGET_IDENT_CROSS\n' + ' REAL*8 PUSE(0:3,NEXTERNAL)\n' + ' INTEGER ICUSE(NEXTERNAL)\n' + 'C APPLY_CROSSING permutes a helicity row together with the' + ' momenta; the\nC recycled driver has no row to permute, so it' + ' feeds a dummy one.\n' + ' INTEGER NHELDUM(NEXTERNAL), NHELOUT(NEXTERNAL)\n' + ' INTEGER DUMFLAV' % {'p': prefix}, + 'hr_cross_decode': + 'C CROSS = (FLAV_IDX-1)/NFLAV is the crossing to apply. IDENUSE' + ' is 0 for a\nC crossing that cannot be applied, whose matrix' + ' element is identically zero.\n' + ' CROSSUSE = (FLAV_IDX-1) / NFLAV\n' + ' FLAV_USE = MOD(FLAV_IDX-1, NFLAV) + 1\n' + ' IF (FLAV_IDX.LT.1) RETURN\n' + ' IDENUSE = %(p)sGET_SPINCOL_CROSS(CROSSUSE)\n' + ' IF (IDENUSE.EQ.0) RETURN' % {'p': prefix}, + 'hr_cross_apply': + ' IF (CROSSUSE.NE.0) THEN\n' + ' NHELDUM(:) = 0\n' + ' CALL %(p)sAPPLY_CROSSING(FLAV_IDX, P, NHELDUM, JC,\n' + ' & PUSE, NHELOUT, ICUSE, DUMFLAV)\n' + ' ENDIF' % {'p': prefix}, + 'hr_matrix_call': + ' IF (CROSSUSE.EQ.0) THEN\n' + ' CALL %(p)sMATRIX(P, JC, FLAV_USE, TS)\n' + ' ELSE\n' + ' CALL %(p)sMATRIX(PUSE, ICUSE, FLAV_USE, TS)\n' + ' ENDIF' % {'p': prefix}, + 'hr_iden_line': + 'C Uncrossed: IDEN carries the representative identical-particle' + '\nC factor and BROKEN_SYM corrects it per flavor. Crossed:' + ' rebuild the\nC denominator as initial state spin*color (per' + ' crossing) times the\nC identical final state factor of the' + ' actual crossed flavors.\n' + ' IF (CROSSUSE.EQ.0) THEN\n' + ' ANS=ANS/DBLE(IDEN)*%(p)sBROKEN_SYM(FLAVOR)\n' + ' ELSE\n' + ' ANS=ANS/DBLE(IDENUSE*%(p)sGET_IDENT_CROSS(CROSSUSE,\n' + ' & FLAVOR))\n' + ' ENDIF' % {'p': prefix}, + 'hr_helcheck': + 'C A crossed FLAV_IDX would need the base row that maps onto' + ' each baked\nC (crossed) row, which the recycled table does not' + ' carry.\n' + ' IF (FLAV_IDX.GT.NFLAV) THEN\n' + " WRITE(*,*) 'SMATRIXHEL: a crossed FLAV_IDX is not" + " supported with --hel_recycling'\n" + ' STOP 1\n' + ' ENDIF\n' + ' IF (FLAV_IDX.LT.1) RETURN', + 'hr_warmup_ncross': '(NEXTERNAL+1)*(NEXTERNAL+1)', + 'hr_warmup_cross_decl': + ' INTEGER %(p)sGET_SPINCOL_CROSS\n' + ' EXTERNAL %(p)sGET_SPINCOL_CROSS' % {'p': prefix}, + 'hr_warmup_cross_skip': + 'C Skip a crossing that cannot be applied: its matrix' + ' element is\nC identically zero, so it needs no helicity' + ' row.\n' + ' IF (CROSS.NE.0) THEN\n' + ' IF (%(p)sGET_SPINCOL_CROSS(CROSS).EQ.0) CYCLE\n' + ' ENDIF' % {'p': prefix}, + 'hr_warmup_cross_apply': + 'C Cross the momenta and the NSF flags exactly as' + ' SMATRIX does.\n' + ' IF (CROSS.NE.0) THEN\n' + ' NHELDUM(:) = 0\n' + ' CALL %(p)sAPPLY_CROSSING(FLAV_EXT, P, NHELDUM, JC,\n' + ' & PUSE, NHELOUT, ICUSE, DUMFLAV)\n' + ' ENDIF' % {'p': prefix}, + }) + + @staticmethod + def _hel_recycling_csym(csym_pairs, good_hels, bad_amps_perhel, nb_amp): + """Fold the measured C-parity pairs into the recycler inputs. + + For each surviving (representative, partner) pair BOTH rows stay in the + helicity table -- so the |M|^2 sum, the polarization filter and + SMATRIXHEL keep a value per row -- but every amplitude of the partner is + added to bad_amps_perhel, so its HELAS calls are never generated, and + its |M|^2 is copied back from the representative by the csym_reuse + block. The reuse indices are the OPTIM's positions (helicities are + renumbered 1..len(good_hels) in the recycled file). + + Returns (bad_amps_perhel, csym_reuse_text, csym_dead_text). The second + text marks the partner rows as dead so the color stage skips them: their + AMP row is all zeros, and summing colors over zeros is half the work at + a process where every row is paired. + """ + if not csym_pairs: + return bad_amps_perhel, '', '' + good_set = set(int(h) for h in good_hels) + opt_index = dict((h, i + 1) for i, h in enumerate(sorted(good_set))) + bad_set = set(bad_amps_perhel) + reuse = [] + for rep, flip in csym_pairs: + if rep not in good_set or flip not in good_set: + continue + for amp in range(1, nb_amp + 1): + bad_set.add((flip, amp)) + reuse.append((opt_index[rep], opt_index[flip])) + if not reuse: + return bad_amps_perhel, '', '' + text = '\n'.join(' TS(%d) = TS(%d)' % (flip, rep) + for rep, flip in sorted(reuse)) + '\n' + dead = '\n'.join(' HRDEAD(%d) = .TRUE.' % flip + for _rep, flip in sorted(reuse)) + '\n' + return sorted(bad_set), text, dead + + # How many helicity rows the color stage of the recycled MATRIX gathers at + # a time. AMP is helicity major, so the rows of one amplitude are adjacent + # and eight complex*16 are one 128 byte cache line: gathering a row on its + # own fetches one line per amplitude and uses 16 bytes of it, gathering + # eight fetches the same line once and uses all of it. Measured here, color + # stage of g g > 5g against the block size, 1/2/4/8/16/32/64 -> + # 3.2/2.8/2.4/2.3/2.3/2.7/2.5 ms. Kept in step with hel_recycle.GATHER_BLOCK, + # which madevent's own color stage uses. + hel_recycling_gather_block = 8 + + def _hel_recycling_color_blocks(self, rd): + """The color stage of the recycled MATRIX: (declarations, body). + + The amplitudes are gathered out of the helicity-major AMP into + contiguous per-row buffers and handed to the SHARED GET_JAMP -- the very + routine the standard output calls. The gather is what makes that + possible at all: read in place, every amplitude of a row sits NCOMB + entries from the next, and the color flows then cost a cache line per + amplitude read instead of a cache line per row. + + The color sum goes through the batched BLAS-3 GET_MATRIX_BATCHV (every + live helicity is one column of a single right hand side) when BLAS is + available and the color matrix is big enough for the call to pay for + itself, and through the shared, folded GET_MATRIX one row at a time when + it is not. Either way the recycled build stops carrying its own copy of + the color sum, so it inherits the folding and everything else the color + side gains. + """ + prefix = rd['proc_prefix'] + nfold = int(rd['ncolorfold']) + blas = self.blas_wanted(nfold) + folding = getattr(self, 'jamp_folding', None) + reps = ([line + 1 for line in folding['representatives']] if folding + else list(range(1, int(rd['ncolor']) + 1))) + decl = [ + " INTEGER NHRBLK", + " PARAMETER (NHRBLK=%d)" % self.hel_recycling_gather_block, + " INTEGER HRL, HRNL", + # Dimensioned like the standard GET_JAMP's AMP: when the color flow + # definitions are emitted as operand tables, their temporaries are + # written past NGRAPHS into this same buffer. One lane per gathered + # row, and a lane is contiguous (fortran is column major). + " COMPLEX*16 AMPK(%s,NHRBLK)" % rd['namp_dim'], + " COMPLEX*16 JAMP(NCOLOR)", + " SAVE AMPK"] + gather = [ + " DO KK = 1, NHRROW, NHRBLK", + " HRNL = MIN(NHRBLK, NHRROW-KK+1)", + " DO I = 1, NGRAPHS", + " DO HRL = 1, HRNL", + " AMPK(I,HRL) = AMP(HRROW(KK+HRL-1),I)", + " ENDDO", + " ENDDO", + " DO HRL = 1, HRNL", + " CALL %sGET_JAMP(AMPK(1,HRL), JAMP)" % prefix] + clear = [" DO K = 1, NCOMB", + " TS(K) = 0D0", + " ENDDO"] + if not blas: + return '\n'.join(decl), '\n'.join(clear + gather + [ + " CALL %sGET_MATRIX(JAMP, TS(HRROW(KK+HRL-1)))" % prefix, + " ENDDO", + " ENDDO"]) + decl += [ + " INTEGER NCOLORFOLD", + " PARAMETER (NCOLORFOLD=%d)" % nfold, + # The batch wants the color flows helicity major, which is the one + # layout the recycled build has for free. + " DOUBLE PRECISION JRB(NCOLORFOLD,NCOMB)", + " DOUBLE PRECISION JIB(NCOLORFOLD,NCOMB)", + " DOUBLE PRECISION TSB(NCOMB)", + " SAVE JRB, JIB, TSB", + " INTEGER COLREPB(NCOLORFOLD), IBH"] + \ + self.get_int_data_lines("COLREPB", reps, var='IBH') + body = '\n'.join(gather + [ + " DO IBH = 1, NCOLORFOLD", + " JRB(IBH,KK+HRL-1) = DBLE(JAMP(COLREPB(IBH)))", + " JIB(IBH,KK+HRL-1) = DIMAG(JAMP(COLREPB(IBH)))", + " ENDDO", + " ENDDO", + " ENDDO"] + clear + [ + " IF (NHRROW.GT.0) THEN", + " CALL %sGET_MATRIX_BATCHV(JRB,JIB,NHRROW,TSB)" % prefix, + " DO KK = 1, NHRROW", + " TS(HRROW(KK)) = TSB(KK)", + " ENDDO", + " ENDIF"]) + return '\n'.join(decl), body + + # Statements per chunk when the recycled helas block is split out of MATRIX + # (see HelicityRecycler.split_helas_block). Also the threshold below which + # the block is left in MATRIX: a small block optimizes perfectly as one + # unit and splitting it costs runtime. Overridable per output with + # --hel_recycling_chunk= (0 disables the split entirely). + hel_recycling_chunk_stmts = 1500 + + def _hel_recycling_chunk_size(self): + """Statements per chunk, from --hel_recycling_chunk if given.""" + try: + return int(self.cmd_options.get('hel_recycling_chunk', + self.hel_recycling_chunk_stmts)) + except (TypeError, ValueError): + logger.warning('--hel_recycling_chunk must be an integer; using %s', + self.hel_recycling_chunk_stmts) + return self.hel_recycling_chunk_stmts + + def _hel_recycling_chunk_files(self): + """How many source files to spread the chunk subroutines over. + + Defaults to the core count, so that `make -j` builds them all at once: + the chunks are independent, and left in one file they are one + translation unit that one gfortran process compiles on one core with a + heap that grows with the whole file. Override with + --hel_recycling_files=; 1 restores the single-file behaviour.""" + + default = os.cpu_count() or 1 + try: + return max(1, int(self.cmd_options.get('hel_recycling_files', + default))) + except (TypeError, ValueError): + logger.warning('--hel_recycling_files must be an integer; using %s', + default) + return default + + def _hel_recycling_chunk_spec(self, replace_dict, out_path): + """Describe how to split the recycled helas block of the standalone + MATRIX into chunk subroutines: the file to write them to, the shared + state they take by reference, and their declaration preamble. + + NWAVEFUNCS/NCOMB are the RECYCLED counts, which only the rewriter knows, + so they are left as ${...} for it to substitute. The rewriter picks the + arguments from the candidates the block actually references.""" + prologue = ( + ' use model_object\n' + ' use aloha_object\n' + ' IMPLICIT NONE\n' + ' INTEGER NEXTERNAL\n' + ' PARAMETER (NEXTERNAL=%(nexternal)s)\n' + ' INTEGER NWAVEFUNCS, NCOMB, NGRAPHS\n' + ' PARAMETER (NWAVEFUNCS=${nwavefuncs}, NCOMB=${ncomb},\n' + ' & NGRAPHS=%(ngraphs)s)\n' + ' REAL*8 ZERO\n' + ' PARAMETER (ZERO=0D0)\n' + ' COMPLEX*16 IMAG1\n' + ' PARAMETER (IMAG1=(0D0,1D0))' + ) % {'nexternal': replace_dict['nexternal'], + 'ngraphs': replace_dict['ngraphs']} + candidates = [ + ('P', ' REAL*8 P(0:3,NEXTERNAL)'), + ('IC', ' INTEGER IC(NEXTERNAL)'), + ('FLAVOR', ' INTEGER FLAVOR(NEXTERNAL)'), + ('W', ' type(aloha) W(NWAVEFUNCS)'), + ('AMP', ' COMPLEX*16 AMP(NCOMB,NGRAPHS)'), + ] + if replace_dict.get('nwords_wf'): + # the word counts go in the prologue: either mask may be the only + # one the block references, and both declarations need them. + prologue += ('\n INTEGER NWORDS_WF, NWORDS_AMP\n' + ' PARAMETER (NWORDS_WF=%(nwords_wf)d,' + ' NWORDS_AMP=%(nwords_amp)d)' % replace_dict) + candidates += [ + ('CURRENT_WF_MASK', + ' INTEGER*8 CURRENT_WF_MASK(NWORDS_WF)'), + ('CURRENT_AMP_MASK', + ' INTEGER*8 CURRENT_AMP_MASK(NWORDS_AMP)'), + ] + locals_ = ( + ' COMPLEX*16 TMP(%(wavefunctionsize)s)\n' + ' COMPLEX*16 DUM0,DUM1\n' + ' DATA DUM0, DUM1/(0D0, 0D0), (1D0, 0D0)/\n' + ' double precision bwcutoff' + ) % {'wavefunctionsize': replace_dict['wavefunctionsize']} + epilogue = " include 'coupl.inc'\n bwcutoff=15" + base = out_path[:-2] if out_path.endswith('.f') else out_path + # proc_prefix keeps the chunk names distinct when several subprocess + # libraries end up in one f2py module (write_f2py_splitter). + return {'file': '%s_getamp.f' % base, + 'stmts': self._hel_recycling_chunk_size(), + 'nfiles': self._hel_recycling_chunk_files(), + 'spec': {'name': '%sGET_AMP_CH' % replace_dict['proc_prefix'], + 'prologue': prologue, + 'candidates': candidates, 'locals': locals_, + 'epilogue': epilogue}} + + def _run_hel_recycle(self, orig_path, driver_path, out_path, + good_hels, bad_amps, bad_amps_perhel, gauge, + csym_reuse='', csym_dead='', chunk=None): + """Run the madevent DAG rewriter to turn matrix_orig.f + template_matrix.f + into the recycled matrix.f at out_path. good_hels/bad_amps/bad_amps_perhel + are string lists in the gen_ximprove format; all empty bad_* + good_hels = + 1..NCOMB reproduces the compute-all (exact) matrix element.""" + import madgraph.madevent.hel_recycle as hel_recycle + recycler = hel_recycle.HelicityRecycler(good_hels, bad_amps, + bad_amps_perhel, gauge=gauge) + if csym_reuse: + recycler.template_dict['csym_reuse'] = csym_reuse + if csym_dead: + recycler.template_dict['csym_dead'] = csym_dead + if chunk: + recycler.chunk_file = chunk['file'] + recycler.chunk_stmts = chunk['stmts'] + recycler.chunk_nfiles = chunk.get('nfiles', 1) + recycler.chunk_spec = chunk['spec'] + recycler.hel_filt = True # drop helicity combinations not in good_hels + recycler.amp_splt = True # P1N amplitude split (the speed-up) + recycler.amp_filt = bool(bad_amps) or bool(bad_amps_perhel) + recycler.set_input(orig_path) + recycler.set_output(out_path) + recycler.set_template(driver_path) + recycler.generate_output_file() + + def _write_hel_recycling_matrix(self, writer, replace_dict, matrix_element): + """Standalone helicity recycling (--hel_recycling): write matrix_orig.f + (the madevent single-MATRIX layout), template_matrix.f (the standalone + SMATRIX/MATRIX driver with ${...} slots) and hel_warmup.f (the good-hel / + zero-amp probe), then run the madevent DAG rewriter (hel_recycle) to + produce a first, compute-all matrix.f in place. + + This first pass keeps every helicity combination (good_elements = + 1..NCOMB), so the directory is already valid + correct. finalize() (once + the Source libraries are built) compiles + runs hel_warmup.f and re-runs + the rewriter with the measured good-helicity / zero-amplitude lists to + drop the dead work -- matching what madevent does at run time. + """ + tmpl_dir = pjoin(_file_path, 'iolibs', 'template_files') + orig_tmpl = pjoin(tmpl_dir, 'matrix_standalone_hel_orig_v4.inc') + driver_tmpl = pjoin(tmpl_dir, 'matrix_standalone_hel_v4.inc') + warmup_tmpl = pjoin(tmpl_dir, 'hel_warmup_v4.inc') + + rd = dict(replace_dict) + # Raw storage for the recycled P1N current wavefunction: the split + # amplitude calls hand TMP to CombineAmp as a type(aloha) scratch. + rd.setdefault('wavefunctionsize', 18) + rd['hr_color_decl'], rd['hr_color_sum'] = \ + self._hel_recycling_color_blocks(rd) + # The recycled color stage needs the per-column batch, which the + # standard SMATRIX has no use for -- add it to the copy of the shared + # routines that only this output gets. Its NCOMB is the RECYCLED count + # (the caller's JRB/JIB are that wide), which only the rewriter knows, + # so it goes in as the ${ncomb} slot for the rewriter to fill. + if rd.get('blas_routine'): + rd['blas_routine'] += self.get_blas_vector_routine( + rd['proc_prefix'], int(rd['ncolorfold']), '${ncomb}') + + out_path = writer.name + dirpath = os.path.dirname(out_path) + orig_path = pjoin(dirpath, 'matrix_orig.f') + driver_path = pjoin(dirpath, 'template_matrix.f') + warmup_path = pjoin(dirpath, 'hel_warmup.f') + + # matrix_orig.f is routed through FortranWriter so long DATA/JAMP/helas + # lines get the fixed-form continuations hel_recycle reads back verbatim. + writers.FortranWriter(orig_path).writelines(open(orig_tmpl).read() % rd) + # The recycled driver only replaces SMATRIX/SMATRIXHEL/MATRIX. Every + # other entry point (the per-helicity GET_AMP/GET_JAMP, the density and + # interference stack, GET_value, the encoder/decoder, the crossing + # routines, BROKEN_SYM and the flavor helpers) is appended verbatim + # from the standard template, so the recycled output exposes the same + # API and the two cannot drift apart. The density path keeps using the + # plain GET_AMP: it evaluates arbitrary helicity configurations, which + # the recycled table -- baked at generation time, dead rows dropped -- + # cannot serve. + shared_anchor = (' SUBROUTINE %(proc_prefix)sGET_NHEL(' + 'IDEN_STAR,NHEL_STAR)' % replace_dict) + standard = open(pjoin(tmpl_dir, self.matrix_template)).read() + if shared_anchor.replace('%(proc_prefix)s', rd['proc_prefix']) \ + not in standard % rd: + raise MadGraph5Error( + 'hel_recycling: cannot find the shared-routine anchor in %s' + % self.matrix_template) + rendered = standard % rd + tail = rendered[rendered.index( + shared_anchor.replace('%(proc_prefix)s', rd['proc_prefix'])):] + + # template_matrix.f: %()s keys filled now; ${...} slots left to hel_recycle. + # FortranWriter is still used (to split long color DATA lines), but it + # upper-cases everything, including the ${...} slot names -- hel_recycle's + # string.Template keys are lower-case, so restore their case afterwards. + writers.FortranWriter(driver_path).writelines( + (open(driver_tmpl).read() % rd) + '\n\n\n' + tail) + driver_txt = open(driver_path).read() + driver_txt = re.sub(r'\$\{(\w+)\}', + lambda m: '${%s}' % m.group(1).lower(), driver_txt) + open(driver_path, 'w').write(driver_txt) + # hel_warmup.f: standalone probe program (compiled + run in finalize). + # Written raw (not via FortranWriter) -- it is hand-authored fixed-form + # with numbered/shared DO labels and IMPLICIT typing that the MG line + # formatter would mangle; it is compiled with -ffixed-line-length-132. + open(warmup_path, 'w').write(open(warmup_tmpl).read() % rd) + + gauge = 'U' + try: + if self.proc_characteristic['gauge']: + gauge = self.proc_characteristic['gauge'] + except Exception: + pass + + # Release the (empty) matrix.f handle the caller opened before overwriting. + try: + writer.close() + except Exception: + pass + + # How to split the recycled helas block out of MATRIX (no-op for a + # block below the threshold); shared by both rewriter passes. + chunk = self._hel_recycling_chunk_spec(rd, out_path) + + # First pass: keep every helicity combination (compute-all, exact). + ncomb = matrix_element.get_helicity_combinations() + good_hels = [str(i) for i in range(1, ncomb + 1)] + self._run_hel_recycle(orig_path, driver_path, out_path, + good_hels, [], [], gauge, chunk=chunk) + + # Register for the finalize() warm-up + re-optimization pass. + if not hasattr(self, '_hr_warmup'): + self._hr_warmup = [] + self._hr_warmup.append({'dirpath': dirpath, 'orig_path': orig_path, + 'driver_path': driver_path, 'out_path': out_path, + 'ncomb': ncomb, 'gauge': gauge, 'chunk': chunk, + 'ngraphs': matrix_element.get_number_of_amplitudes()}) + + @staticmethod + def _parse_hel_warmup(stdout): + """Parse the hel_warmup stdout into (good_hels, bad_amps, + bad_amps_perhel, csym_pairs) using the same rules as gen_ximprove.py.""" + all_hel = set() + all_zamp = set() + all_zampperhel = set() + all_csym = set() + for line in stdout.splitlines(): + if "=" not in line and ":" not in line: + continue + if 'Matrix Element/Good Helicity:' in line: + all_hel.add(tuple(line.split()[3:5])) + if 'CSYM PAIR:' in line: + # (me_index, representative_hel, dropped_partner_hel) + all_csym.add(tuple(line.split()[2:5])) + if 'Amplitude/ZEROAMP:' in line: + all_zamp.add(tuple(line.split()[1:3])) + if 'HEL/ZEROAMP:' in line: + nb_mat, nb_hel, nb_amp = line.split()[1:4] + if (nb_mat, nb_hel) not in all_hel: + continue + if (nb_mat, nb_amp) in all_zamp: + continue + all_zampperhel.add(tuple(line.split()[1:4])) + good_hels = [str(x) for x in sorted(int(h) for _, h in all_hel)] + bad_amps = [str(x) for x in sorted(int(a) for _, a in all_zamp)] + bad_amps_perhel = sorted((int(h), int(a)) for _, h, a in all_zampperhel) + csym_pairs = sorted((int(rep), int(flip)) for _, rep, flip in all_csym) + return good_hels, bad_amps, bad_amps_perhel, csym_pairs + + def _run_hel_recycling_warmups(self, fortran_compiler=None): + """After the Source libraries are built, compile + run each hel_warmup.f + probe and re-run the DAG rewriter with the measured good-helicity / + zero-amplitude lists, so the final matrix.f only computes the helicities + that contribute (the same information madevent gathers at run time). + + On any failure the compute-all matrix.f written at generation time is + left in place, so the output stays valid + correct regardless.""" + warmups = getattr(self, '_hr_warmup', None) + if not warmups: + return + fc = fortran_compiler or 'gfortran' + source = pjoin(self.dir_path, 'Source') + libdir = pjoin(self.dir_path, 'lib') + inc = ['-I%s' % pjoin(source, 'DHELAS'), '-I%s' % pjoin(source, 'MODEL')] + + # Make sure the two libraries the probe links against are present. The + # default Source make target is model-dependent and does not always build + # them, so build them explicitly here (idempotent). + for lib in ('libdhelas.a', 'libmodel.a'): + if not os.path.exists(pjoin(libdir, lib)): + try: + misc.compile(arg=[pjoin('..', 'lib', lib)], cwd=source, + mode='fortran') + except Exception: + pass + for info in warmups: + dirpath = info['dirpath'] + exe = pjoin(dirpath, 'hel_warmup') + cmd = [fc, '-w', '-fPIC', '-ffixed-line-length-132'] + inc + \ + ['-I%s' % dirpath, '-o', exe, + pjoin(dirpath, 'matrix_orig.f'), pjoin(dirpath, 'hel_warmup.f'), + '-L%s' % libdir, '-ldhelas', '-lmodel'] + try: + p = subprocess.run(cmd, cwd=dirpath, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + if p.returncode != 0: + logger.warning('hel_recycling warm-up compile failed in %s; ' + 'keeping the compute-all matrix.f.\n%s', dirpath, + p.stdout.decode(errors='replace')[-1500:]) + continue + r = subprocess.run([exe], cwd=dirpath, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + stdout = r.stdout.decode(errors='replace') + if r.returncode != 0: + logger.warning('hel_recycling warm-up run failed in %s; ' + 'keeping the compute-all matrix.f.', dirpath) + continue + except Exception as err: + logger.warning('hel_recycling warm-up error in %s (%s); keeping ' + 'the compute-all matrix.f.', dirpath, err) + continue + + good_hels, bad_amps, bad_amps_perhel, csym_pairs = \ + self._parse_hel_warmup(stdout) + if not good_hels: + continue # nothing measured -> keep the compute-all version + + bad_amps_perhel, csym_reuse, csym_dead = self._hel_recycling_csym( + csym_pairs, good_hels, bad_amps_perhel, info['ngraphs']) + + self._run_hel_recycle(info['orig_path'], info['driver_path'], + info['out_path'], good_hels, bad_amps, + bad_amps_perhel, info['gauge'], + csym_reuse=csym_reuse, csym_dead=csym_dead, + chunk=info.get('chunk')) + logger.info('hel_recycling: %s/%s good helicities, %s dead amplitudes' + ', %s C-parity pairs reused in %s', len(good_hels), + info['ncomb'], len(bad_amps), + len(csym_reuse.splitlines()) if csym_reuse else 0, + os.path.basename(dirpath)) + # tidy up the probe binary + intermediate objects. + for f in ('hel_warmup', 'matrix_orig.o', 'hel_warmup.o'): + try: + os.remove(pjoin(dirpath, f)) + except OSError: + pass + + #=========================================================================== + # write_check_sa #=========================================================================== + def _recorded_crossing_matches(self, matrix_element): + """(matches, complete): the reachable crossing each RECORDED crossed + subprocess of this matrix element corresponds to. + + Crossing records (merge_crossing='record') say which crossed processes + are real subprocesses of the generation; the runtime crossing space + (GET_PDG_FOR_FLAVOR / its python twin compute_crossing_pdg_entries) is a + dense enumeration of CROSS codes that also contains mathematically + applicable but unrequested crossings -- e.g. a Z pulled into the initial + state for p p > z j. Consumers that must not evaluate the latter (the + check_sa demo, the reweight's folded-crossing lookup) intersect the two + here. + + `matches` is a list of ``(pdg_signature, cross)`` in the recorded order, + matched LABEL-AWARE: a recorded process may carry merged multiparticle + labels (_quark = 81) and so may the reachable signature (a leg that does + not vary with the flavor index keeps its label), so a label matches any + member flavor of the same sign, and two labels match when equal. That is + also why a recorded process is matched as a whole rather than leg by leg: + the reachable set already encodes the correct flavor pairings, which + resolving each merged leg on its own would not (it would fabricate e.g. a + W coupling two same-flavor quarks). Both beam orientations are tried. + `complete` is False when a recorded process has NO reachable + instantiation, so a caller can fall back rather than hide a real + crossing.""" + crossed = matrix_element.get('crossed_processes') \ + if 'crossed_processes' in matrix_element else None + if not crossed: + return [], True + model = matrix_element.get('processes')[0].get('model') + merged = model.get('merged_particles') + + def leg_matches(leg_id, pdg): + # Does the reachable PDG instantiate this recorded leg id? Equal ids + # (two concrete particles, or two identical merged labels) always + # match; otherwise one of the two may be a merged label covering the + # other flavor, with the same sign. + if leg_id == pdg: + return True + a, b = abs(leg_id), abs(pdg) + if (leg_id > 0) != (pdg > 0): + return False + return (a in merged and b in merged[a]) or \ + (b in merged and a in merged[b]) + + ninitial = matrix_element.get_nexternal_ninitial()[1] + # signatures the runtime can actually reach (applicable crossings) + reachable = [(tuple(pdg), cross) for (_i, cross, _f, pdg) in + self.compute_crossing_pdg_entries(matrix_element)] + # A decay-chain base records its crossings at the PRODUCTION level, but + # the reachable signatures span the decay leaves (the ME's NEXTERNAL), so + # the recorded process must be expanded before it can match. The decays + # never cross (they ride along on their production leg), so re-attaching + # the base's decay chains and expanding gives the crossed leaf signature. + base_decays = matrix_element.get('processes')[0].get('decay_chains') + + def crossed_leg_ids(proc): + if not base_decays: + return [l.get('id') for l in proc.get('legs')] + expanded = copy.copy(proc) + expanded.set('decay_chains', base_decays) + expanded.set('legs_with_decays', base_objects.LegList()) + return [l.get('id') for l in expanded.get_legs_with_decays()] + + matches, complete = [], True + for (proc, _bp, _xp) in crossed: + legs = crossed_leg_ids(proc) + orients = [legs] + if ninitial == 2: # try the beam-swapped orientation + orients.append([legs[1], legs[0]] + legs[2:]) + hit = None + for orient in orients: + for (r, cross) in reachable: + if len(r) == len(orient) and \ + all(leg_matches(L, P) for L, P in zip(orient, r)): + hit = (r, cross) + break + if hit is not None: + break + if hit is None: + complete = False + continue + if hit[1] == 0: + # The identity: a recorded process that is the base's own beam + # swap (mirror), not a crossing. Consumers show/reach the base + # through its own PDG entry, so drop it. + continue + matches.append(hit) + return matches, complete + + def recorded_crossing_codes(self, matrix_element): + """(cross codes, complete) of the crossed subprocesses folded into this + matrix element: the CROSS half of every extended FLAV_IDX that names a + crossing this generation actually requested. + + This is what a python consumer needs to walk the folded crossings + soundly: it can enumerate GET_PDG_FOR_FLAVOR over + ``cross*NFLAV + flav`` (getting the exact per-flavor signature, which + the flavor index and not the code determines) while skipping the codes + that are merely applicable. See _recorded_crossing_matches.""" + matches, complete = self._recorded_crossing_matches(matrix_element) + return sorted(set(cross for (_sig, cross) in matches)), complete + + def _crossed_signatures(self, matrix_element): + """(signatures, complete) for the crossed subprocesses folded into this + matrix element (merge_crossing='record'), so check_sa can demo exactly + the crossings that are real subprocesses of the generation -- not every + mathematically valid crossing of the base. + + Each signature is a representative signed-PDG tuple in the crossed leg + order, matched at RUNTIME against GET_PDG_FOR_FLAVOR. Matching on the PDG + rather than the extended index avoids the NFLAV-convention gap between + the crossing-PDG enumeration and the runtime flavor table. Mirror pairs + are collapsed (the chosen signature's beam swap is also marked seen). + See _recorded_crossing_matches for the matching itself.""" + matches, complete = self._recorded_crossing_matches(matrix_element) + ninitial = matrix_element.get_nexternal_ninitial()[1] + sigs, seen = [], set() + for (hit, _cross) in matches: + mirror = (hit[1], hit[0]) + hit[2:] if ninitial == 2 else hit + if hit in seen or mirror in seen: + continue # mirror partner already taken + sigs.append(hit) + seen.add(hit) + seen.add(mirror) + return sigs, complete + + def _get_check_sa_crossing_example(self, matrix_element, proc_prefix): + """Fortran block for check_sa.f demonstrating the crossed matrix elements. + + Returns '' when crossing is not active for this matrix element (flag + off, or an s-channel constraint disables it) AND when no crossed + subprocess was folded into it, so the driver is unchanged and no dead + block is produced. Otherwise it scans every crossing of the base -- FLIP1 and + FLIP2 each range over 1..NEXTERNAL, choosing which two legs sit in the + initial slots -- and, for each, evaluates the crossed matrix element and + prints the momenta actually used next to their signed PDGs. + + Only the crossings that are REAL subprocesses of the generation (folded + in via merge_crossing='record') are shown, not every mathematically + valid crossing: their representative signed-PDG signatures are loaded + into XCSIG (from _crossed_signatures) and each enumerated crossing is + kept only if GET_PDG_FOR_FLAVOR matches an XCSIG row. When a folded + crossing has no reachable signature (e.g. a flavor-changing W), the + signatures are 'incomplete' and the block falls back to showing every + applicable crossing (non-zero PDG, minus the FLIP1=1,FLIP2=2 identity). + + The crossing code is CROSS = FLIP1*(NEXTERNAL+1) + FLIP2, matching + GET_CROSS_PERM's decode (i_part = CROSS/(NEXTERNAL+1), + j_part = CROSS mod (NEXTERNAL+1)); FLAV_IDX = CROSS*NFLAV + flav, with + NFLAV emitted as the literal matrix.f value so the encoding matches + exactly. Degenerate crossings (e.g. FLIP1==FLIP2) decode to all-zero + PDGs and are skipped by both the match and the fallback. + """ + use_crossing = self.opt.get('use_crossing', True) and \ + not any(self.breaks_crossing_symmetry(proc) + for proc in matrix_element.get('processes')) + if not use_crossing: + return '' + # GET_PDG_FOR_FLAVOR is what turns a FLAV_IDX back into a process, and + # only the default template has a hole for it -- the split-orders and + # matchbox variants carry no crossing machinery at all. Emitting the + # block against those leaves the driver unlinkable (or, when the block + # happens to be gated off, relying on the compiler to drop a call to a + # symbol that does not exist). + if not self.matrix_template_provides(matrix_element, + '%(flavor_pdg_function)s'): + return '' + + # Gate the demo on the generated DATA, not on the flag. The block is + # only ever worth running for the crossings that were actually FOLDED + # into this matrix element (merge_crossing='record'): those partonic + # contributions have no directory of their own, so this driver is the + # only place they are exercised. With nothing folded in, the block used + # to be emitted anyway behind IF(.FALSE.) -- dead fortran that still + # costs a full _build_flav_table_flat (i.e. a compute_flavor_masks pass + # over every wavefunction of the matrix element) to produce, which is + # the whole crossing cost of an output like g g > t t~ 4 g. + # + # This drops no crossing: matrix.f keeps the complete machinery, so + # every crossing the module can be ASKED for stays callable through + # SMATRIX / GET_PDG_FOR_FLAVOR exactly as before. Only the printout + # that was already switched off disappears. + crossed = matrix_element.get('crossed_processes') \ + if 'crossed_processes' in matrix_element else None + if not crossed: + return '' + + # NFLAV as matrix.f computes it, so CROSS*NFLAV+flav decodes correctly. + # It is assigned to a local NFLAV here so the loop body reads generically + # (FLAV_IDX = I*NFLAV+J) instead of a bare literal. + n_table, _ = self._build_flav_table_flat(matrix_element) + sigs, complete = self._crossed_signatures(matrix_element) + + sep = (' write (*,*) "-------------------------------------' + '----------------------------------------"') + + # For the FLAV_IDX already set: print the crossed process -- its per-leg + # PDG next to the momenta used to evaluate it. Every crossing shown here + # keeps the massive particles final and only relabels the massless + # partons, so its mass pattern is P's slot for slot; a standalone + # (non-crossed) run of that subprocess would draw the very same RAMBO + # point (identical hard-coded seed, sqrt(s) and per-slot masses). So the + # base P IS that point, printed row k = P(:,k) with the crossed PDG + # XPDG(k) -- copy/paste-comparable with the subprocess's own check. + # XPDG is already set for this FLAV_IDX by the loop body above. + demo_one = [ + ' CALL %sSMATRIX(P, FLAV_IDX, MATELEM)' % proc_prefix, + " write (*,*) 'FLAV_IDX', FLAV_IDX", + " write (*,*) ' PDG E px" + " py pz'", + ' DO XCK=1,NEXTERNAL', + " write (*,'(1X,I6,4(1X,E15.7))') XPDG(XCK),", + ' & P(0,XCK), P(1,XCK), P(2,XCK), P(3,XCK)', + ' ENDDO', + ' write (*,*) "Matrix element = ", MATELEM,' + ' " GeV^",-(2*nexternal-8)', + sep, + ] + + lines = [ + ' if(.true.) then', + ' write (*,*)', + ' write (*,*) " Crossed processes (folded into this matrix' + ' element):"', + ' write (*,*)', + ' NFLAV = %d' % n_table, + ] + if sigs and complete: + # Load the signed-PDG signatures of the folded crossings, then show + # only the crossings whose runtime PDG matches one of them (the real + # subprocesses of this generation, not every valid crossing). + lines.append(' XCNSIG = %d' % len(sigs)) + for s, sig in enumerate(sigs, 1): + for k, pid in enumerate(sig, 1): + lines.append(' XCSIG(%d,%d) = %d' % (k, s, pid)) + match_cond = 'XCMATCH' + else: + # A folded crossing could not be matched to a runtime PDG (e.g. a + # flavor-changing W subprocess): fall back to every crossing that is + # applicable here (all-zero PDG = not applicable, skipped), so no real + # subprocess is hidden. + lines.append(' XCNSIG = 0') + match_cond = 'XCVALID' + lines += [ + 'C FLIP1/FLIP2 pick which legs sit in the two initial slots;', + 'C 1..NEXTERNAL spans every crossing (FLIP1=1,FLIP2=2 = base).', + ' DO FLIP1=1,NEXTERNAL', + ' DO FLIP2=1,NEXTERNAL', + ' DO J=1,NFLAV', + ' I = FLIP1*(NEXTERNAL+1) + FLIP2', + ' FLAV_IDX = I*NFLAV+J', + ' CALL %sGET_PDG_FOR_FLAVOR(FLAV_IDX, XPDG)' % proc_prefix, + ] + if sigs and complete: + lines += [ + 'C Keep this crossing only if its PDG matches a folded', + 'C subprocess signature.', + ' XCMATCH = .FALSE.', + ' DO XCS=1,XCNSIG', + ' XCVALID = .TRUE.', + ' DO XCK=1,NEXTERNAL', + ' IF (XPDG(XCK).NE.XCSIG(XCK,XCS))' + ' XCVALID = .FALSE.', + ' ENDDO', + ' IF (XCVALID) XCMATCH = .TRUE.', + ' ENDDO', + ] + else: + lines += [ + 'C Applicable here iff its PDG signature is not all-zero,', + 'C skipping the identity (base process, shown above).', + ' XCVALID = .FALSE.', + ' DO XCK=1,NEXTERNAL', + ' IF (XPDG(XCK).NE.0) XCVALID = .TRUE.', + ' ENDDO', + ' IF (FLIP1.EQ.1 .AND. FLIP2.EQ.2) XCVALID = .FALSE.', + ] + lines.append(' IF (.NOT.%s) CYCLE' % match_cond) + lines.extend(demo_one) + lines += [' ENDDO', ' ENDDO', ' ENDDO', ' endif'] + return '\n'.join(lines) + def write_check_sa(self, writer, matrix_element, proc_prefix=''): if self.format != 'standalone': @@ -4415,7 +8925,26 @@ def write_check_sa(self, writer, matrix_element, proc_prefix=''): 'dens_pos': 'if(nincoming.eq.2) then \n POS(1) = 3 \n else \n POS(1) =1 \n endif', 'dens_allow_hel': 'ALLOW_HEL(1) = +1 \n ALLOW_HEL(2) = -1'} - if 'density' in self.cmd_options: + # GET_DENSITY only exists in the templates that write one. Where it does + # not, the driver still compiles get_density_matrix (it is a routine of + # this file, not of matrix.f), so the call has to go -- an undefined + # symbol there is enough to stop the whole driver from linking. + has_density = self.matrix_template_provides(matrix_element, + 'GET_DENSITY') + if has_density: + replace_dict['density_call'] = ( + ' call %sGET_DENSITY(P, POS, N_CHANGING, ALLOW_HEL,' + ' N_COMB, FLAVOR, 0d0, 0d0, INTER)' % proc_prefix) + else: + replace_dict['density_call'] = ( + " WRITE(*,*) 'no density matrix in this output'\n" + ' INTER = (0d0, 0d0)') + + if 'density' in self.cmd_options and not has_density: + logger.warning('--density is not available for the %s output: its ' + 'matrix element has no GET_DENSITY entry point.', + self.opt.get('export_format', 'current')) + elif 'density' in self.cmd_options: replace_dict['use_density'] = '.true.' changing = [int(i) for i in self.cmd_options['density'].split(',')] replace_dict['dens_nchanging'] = len(changing) @@ -4470,6 +8999,14 @@ def write_check_sa(self, writer, matrix_element, proc_prefix=''): replace_dict['maxflavor'] = maxflavor replace_dict['flavor_def'] = '\n '.join(flavor_text) + # Crossing-symmetry demonstration: when crossing is active for this + # matrix element, evaluate one genuinely crossed process (the first + # valid non-identity crossing) at the same phase-space point and print + # its per-leg PDG (via GET_PDG_FOR_FLAVOR) and matrix element, so that + # `make check` visibly exercises the crossing machinery. + replace_dict['crossing_example'] = \ + self._get_check_sa_crossing_example(matrix_element, proc_prefix) + fsock = open(pjoin(self.mgme_dir, 'madgraph', 'iolibs', 'template_files', 'check_sa.f'), 'r') text = fsock.read() fsock.close() @@ -4516,14 +9053,49 @@ class ProcessExporterFortranMatchBox(ProcessExporterFortranSA): default_opt = {'clean': False, 'complex_mass':False, 'export_format':'matchbox', 'mp': False, - 'sa_symmetry': True} + 'sa_symmetry': True, + # dropped when this dict was written out in full rather + # than derived from the mother's; without it the class + # cannot even be constructed without explicit options + 'output_options':{}} #specific template of the born matrix_template = "matrix_standalone_matchbox.inc" + # matchbox needs the color flow information + support_ddm_color_basis = False - @staticmethod + + # Inherits from the standalone exporter but writes its own template, which + # has no crossing machinery: the capability does not carry over. + supports_crossing = False + + # The matchbox templates carry neither the f2py entry points (GET_value, + # IS_BORN_HEL_SELECTED) nor the density stack the generated wrapper calls, + # and Herwig links the Fortran directly, so no python interface is written. + write_f2py_interface = False + + def get_proc_prefix(self, matrix_element, default=''): + """Matchbox names every routine after the process id, ignoring the + --prefix the caller may have passed; write_matrix_element_v4 does the + same, and the drivers must agree with it. madloop_matchbox is exempt: + it supplies its own prefix from the MadLoop rep_dict.""" + + if self.opt['export_format'] != 'matchbox': + return default + return 'MG5_%i_' % matrix_element.get('processes')[0].get('id') + + def color_data_prefix(self, replace_dict): + """CF and DENOM are plain locals of each routine in the matchbox + templates rather than one prefixed set per subprocess, so the DATA + statements filling them must not carry the prefix either. Only + madloop_matchbox sees the difference: plain matchbox reaches here + before its own proc_prefix is set.""" + + return '' + + @staticmethod def get_color_string_lines(matrix_element): """Return the color matrix definition lines for this matrix element. Split rows in chunks of size n.""" @@ -4588,10 +9160,17 @@ def finalize(self, matrix_elements, history, mg5options, flaglist, second_export def get_JAMP_lines(self, col_amps, JAMP_format="JAMP(%s)", AMP_format="AMP(%s)", split=-1, - JAMP_formatLC=None): - - """Adding leading color part of the colorflow""" - + JAMP_formatLC=None, orbit=False, proc_prefix='', + symmetry_source=None): + + """Adding leading color part of the colorflow. The leading color part + needs the definitions written out, so the orbit recipes are not used + here: orbit and the symmetry_source it reads the color basis symmetry + from are taken to keep the signature of the base class and go no + further (jamp_orbit_allowed already says no for this template, which + declares neither the tables nor INIT_JAMP). proc_prefix does reach the + base, since it names what is written out rather than how.""" + if not JAMP_formatLC: JAMP_formatLC= "LN%s" % JAMP_format @@ -4609,7 +9188,8 @@ def get_JAMP_lines(self, col_amps, JAMP_format="JAMP(%s)", AMP_format="AMP(%s)", text, nb = super(ProcessExporterFortranMatchBox, self).get_JAMP_lines(col_amps, JAMP_format=JAMP_format, AMP_format=AMP_format, - split=-1) + split=-1, + proc_prefix=proc_prefix) # Filter the col_ampls to generate only those without any 1/NC terms @@ -4625,7 +9205,8 @@ def get_JAMP_lines(self, col_amps, JAMP_format="JAMP(%s)", AMP_format="AMP(%s)", text2, nb2 = super(ProcessExporterFortranMatchBox, self).get_JAMP_lines(LC_col_amps, JAMP_format=JAMP_formatLC, AMP_format=AMP_format, - split=-1) + split=-1, + proc_prefix=proc_prefix) text += text2 return text, max(nb,nb2) @@ -5245,6 +9826,26 @@ def write_auto_dsig_file(self, writer, matrix_element, proc_id = ""): replace_dict['proc_id'] = proc_id replace_dict['numproc'] = 1 + # Flavor lookup + SMATRIX call default to this subprocess's own matrix + # element; a cross-group dependent (Track B) overrides them below to route + # to a base group's symlinked crossing-aware SMATRIX. + replace_dict['dsig_xg_decl'] = '' + replace_dict['dsig_xg_decl_vec'] = '' + replace_dict['dsig_xg_decl_multi'] = '' + replace_dict['dsig_xg_helper'] = '' + replace_dict['dsig_getflavor'] = \ + ' CALL GET_FLAVOR%s(IFLAV, FLAVOR)' % proc_id + replace_dict['dsig_smatrix_call'] = ( + ' CALL SMATRIX%s(P1, IFLAV, RHEL, RCOL,channel,1, DSIGUU,' + ' selected_hel(1), selected_col(1))' % proc_id) + # ... and the same for the vectorised (SMATRIX_MULTI) path. + replace_dict['dsig_getflavor_vec'] = \ + ' CALL GET_FLAVOR%s(IFLAV_VEC(IVEC), FLAVOR)' % proc_id + replace_dict['dsig_smatrix_vec_name'] = 'SMATRIX%s' % proc_id + replace_dict['dsig_smatrix_vec_flav'] = 'IFLAV_VEC(IVEC)' + replace_dict['dsig_smatrix_vec_chan'] = 'channels(IVEC)' + replace_dict['dsig_smatrix_vec_post'] = '' + # Set dsig_line if ninitial == 1: # No conversion, since result of decay should be given in GeV @@ -5473,8 +10074,16 @@ class ProcessExporterFortranME(ProcessExporterFortran): MadEvent format.""" matrix_file = "matrix_madevent_v4.inc" + jamp_fold = True + jamp_orbit = True + # AMP is indexed by helicity once the matrix element is rewritten for + # helicity recycling, so the definitions cannot sit at the end of it + jamp_gather = True done_warning_tchannel = False - + # set as soon as one matrix element is written with the batched color + # sum, so that only then is the library linked in + blas_used = False + default_opt = {'clean': False, 'complex_mass':False, 'export_format':'madevent', 'mp': False, 'v5_model': True, @@ -5520,6 +10129,95 @@ def __init__(self, dir_path = "", opt=None): else: self.opt['nb_warp'] = 1 + if opt and isinstance(opt['output_options'], dict) and \ + 'amp_chunk_size' in opt['output_options']: + self.opt['amp_chunk_size'] = banner_mod.ConfigFile.format_variable( + opt['output_options']['amp_chunk_size'], int, 'amp_chunk_size') + else: + self.opt['amp_chunk_size'] = AMP_CHUNK_SIZE_DEFAULT + + def write_amp_chunk_files(self, replace_dict, proc_id): + """Move the HELAS call sequence of matrix_orig.f out of + MATRIX and into matrix_origamp.f, one subroutine + per amp_chunk_size statements, and leave the calls to them behind. + + Returns the number of chunk files written (0 when the sequence is short + enough to stay inline, which leaves replace_dict untouched and the + generated file byte-identical to the unchunked output). + """ + + chunk_size = self.opt.get('amp_chunk_size', AMP_CHUNK_SIZE_DEFAULT) + calls = replace_dict['helas_calls'].split('\n') + # a re-output into the same directory (output -noclean) with a bigger + # chunk size, or none, must not leave live orphans behind: the makefile + # globs these and would compile and link whatever it finds + for stale in glob.glob('matrix%s_origamp*.f' % proc_id): + os.remove(stale) + if chunk_size <= 0 or len(calls) <= chunk_size: + return 0 + + chunks = chunk_fortran_statements(calls, chunk_size, fixed_form=False) + if len(chunks) < 2: + return 0 + + self.set_amp_chunk_replace_keys(replace_dict) + template = open(pjoin(_file_path, + 'iolibs/template_files/matrix_madevent_ampchunk_v4.inc')).read() + args = ('P,NHEL,IC,IVEC,FLAVOR,W,AMP%s' % + replace_dict['amp_chunk_mask_arg']) + driver = ['C The HELAS call sequence lives in matrix%s_origamp.f, one' + % proc_id, + 'C subroutine per %d statements, so that the amplitudes can be' + % chunk_size, + 'C compiled apart from the JAMP and colour blocks below.'] + for i, chunk in enumerate(chunks): + chunk_dict = dict(replace_dict) + chunk_dict['chunk_id'] = str(i + 1) + chunk_dict['helas_calls'] = '\n'.join(chunk) + writer = writers.FortranWriter( + 'matrix%s_origamp%d.f' % (proc_id, i + 1)) + writer.writelines(misc.apply_template(template, chunk_dict)) + driver.append('CALL ORIGAMP%s_%d(%s)' % (proc_id, i + 1, args)) + replace_dict['helas_calls'] = '\n'.join(driver) + return len(chunks) + + def write_amp_chunk_template(self, replace_dict, ime): + """Write template_matrix_ampchunk.f, the per-chunk counterpart of + template_matrix.f: hel_recycle renders it once per slice of the + unrolled call sequence into matrix_optimamp.f. Skipped when the + chunk size is 0, in which case hel_recycle keeps the sequence inline.""" + + if self.opt.get('amp_chunk_size', AMP_CHUNK_SIZE_DEFAULT) <= 0: + return + self.set_amp_chunk_replace_keys(replace_dict) + tfile = open(pjoin(_file_path, + 'iolibs/template_files/matrix_madevent_ampchunk_v4_hel.inc')).read() + writer = writers.FortranWriter('template_matrix%d_ampchunk.f' % ime) + writer.uniformcase = False + writer.writelines(misc.apply_template(tfile, replace_dict)) + + def set_amp_chunk_replace_keys(self, replace_dict): + """Fill the replace_dict holes that only the amplitude-chunk files use: + the flavor-mask arrays they have to be handed. Their DATA tables stay + in the matrix element, so the dummies are declared assumed-size. + + Nothing the matrix element itself writes is touched here -- the chunks + recompute the fake widths from coupl.inc instead of reading them out of + the matrix element's SAVEd locals -- so a process whose call sequence is + short enough to stay inline comes out byte-identical.""" + + if replace_dict.get('flavor_mask_decl'): + replace_dict['amp_chunk_mask_arg'] = \ + ',CURRENT_WF_MASK,CURRENT_AMP_MASK' + replace_dict['amp_chunk_mask_decl'] = ( + 'C Flavor masks of the calling matrix element; the DATA\n' + 'C tables they are copied from stay there.\n' + ' INTEGER*8 CURRENT_WF_MASK(*)\n' + ' INTEGER*8 CURRENT_AMP_MASK(*)') + else: + replace_dict['amp_chunk_mask_arg'] = '' + replace_dict['amp_chunk_mask_decl'] = '' + # helper function for customise helas writter @staticmethod def custom_helas_call(call, arg): @@ -5773,6 +10471,10 @@ def generate_subprocess_directory(self, matrix_element, self.write_leshouche_file(writers.FortranWriter(filename), matrix_element) + filename = pjoin(Ppath, 'colorflow.inc') + self.write_colorflow_file(writers.FortranWriter(filename), + matrix_element) + filename = pjoin(Ppath, 'maxamps.inc') nb_flavor_per_proc = matrix_element.get_nb_flavors() # Compute actual MAXPROC: for merged processes each flavor combination @@ -5933,6 +10635,16 @@ def finalize(self, matrix_elements, history, mg5options, flaglist, second_export 'cpp': mg5options['cpp_compiler'], 'f2py': mg5options['f2py_compiler']} + # a matrix element written with the batched color sum needs the + # library it calls into on the link line + if self.blas_used: + makefile = pjoin(self.dir_path, 'SubProcesses', 'makefile') + if os.path.exists(makefile): + text = open(makefile).read() + text = text.replace('BLASLIBS =', + 'BLASLIBS = %s' % self.blas_link_flags()) + open(makefile, 'w').write(text) + # indicate that the output type is not grouped if not isinstance(self, ProcessExporterFortranMEGroup): self.proc_characteristic['grouped_matrix'] = False @@ -6057,11 +10769,273 @@ def finalize(self, matrix_elements, history, mg5options, flaglist, second_export #return to the initial dir #os.chdir(old_pos) + #=========================================================================== + # BLAS-3 color sum + #=========================================================================== + @staticmethod + def get_blas_routine_me(prefix, proc_id, nfold, nampso, nsqampso, + ncomb, cf_dim, chosen_so): + """The color sum for a whole batch of helicities at once. + + The color matrix is the same for every helicity, so the helicities + are the columns of a single right hand side and the sum is two + DSYMM calls. DSYMM is real, so the two parts of JAMP go through + separately; the color matrix is real and symmetric, so that is all + it takes, and the two products add up term by term. + + With split orders JAMP carries a second index, and every (M,N) pair + the squared order mask keeps is one more column pairing. The mask is + symmetric, since SQSOINDEX adds the two amplitude orders, and that is + what lets the triangle the scalar sum walks be traded for the whole + symmetric matrix here.""" + + return """ + SUBROUTINE {p}GET_MATRIX_BATCH{i}(JR,JI,NB,ANSB) + IMPLICIT NONE + INTEGER NFOLD, NAMPSO, NSQAMPSO, NBMAX + PARAMETER (NFOLD={n}, NAMPSO={a}) + PARAMETER (NSQAMPSO={q}, NBMAX={c}) + INTEGER NB + DOUBLE PRECISION JR(NFOLD,NAMPSO,*), JI(NFOLD,NAMPSO,*) + DOUBLE PRECISION ANSB(*) + LOGICAL CHOSEN_SO_CONFIGS(NSQAMPSO) + DATA CHOSEN_SO_CONFIGS/{s}/ + SAVE CHOSEN_SO_CONFIGS + INTEGER I,J,K,M,N,CFI,NRHS + DOUBLE PRECISION S + DOUBLE PRECISION, ALLOCATABLE, SAVE :: CFULL(:,:) + DOUBLE PRECISION, ALLOCATABLE, SAVE :: TR(:,:), TI(:,:) + LOGICAL FIRST + DATA FIRST /.TRUE./ + SAVE FIRST + INTEGER CF({d}) + INTEGER DENOM + COMMON /{p}color_matrix{i}/ CF,DENOM + INTEGER SQSOINDEX{i} + IF (FIRST) THEN + CALL {p}INIT_CF{i}() + ALLOCATE(CFULL(NFOLD,NFOLD)) + ALLOCATE(TR(NFOLD,NAMPSO*NBMAX)) + ALLOCATE(TI(NFOLD,NAMPSO*NBMAX)) +C The triangle written out has its off diagonal doubled, since +C the scalar sum walks it once. BLAS wants the whole matrix, +C with every entry counted once. + CFI = 0 + DO I = 1, NFOLD + DO J = I, NFOLD + CFI = CFI + 1 + IF (I.EQ.J) THEN + CFULL(I,J) = DBLE(CF(CFI)) + ELSE + CFULL(I,J) = DBLE(CF(CFI))/2D0 + CFULL(J,I) = CFULL(I,J) + ENDIF + ENDDO + ENDDO + FIRST = .FALSE. + ENDIF + NRHS = NB*NAMPSO + CALL DSYMM('L','U',NFOLD,NRHS,1D0,CFULL,NFOLD,JR,NFOLD,0D0,TR,NFOLD) + CALL DSYMM('L','U',NFOLD,NRHS,1D0,CFULL,NFOLD,JI,NFOLD,0D0,TI,NFOLD) + DO K = 1, NB + S = 0D0 + DO M = 1, NAMPSO + DO N = 1, NAMPSO + IF (CHOSEN_SO_CONFIGS(SQSOINDEX{i}(M,N))) THEN + DO I = 1, NFOLD + S = S + TR(I,(K-1)*NAMPSO+M)*JR(I,N,K) + S = S + TI(I,(K-1)*NAMPSO+M)*JI(I,N,K) + ENDDO + ENDIF + ENDDO + ENDDO + ANSB(K) = S / DBLE(DENOM) + ENDDO + END +""".format(p=prefix, i=proc_id, n=nfold, a=nampso, q=nsqampso, c=ncomb, + d=cf_dim, s=chosen_so) + + # For every template where MATRIX is one helicity at a time: the call + # SMATRIX makes in its helicity loop, what selects the helicities worth + # computing, when the good helicities have settled, the arguments MATRIX + # takes on top of its own, and the dimension the file declares the color + # matrix with (the common block is laid out by it, so DENOM only lands + # where the batched routine looks for it if the two agree). + blas_me_shape = { + 'matrix_madevent_v4.inc': { + 'call': 'MATRIX%(proc_id)s(P,NHEL(1,I),IFLAV, IVEC)', + 'collect': 'MATRIX%(proc_id)s(P,NHEL(1,IBH),IFLAV,IVEC,' + 'JRB,JIB,BLASGATE,BLASNB)', + 'select': 'GOODHEL(IBH,IFLAV) .OR. NTRY(IFLAV) .LE. MAXTRIES' + '.OR.(ISUM_HEL.NE.0)', + 'settled': 'NTRY(IFLAV).GT.MAXTRIES', + 'cf_dim': 'NFOLD*(NFOLD+1)'}, + # The grouped template is the one crossing symmetry rewrites, so its + # call/gate are the crossing holes rather than literals; _at_ibh below + # re-points them at the sweep's own loop variable. + 'matrix_madevent_group_v4.inc': { + 'call': 'MATRIX%(proc_id)s(%(me_matrix_args)s)', + 'collect': 'MATRIX%(proc_id)s(%(me_matrix_args_ibh)s,JRB,JIB,' + 'BLASGATE,BLASNB)', + 'select': 'GOODHEL(%(me_goodhel_idx_ibh)s,%(me_flav_key)s,' + '%(proc_id)s) .OR. ' + 'NTRY(%(me_flav_key)s,%(proc_id)s).LE.MAXTRIES.or.' + '(ISUM_HEL.NE.0)%(me_goodhel_or_ibh)s', + 'settled': 'NTRY(%(me_flav_key)s,%(proc_id)s).GT.MAXTRIES', + 'cf_dim': 'NFOLD*(NFOLD+1)/2'}, + } + + # The helicity index inside the crossing holes is always the bare loop + # variable I; the BLAS pre-sweep runs its own loop over IBH, so the same + # holes have to be re-pointed at it. Whole-word only, which is exactly + # right for the strings fill_crossing_replace_dict_me writes: IC, IVEC, + # IFLAV, IHEL and AMP2/JAMP2 are all left alone. + _ibh_index = re.compile(r'\bI\b') + + @classmethod + def _at_ibh(cls, text): + """Same expression, evaluated at the sweep's IBH instead of at I.""" + return cls._ibh_index.sub('IBH', text) + + def set_blas_replace_dict(self, replace_dict, ncomb, nfold): + """Template replacements for the BLAS-3 color sum. + + Everything the batched path adds hangs off the end of a line that is + already there, so with BLAS off the generated file is character for + character the one written before any of this existed. + + Two shapes are covered. The helicity recycled matrix element already + walks every helicity inside one call, so there the batch is the loop + it is already running (the blas_hel_* keys). Everywhere else MATRIX is + one helicity at a time and SMATRIX is the one holding the loop, so the + columns are gathered there and the value each helicity ends up with is + read back out of the batch (the blas_* keys).""" + + keys = ['blas_hel_decl', 'blas_hel_setup', 'blas_hel_gather', + 'blas_hel_gate', 'blas_hel_finish', 'blas_hel_routine', + 'blas_decl', 'blas_arg', 'blas_gather', 'blas_gate', + 'blas_smatrix_decl', 'blas_branch', 'blas_matrix_args', + 'blas_routine'] + shape = self.blas_me_shape.get(self.matrix_file) + for key in keys: + replace_dict[key] = '' + # IBH-indexed twins of the crossing holes, for the pre-sweep loop + for key in ('me_matrix_args', 'me_goodhel_idx', + 'smatrix_me_goodhel_or'): + replace_dict[key.replace('smatrix_me_', 'me_') + '_ibh'] = \ + self._at_ibh(replace_dict.get(key, '')) + replace_dict['blas_matrix_call'] = \ + (shape['call'] % replace_dict) if shape else '' + + nampso = replace_dict['nAmpSplitOrders'] + if not self.blas_wanted(nfold): + return + self.blas_used = True + + prefix = replace_dict['proc_prefix'] + proc_id = replace_dict['proc_id'] + replace_dict['blas_hel_decl'] = "\n".join([ + "", + " DOUBLE PRECISION JRB(NCOLORFOLD,NAMPSO,NCOMB)", + " DOUBLE PRECISION JIB(NCOLORFOLD,NAMPSO,NCOMB)", + " SAVE JRB, JIB", + " INTEGER BLASGATE", + " LOGICAL BLAS_COLOR_SUM", + " COMMON/TO_BLAS_COLOR_SUM/BLAS_COLOR_SUM"]) + replace_dict['blas_hel_setup'] = "\n".join([ + "", + " BLASGATE = 1", + " IF (BLAS_COLOR_SUM) BLASGATE = 0"]) + replace_dict['blas_hel_gather'] = "\n".join([ + "", + " JRB(:,:,K) = DBLE(%s(:,:))" + % replace_dict['color_fold_array'], + " JIB(:,:,K) = DIMAG(%s(:,:))" + % replace_dict['color_fold_array']]) + # a zero trip count leaves the scalar sum out without changing a + # single block, which is what the helicity recycling rewriter walks + replace_dict['blas_hel_gate'] = "*BLASGATE" + replace_dict['blas_hel_finish'] = "\n".join([ + "", + " IF (BLASGATE.EQ.0) CALL %sGET_MATRIX_BATCH%s(JRB,JIB," + "NCOMB,TS)" % (prefix, proc_id)]) + replace_dict['blas_hel_routine'] = self.get_blas_routine_me( + prefix, proc_id, nfold, nampso, + replace_dict['nSqAmpSplitOrders'], ncomb, + 'NFOLD*(NFOLD+1)', replace_dict['chosen_so_configs']) + + if not shape or self.opt.get('hel_recycling'): + # with helicity recycling on, this file is only what the good + # helicities are found with, and what the rewriter reads: it stays + # scalar, and the batch lives in the recycled matrix element above + return + + replace_dict['blas_decl'] = "\n".join([ + "", + " DOUBLE PRECISION JRB(NCOLORFOLD,NAMPSO,%d)" % ncomb, + " DOUBLE PRECISION JIB(NCOLORFOLD,NAMPSO,%d)" % ncomb, + " INTEGER BLASGATE, BLASCOL"]) + replace_dict['blas_arg'] = ",JRB,JIB,BLASGATE,BLASCOL" + replace_dict['blas_gather'] = "\n".join([ + "", + " JRB(:,:,BLASCOL) = DBLE(%s(:,:))" + % replace_dict['color_fold_array'], + " JIB(:,:,BLASCOL) = DIMAG(%s(:,:))" + % replace_dict['color_fold_array']]) + # a zero trip count leaves the scalar sum out + replace_dict['blas_gate'] = "*BLASGATE" + replace_dict['blas_matrix_args'] = ",JRB,JIB,1,1" + replace_dict['blas_smatrix_decl'] = "\n".join([ + "", + " DOUBLE PRECISION JRB(%d,%d,%d)" % (nfold, nampso, ncomb), + " DOUBLE PRECISION JIB(%d,%d,%d)" % (nfold, nampso, ncomb), + " SAVE JRB, JIB", + " DOUBLE PRECISION BLASB(NCOMB), BLASP(NCOMB)", + # BLAS_COLOR_SUM itself comes with run.inc, which SMATRIX has + " INTEGER BLASIDX(NCOMB), BLASNB, BLASGATE, IBH"]) + # The sweep must skip whatever the loop reading BLASB back will skip, + # or it evaluates a MATRIX call per C-parity partner for a BLASB entry + # nothing ever reads. The loop keeps the doubling, so only the wasted + # work goes away here. + select = '.NOT.(DEDUP.AND.IBH.GT.FLIP(IBH)) .AND. (%s)' \ + % (shape['select'] % replace_dict) + # One sweep over the helicities worth computing fills BLASB, either + # helicity by helicity as before or, once the good helicities have + # settled, as one batch; the loop below then only reads it back, so + # nothing is computed twice and AMP2/JAMP2 still add up once. + replace_dict['blas_branch'] = "\n".join([ + " BLASGATE = 1", + " IF (BLAS_COLOR_SUM .AND. %s) BLASGATE = 0" + % (shape['settled'] + % replace_dict), + " BLASNB = 0", + " DO IBH = 1, NCOMB", + " IF (%s) THEN" % select, + " BLASNB = BLASNB + 1", + " BLASIDX(BLASNB) = IBH", + " BLASB(IBH) = %s" % (shape['collect'] % replace_dict), + " ENDIF", + " ENDDO", + " IF (BLASGATE.EQ.0 .AND. BLASNB.GT.0) THEN", + " CALL %sGET_MATRIX_BATCH%s(JRB,JIB,BLASNB,BLASP)" + % (prefix, proc_id), + " DO IBH = 1, BLASNB", + " BLASB(BLASIDX(IBH)) = BLASP(IBH)", + " ENDDO", + " ENDIF", + ""]) + replace_dict['blas_matrix_call'] = "BLASB(I)" + replace_dict['blas_routine'] = self.get_blas_routine_me( + prefix, proc_id, nfold, nampso, + replace_dict['nSqAmpSplitOrders'], ncomb, + shape['cf_dim'], replace_dict['chosen_so_configs']) + #=========================================================================== # write_matrix_element_v4 #=========================================================================== def write_matrix_element_v4(self, writer, matrix_element, fortran_model, - proc_id = "", config_map = [], subproc_number = ""): + proc_id = "", config_map = [], subproc_number = "", + xgrow_map = None): """Export a matrix element to a matrix.f file in MG4 madevent format""" if not matrix_element.get('processes') or \ @@ -6097,7 +11071,47 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, 'set_amp2_line': 'ANS=ANS*AMP2(MAPCONFIG(ICONFIG))/XTOT', 'flavor_mask_decl':'', 'flavor_mask_setup':''} - + + # Crossing colour selection: an ME that serves as a base for a crossed + # dependent publishes its per-flow JAMP2 (in its own flow order) so the + # dependent can reselect colour natively instead of relabelling the base's + # own selection -- which was masked with the BASE's ICOLAMP row and can + # name a flow the dependent's own SELECT_COLOR would never pick. Both + # crossing paths need it: the cross-group dependent (Track B, + # _dsig_crossgroup_fills) and the within-group router (Track A, + # write_matrix_router_file), each calling XG_SELCOL with its OWN IPROC. + # Emitted only for those bases -- every other madevent ME keeps both holes + # empty and is byte-identical. + if (id(matrix_element) in getattr(self, '_crossgroup_base_mes', set()) + or id(matrix_element) in getattr(self, '_router_base_mes', set())): + replace_dict['xg_jamp2_decl'] = ( + 'C Crossing base: publish this ME\'s per-flow JAMP2 so a' + '\nC crossed dependent can reselect colour in its own flow space.' + '\n DOUBLE PRECISION XG_JAMP2(0:MAXFLOW,VECSIZE_MEMMAX)' + '\n COMMON/TO_XG_JAMP2/XG_JAMP2') + replace_dict['xg_jamp2_pub'] = ( + ' DO I=0,INT(JAMP2(0))' + '\n XG_JAMP2(I,IVEC) = JAMP2(I)' + '\n ENDDO') + else: + replace_dict['xg_jamp2_decl'] = '' + replace_dict['xg_jamp2_pub'] = '' + + # Crossing holes of matrix_madevent_group_v4.inc: the group SMATRIX + # decodes the extended FLAV_IDX and evaluates the crossed process through + # a runtime IC. Only that template carries the holes (the single-process + # matrix_madevent_v4.inc does not), and a process whose definition pins a + # specific s-channel has its crossings generated separately, so it stays + # on the plain path. When off the fills reproduce the historical code. + me_use_crossing = ( + self.opt.get('use_crossing', False) + and self.matrix_file == 'matrix_madevent_group_v4.inc' + and not any(self.breaks_crossing_symmetry(proc) + for proc in matrix_element.get('processes'))) + self.fill_crossing_replace_dict_me(matrix_element, replace_dict, + me_use_crossing, proc_id, + xgrow_map=xgrow_map) + mask_decl, mask_setup, n_flavors, active_flavor_mask = \ self._get_flavor_mask_blocks(matrix_element) @@ -6107,12 +11121,17 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, fortran_model.use_flavor_mask = (n_flavors > 0) fortran_model.me_n_flavors = n_flavors fortran_model.me_active_flavor_mask = active_flavor_mask + # With crossing on, the external wavefunction NSF/NSV flag is multiplied + # by IC(i) so a leg crossed between the initial and final state flips + # (the crossed P/NHEL/IC are built by APPLY_CROSSING in SMATRIX). + fortran_model.use_crossing_ic = me_use_crossing try: helas_calls = fortran_model.get_matrix_element_calls(matrix_element) finally: fortran_model.use_flavor_mask = False fortran_model.me_n_flavors = 0 fortran_model.me_active_flavor_mask = None + fortran_model.use_crossing_ic = False if fortran_model.width_tchannel_set_tozero and not ProcessExporterFortranME.done_warning_tchannel: logger.info("Some T-channel width have been set to zero [new since 2.8.0]\n if you want to keep this width please set \"zerowidth_tchannel\" to False", '$MG:BOLD') ProcessExporterFortranME.done_warning_tchannel = True @@ -6202,10 +11221,21 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, # Extract ncolor ncolor = max(1, len(matrix_element.get('color_basis'))) replace_dict['ncolor'] = ncolor + # |M|^2 is summed over one color flow per reversal pair when the basis + # allows it. JAMP itself keeps every flow: jamp2 and the color flow + # selection below read all of them. + folding = self.get_jamp_folding(matrix_element) + replace_dict.update(self.get_color_fold_ampso(folding, ncolor)) # Extract color data lines color_data_lines = self.get_color_data_lines(matrix_element) replace_dict['color_data_lines'] = "\n".join(color_data_lines) % {'proc_prefix': replace_dict['proc_prefix']} + # A compressed color matrix is rebuilt at run time, into the common + # block the matrix element reads it from. + replace_dict['color_init_routine'] = "\n".join( + self.get_color_init_routine(matrix_element, + replace_dict['proc_prefix'], + suffix=str(replace_dict['proc_id']))) # Set the size of Wavefunction @@ -6252,11 +11282,28 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, # Extract JAMP lines # If no split_orders then artificiall add one entry called 'ALL_ORDERS' + self.jamp_recipes = None jamp_lines, nb_temp = self.get_JAMP_lines_split_order(\ matrix_element,amp_orders,split_order_names= - split_orders if len(split_orders)>0 else ['ALL_ORDERS']) + split_orders if len(split_orders)>0 else ['ALL_ORDERS'], + orbit=self.jamp_orbit_allowed(matrix_element)) replace_dict['jamp_lines'] = '\n'.join(jamp_lines) replace_dict['nb_temp_jamp'] = nb_temp + recipes = getattr(self, 'jamp_recipes', None) + replace_dict['jamp_decl'] = '\n'.join( + self.get_jamp_decl_lines(recipes, '')) + replace_dict['jamp_tmp_decl'] = '' if recipes else \ + " COMPLEX*16 TMP_JAMP(%i)" % nb_temp + + # The color sum can run on the (n-2)! DDM basis while the color flow + # probabilities keep using the (n-1)! trace one + ncolor = self.set_color_flow_lines(matrix_element, replace_dict, ncolor) + + # BLAS-3 color sum: the helicities are the columns of a single right + # hand side, so the whole sum is two DSYMM calls instead of one + # triangular loop per helicity. + self.set_blas_replace_dict(replace_dict, ncomb, + int(replace_dict['ncolorfold'])) if self.beam_polarization == [True, True]: replace_dict['beam_polarization'] = """ @@ -6350,12 +11397,535 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, replace_dict['return_value'] = (len([call for call in helas_calls if call.find('#') != 0]), ncolor) return replace_dict + #=========================================================================== + # _crossgroup_base_files + #=========================================================================== + def _crossgroup_base_files(self, base_proc_id): + """Base-group matrix-element source files a cross-group dependent symlinks + into its own P directory so the makefile compiles the shared crossing- + aware SMATRIX there too. Correctness-first: the source is reused (symlink) + but each directory still compiles its own object; sharing the compiled .o + is a later build step. With helicity recycling the base keeps + matrix_orig.f plus the template for the run-time optimised copy, + otherwise a single matrix.f.""" + if self.opt.get('hel_recycling'): + return ['matrix%d_orig.f' % base_proc_id, + 'template_matrix%d.f' % base_proc_id] + return ['matrix%d.f' % base_proc_id] + + def write_crossgroup_mk(self, base_dir, base_proc_id): + """Write crossgroup.mk in the current (dependent) P directory. Included by + the shared makefile (`-include crossgroup.mk`), it makes the base group's + matrix object file be SYMLINKED from the base directory rather than + recompiled from the symlinked source -- the whole point of the reuse. It is + built in the base directory first (the specific rule overrides the + makefile's %.o:%.f pattern; the recursive rule is the standalone ordering + fallback -- the top-level parallel makefile also orders base before + dependents). + + With helicity recycling BOTH matrix_orig.o (the full matrix element) and + matrix_optim.o are shared: gen_ximprove bakes the base optim over + G_base U tau(G_base) of the crossing class (see crossgroup_helunion.dat), + so it covers every member. Without recycling the single matrix.o is + the full, shareable object.""" + objs = ['matrix%d.o' % base_proc_id] + if self.opt.get('hel_recycling'): + objs = ['matrix%d_orig.o' % base_proc_id, + 'matrix%d_optim.o' % base_proc_id] + lines = ['# Track B cross-group crossing: reuse the base group\'s compiled', + '# matrix element (%s) instead of recompiling the symlinked source.' + % base_dir] + for o in objs: + base_o = pjoin('..', base_dir, o) + lines.append('%s: %s' % (o, base_o)) + lines.append('\tln -sf %s %s' % (base_o, o)) + lines.append('%s:' % base_o) + lines.append('\t+$(MAKE) -C %s %s' % (pjoin('..', base_dir), o)) + if self.opt.get('amp_chunk_size', AMP_CHUNK_SIZE_DEFAULT) > 0: + # ... and, when the base's HELAS call sequence was split out into + # amplitude files of its own, those objects too. They are globbed + # in the base directory at make time rather than listed here: the + # optim ones do not exist until gen_ximprove has recycled the base, + # which is well after this file is written. The pattern rules below + # override the shared makefile's %.o: %.f (it is included last), and + # the extra prerequisites get them built before either binary links. + base = pjoin('..', base_dir) + for kind in ('origamp', 'optimamp'): + var = 'XG_%s' % kind.upper() + lines.append('%s := $(notdir $(patsubst %%.f,%%.o,' + '$(wildcard %s/matrix%d_%s*.f)))' + % (var, base, base_proc_id, kind)) + lines.append('matrix%d_%s%%.o:' % (base_proc_id, kind)) + lines.append('\t+$(MAKE) -C %s $@' % base) + lines.append('\tln -sf %s/$@ $@' % base) + lines.append('MATRIX += $(XG_ORIGAMP) $(XG_OPTIMAMP)') + lines.append('MATRIX_HEL += $(XG_ORIGAMP)') + lines.append('madevent_forhel: $(XG_ORIGAMP)') + lines.append('madevent: $(XG_ORIGAMP) $(XG_OPTIMAMP)') + open('crossgroup.mk', 'w').write('\n'.join(lines) + '\n') + + def write_crossgroup_helunion(self, subproc_path): + """Write crossgroup_helunion.dat in each crossing BASE directory. Each + line is ` t1 t2 ... tNCOMB`, the base->base helicity SIGN + map tau of one dependent crossing (_crossgroup_base_helsignmap): the + recycled optim's row h contributes to that crossing iff tau[h] is good for + the base. gen_ximprove reads it and bakes the base optim over the union + G_base U tau(G_base) over every line, so a single compiled optim serves + every member of the class. An all-zero row is the sentinel for a crossing + whose tau is not a clean permutation: keep every config. + + tau and NOT the GHREMAP sigma (_crossed_helicity_configs, permuted=True). + sigma is the transform of matrix_orig.f, which takes NHEL at run time + and applies the crossing's slot permutation to it; the recycled + matrix_optim.f bakes its configs into the HELAS calls and gets only + (PUSE, IC), so the sign flips survive and the permutation does not. + Baking the sigma union + into the optim drops helicity rows the crossed caller needs -- measured + -28.5% on the q q~ > q q~ cross section, where the routed t-channel + subprocess got 2 of the 4 rows it needs. + + Both crossing flavours feed this: a Track B cross-group dependent (whose + base lives in another P directory) and a Track A within-group router + (whose base is a matrix element of the same directory). Either way the + recycled optim is entered with crossed momenta, and it bakes its helicity + configs -- so pruning it to the base's own good-hel biases the crossed + caller.""" + for base_dir, per_proc in self._crossgroup_helperms.items(): + lines = [] + for base_proc_id, perms in sorted(per_proc.items()): + for pi in perms: + lines.append('%d %s' % (base_proc_id, + ' '.join(str(x) for x in pi))) + if lines: + with open(pjoin(subproc_path, base_dir, + 'crossgroup_helunion.dat'), 'w') as f: + f.write('\n'.join(lines) + '\n') + + def write_crossgroup_parallel_makefile(self, subproc_path): + """Write SubProcesses/makefile_madevent so every P directory builds with a + single `make -f makefile_madevent -jN` (madevent binaries) or `... forhel`. + Cross-group dependents (Track B) are ordered AFTER their base directory so + the base's shared objects exist to be symlinked in; make's dependency graph + then gives both the ordering and full parallelism. Each target just + delegates to that directory's own makefile.""" + lines = [ + '# Generated (Track B): build every P directory in one parallel call:', + '# make -f makefile_madevent -j # the madevent binaries', + '# make -f makefile_madevent -j forhel # the madevent_forhel ones', + '# Cross-group dependents are ordered after their base directory.', + 'PDIRS := $(shell cat subproc.mg 2>/dev/null | tr -d " \\t")', + 'MADEVENT := $(addsuffix /madevent,$(PDIRS))', + 'FORHEL := $(addsuffix /madevent_forhel,$(PDIRS))', + '', + '.PHONY: all forhel $(MADEVENT) $(FORHEL)', + 'all: $(MADEVENT)', + 'forhel: $(FORHEL)', + '', + '$(MADEVENT) $(FORHEL):', + '\t+$(MAKE) -C $(@D) $(@F)', + '', + '# cross-group ordering (dependent directory waits for its base):', + ] + for dep, base in self._crossgroup_dirs: + lines.append('%s/madevent: %s/madevent' % (dep, base)) + lines.append('%s/madevent_forhel: %s/madevent_forhel' % (dep, base)) + open(pjoin(subproc_path, 'makefile_madevent'), 'w').write( + '\n'.join(lines) + '\n') + + #=========================================================================== + # _dsig_crossgroup_fills + #=========================================================================== + def _crossed_helicity_configs(self, base_me, cross, signed=True, + permuted=True): + """The base helicity rows transformed by the crossing. Three consumers + need three DIFFERENT transforms, selected by (signed, permuted). Which + one belongs where is decided by what the code being fed can APPLY at run + time, and getting it wrong is silent: + + * (True, True) -- the GHREMAP remap sigma[hb][k] = base_row[PERM[k]]*SGN[k], + the transform the _GOODHEL_PROBE relation validates: a base row is good + WHEN CROSSED iff sigma^-1 of it is good for the base's own process. This + is the *loop-index* space of matrix_orig.f, which takes NHEL at run + time and so realises the full PERM+SGN transform via + APPLY_CROSSING_TABLE (CROSS_GHIDX is its fortran side). SGN belongs + here because the crossed physical config + bh[PERM[k]]*SGN[k]*IC_IN[PERM[k]] reduces to the bare table value + bh[PERM[k]]*SGN[k] once the common IC_IN[PERM[k]] is stripped. + + CAUTION: G_base U sigma(G_base) is NOT a safe helicity table for the + recycled matrix_optim.f -- see (True, False) below, which is. + + * (True, False) -- the good-hel-set remap of the RECYCLED optim + (_crossgroup_base_helsignmap): tau[hb][k] = base_row[k]*SGN[k], a sign + flip at the crossed legs with NO slot permutation. matrix_optim.f + bakes its helicity configs into the HELAS calls and takes only + (PUSE, IC) at run time, so a crossed entry can apply SGN -- through + IC -- but never PERM. Writing sigma = tau . pi_unsigned (with + pi_unsigned the (False, True) map, which says which optim row + reproduces which orig row) gives, for optim row hb, the exact + statement: hb is non-zero when crossed iff tau[hb] is good for the + base. So the shared optim's good-hel union is G_base U tau(G_base), + NOT G_base U sigma(G_base). tau is also always a clean permutation -- + each leg's helicity states are closed under negation -- whereas sigma + need not be when the crossing swaps legs of different spin. + + * (False, True) -- the event helicity LABEL (the router's digit + permutation): + crossed[hb][k] = base_row[PERM[k]], exactly what APPLY_CROSSING_TABLE + writes into NHEL (it permutes NHEL -- NHEL(XK)=NHEL_IN(PERM(XK)) -- but + flips only the IC/NSF flags -- IC(XK)=SGN(XK)*IC_IN(PERM(XK))). The LHE + label is the raw NHEL table value (unwgt.f: jpart(7,i)=nhel(i)), never + NHEL*IC, and the base MATRIX gives leg k the physical spinor helicity + NHEL(k)*IC(k)=base_row[PERM[k]]*SGN[k]*IC_IN[PERM[k]] + =base_row[PERM[k]]*IC_dep[k] (SGN[k]*IC_IN[PERM[k]] is exactly slot k's + own NSF in the dependent), matching the dependent's native label + NHEL_dep[k]*IC_dep[k] iff NHEL_dep[k]=base_row[PERM[k]] -- NO extra sign. + Multiplying SGN here double-counts the flip and mislabels every + fermion/vector leg that swaps initial<->final. + + Returns (base_rows, crossed_rows) as tuples in the base NHEL order.""" + bh = [tuple(x) for x in base_me.get_helicity_matrix()] + tables = ProcessExporterFortran.compute_crossing_tables(self, base_me) + nx = tables['nexternal'] + P = [tables['perm'][cross * nx + k] for k in range(nx)] if permuted \ + else list(range(nx)) + S = [tables['ic'][cross * nx + k] for k in range(nx)] if signed \ + else [1] * nx + crossed = [tuple(row[P[k]] * S[k] for k in range(nx)) for row in bh] + return bh, crossed + + def _helicity_row_permutation(self, bh, crossed): + """1-based row permutation pi[hb] = the index whose base NHEL row equals + the transformed row of hb, or None if the transform is not a clean + permutation of the table.""" + bhpos = {cfg: i for i, cfg in enumerate(bh)} + pi = [bhpos.get(c, -1) for c in crossed] + if -1 in pi or sorted(pi) != list(range(len(bh))): + return None + return [p + 1 for p in pi] + + def _crossgroup_base_helsignmap(self, base_me, cross): + """1-based base->base helicity permutation tau of a crossing: + tau[hb] = the base index whose NHEL row equals the row of hb with the + helicity of every crossed leg negated (SGN, no PERM). This is the + transform the recycled matrix_optim.f realises when entered with a + crossing's (PUSE, IC): optim row hb is non-zero for that crossing iff + tau[hb] is good for the base's own process, so the union good-hel the + shared optim must be baked over is G_base U tau(G_base). Returns None if + not a clean permutation (only reachable if the helicity table is not + closed under negating those legs, e.g. a restricted helicity set).""" + return self._helicity_row_permutation( + *self._crossed_helicity_configs(base_me, cross, permuted=False)) + + def _diagram_topology_signature(self, me): + """Per diagram number, the set of its internal propagators as + (canonical external-leg subset, |PDG|) -- a crossing-covariant topology + signature. A propagator is identified by the external legs whose momenta + flow through it (a subset and its complement are the same propagator, + hence the canonical choice of the two) TOGETHER WITH the particle running + in it. get_s_and_t_channels numbers the propagators negative, + external-inward; the final t-channel 'propagator' is a single external + leg and is dropped (canonical length 1). + + The leg subsets alone are not a fine enough invariant: two diagrams can + route the same momenta through different particles, and then they share a + signature, the base lookup loses one of them and _crossgroup_configmap + degrades to the identity. g g > t t~ u u~ is the standing example -- the + gluon-exchange diagram and the one carrying the four-gluon vertex through + its auxiliary field have identical leg subsets and differ only here. + + |PDG| and not PDG: crossing a leg between the initial and the final state + reverses the momentum flow through every propagator on its path, which + conjugates them. The magnitude is what is invariant under the relabelling + -- and staying invariant is the whole point, since this signature is what + matches a diagram to its counterpart in the crossed process. + + Returns (dict diagram_number -> frozenset of (subset, |PDG|), nexternal). + """ + nx, nini = me.get_nexternal_ninitial() + model = me.get('processes')[0].get('model') + npdg = model.get_first_non_pdg() + allset = frozenset(range(1, nx + 1)) + canon = lambda s: min(s, allset - s, key=lambda x: (len(x), sorted(x))) + out = {} + for diag in me.get('diagrams'): + sch, tch = diag.get('amplitudes')[0].get_s_and_t_channels( + nini, model, npdg) + ext = {i: frozenset([i]) for i in range(1, nx + 1)} + props = set() + for vert in list(sch) + list(tch): + legs = vert.get('legs') + daughters = [l.get('number') for l in legs[:-1]] + s = frozenset().union(*[ext.get(d, frozenset([d])) + for d in daughters]) if daughters \ + else frozenset() + ext[legs[-1].get('number')] = s + if 2 <= len(canon(s)): + props.add((canon(s), abs(legs[-1].get('id')))) + out[diag.get('number')] = frozenset(props) + return out, nx + + def _crossgroup_configmap(self, dep_me, base_me, cross): + """1-based map from a dependent diagram number to the base diagram number + of the same topology under the crossing. The dependent's genps samples its + own config's poles, but the base SMATRIX enhances AMP2(channel), so channel + must name the matching BASE diagram; otherwise the importance sampling is + mis-paired (this only affects the variance, never the result -- summing the + channels gives the full integral for any bijective pairing). Returns the + identity if the diagrams cannot be cleanly matched -- with a warning, + because that fallback is otherwise invisible: it is indistinguishable + from the common and legitimate case of a crossing-covariant numbering, + every matrix element still agrees to the last digit, and the only symptom + is a cross section that integrates slowly and unstably behind an error + estimate that no longer means anything.""" + bsub, nx = self._diagram_topology_signature(base_me) + dsub, _ = self._diagram_topology_signature(dep_me) + ngraphs = len(dep_me.get('diagrams')) + bsig = {v: k for k, v in bsub.items()} + tables = ProcessExporterFortran.compute_crossing_tables(self, base_me) + P = [tables['perm'][cross * nx + k] for k in range(nx)] + d2b = {k + 1: P[k] + 1 for k in range(nx)} # dep leg -> base leg + allset = frozenset(range(1, nx + 1)) + canon = lambda s: min(s, allset - s, key=lambda x: (len(x), sorted(x))) + + def bail(why): + logger.warning( + 'crossing: could not match the diagrams of %s onto %s ' + '(crossing %d): %s. Falling back to the identity config map -- ' + 'the cross section stays correct, but the multi-channel ' + 'importance sampling of the routed subprocess is mis-paired and ' + 'will integrate slowly, with an unreliable error estimate.', + dep_me.get('processes')[0].shell_string(), + base_me.get('processes')[0].shell_string(), cross, why) + return list(range(1, ngraphs + 1)) + + if len(bsig) != len(bsub): + return bail("%d of the base's %d diagrams share a topology " + "signature with another" + % (len(bsub) - len(bsig), len(bsub))) + cmap = list(range(1, ngraphs + 1)) + for dd, ds in dsub.items(): + if not 1 <= dd <= ngraphs: + return bail('diagram number %d is outside 1..%d' % (dd, ngraphs)) + sig = frozenset((canon(frozenset(d2b[l] for l in sub)), pdg) + for (sub, pdg) in ds) + if sig in bsig: + cmap[dd - 1] = bsig[sig] + else: + return bail('diagram %d has no counterpart in the base' % dd) + if sorted(cmap) != list(range(1, ngraphs + 1)): + return bail('the matching is not a bijection') + return cmap + + def _dsig_crossgroup_fills(self, matrix_element, proc_id, crossgroup): + """Fill the cross-group (Track B) holes of auto_dsig_v4.inc for a + dependent subprocess that has no matrix element of its own and routes to + a base group's symlinked crossing-aware SMATRIX. + + * beams -- the dependent cannot define its own GET_FLAVOR (it would clash + with the symlinked base's), so its FLAVOR table (group-position coded, + exactly as GET_FLAVOR would return) is inlined as DSIG_XGFLAV and + indexed by IFLAV for the PDF. + * SMATRIX -- dispatch to the base SMATRIX with the crossed FLAV_IDX + (DSIG_XGROUTE(IFLAV)) instead of IFLAV; the base crosses the momenta and + rebuilds the crossed denominator internally so ANS is this subprocess's + matrix element. Momenta/PDF/phase space stay this subprocess's own. + * event helicity/colour -- the base returns selected_hel/selected_col in + ITS enumeration; the event is written through this subprocess's own + get_helicities / ICOLUP, so remap the index base -> dependent per flavor + (DSIG_XGHEL / DSIG_XGCOL). Colour is the identity for colourless. + * multi-channel -- the base enhances AMP2(channel) in its diagram + numbering; translate this subprocess's channel to the matching base + diagram (DSIG_XGCONFIG) so importance sampling stays paired. + All four maps are emitted only when non-identity. + """ + base_proc_id = crossgroup['base_proc_id'] + flav_idx = crossgroup['flav_idx'] # per dep flavor -> base FLAV_IDX + base_me = crossgroup['base_me'] + nflav_base = len(base_me.get_external_flavors_with_iden()) + all_flv = matrix_element.get_external_flavors_with_iden() + model = self.model or matrix_element.get('processes')[0].get('model') + pdg_to_group_pos, max_group_size = self._build_flavor_group_lookup(model) + + # Column-major flat DATA (leg fastest, then flavor) -- avoids an implied- + # do index variable, which need not be declared in every program unit. + positions = [str(self._map_flavor_to_group_pos( + f, pdg_to_group_pos, max_group_size)) + for flav in all_flv for f in flav[0]] + decl = [' INTEGER DSIG_XGFLAV(NEXTERNAL,%d)' % len(all_flv), + ' DATA DSIG_XGFLAV /%s/' % ','.join(positions), + ' INTEGER DSIG_XGROUTE(%d)' % len(all_flv), + ' DATA DSIG_XGROUTE /%s/' % ','.join(str(x) for x in flav_idx)] + + # Per-flavor colour map (base flow -> dependent flow). + colmap = [self._router_colmap(matrix_element, base_me, + (iflav - 1) // nflav_base) + for iflav in flav_idx] + ncol = len(colmap[0]) if colmap else 0 + # Event helicity: relabel the base's selected helicity code into this + # (crossed) subprocess's canonical code by permuting the code's + # mixed-radix digits with the crossing permutation (GET_CROSS_PERM), + # decoded directly by this subprocess's get_nhel. Replaces the explicit + # base->dep helicity map. GET_CROSS_PERM takes the extended base index + # (DSIG_XGROUTE(flav)); cross 0 gives the identity permutation. + nhstate = [len(s) for s in base_me.get_helicity_per_particle()] + decl += [' INTEGER XPERM(NEXTERNAL), XSGN(NEXTERNAL), XDUMF', + ' INTEGER XBDIG(NEXTERNAL), XHR, XHK', + ' INTEGER XNHS(NEXTERNAL)', + ' DATA XNHS /%s/' % ','.join(str(n) for n in nhstate)] + hel_post = ( + '\n CALL CR%s_GET_CROSS_PERM(DSIG_XGROUTE({flav}), XPERM,' + ' XSGN, XDUMF)' + '\n IF (selected_hel{idx}.GE.1) THEN' + '\n XHR = selected_hel{idx} - 1' + '\n DO XHK=NEXTERNAL,1,-1' + '\n XBDIG(XHK) = MOD(XHR, XNHS(XHK))' + '\n XHR = XHR / XNHS(XHK)' + '\n ENDDO' + '\n selected_hel{idx} = 0' + '\n DO XHK=1,NEXTERNAL' + '\n selected_hel{idx} = selected_hel{idx} * XNHS(XPERM(XHK))' + ' + XBDIG(XPERM(XHK))' + '\n ENDDO' + '\n selected_hel{idx} = selected_hel{idx} + 1' + '\n ENDIF') % base_proc_id + # Colour: unlike helicity, a base->dep index relabel of selected_col is + # NOT sufficient. The base SMATRIX picked its flow with select_color, + # which masks the base-order JAMP2 with THIS (dependent) binary's ICOLAMP + # + ICONFIG -- mismatched in flow order AND config space -- so the picked + # flow can be incompatible with the sampled config and addmothers fails + # to reduce its ICOLUP. Reselect natively instead: permute the base's + # published per-flow JAMP2 (COMMON/TO_XG_JAMP2) into this subprocess's + # flow order (DSIG_XGCOL) and run this subprocess's own SELECT_COLOR + # (its own ICOLAMP + ICONFIG), via the XG_SELCOL helper below. Bit-for-bit + # a native colour selection. Only needed when colmap is non-identity + # (identity/colourless: the base's selection is already in this order). + identity_col = list(range(1, ncol + 1)) + col_active = ncol > 0 and any(cm != identity_col for cm in colmap) + dsig_xg_helper = '' + col_scalar_call, col_vec_call = '', '' + if col_active: + col_flat = ','.join(str(x) for col in colmap for x in col) + dsig_xg_helper = self._crossgroup_colsel_helper( + proc_id, ncol, len(colmap), col_flat) + col_scalar_call = ('\n CALL XG_SELCOL%s(RCOL, IFLAV, 1,' + ' SELECTED_COL(1))' % proc_id) + col_vec_call = ('\n CALL XG_SELCOL%s(COL_RAND(IVEC),' + ' IFLAV_VEC(IVEC), IVEC, SELECTED_COL(IVEC))' + % proc_id) + + # Multi-channel config remap: the base SMATRIX enhances AMP2(channel) in + # ITS diagram numbering, but this subprocess's genps samples its own + # config's poles, so translate the channel to the matching base diagram. + ngraphs = len(base_me.get('diagrams')) + configmap = [self._crossgroup_configmap(matrix_element, base_me, + (iflav - 1) // nflav_base) + for iflav in flav_idx] + chan_scalar, chan_vec = 'channel', 'channels(IVEC)' + if any(cm != list(range(1, ngraphs + 1)) for cm in configmap): + decl.append(' INTEGER DSIG_XGCONFIG(%d,%d)' + % (ngraphs, len(configmap))) + decl.append(' DATA DSIG_XGCONFIG /%s/' + % ','.join(str(x) for col in configmap for x in col)) + chan_scalar = 'DSIG_XGCONFIG(channel, IFLAV)' + chan_vec = 'DSIG_XGCONFIG(channels(IVEC), IFLAV_VEC(IVEC))' + + # DSIG_XG* are used from three separate program units (DSIG, DSIG_VEC, + # SMATRIX_MULTI); declare them in each. + decl_block = '\n'.join(decl) + '\n' + + return { + 'dsig_xg_decl': decl_block, + 'dsig_xg_decl_vec': decl_block, + 'dsig_xg_decl_multi': decl_block, + 'dsig_xg_helper': dsig_xg_helper, + 'dsig_getflavor': ' FLAVOR(:) = DSIG_XGFLAV(:, IFLAV)', + 'dsig_smatrix_call': ( + ' CALL SMATRIX%d(P1, DSIG_XGROUTE(IFLAV), RHEL, RCOL, %s,' + ' 1, DSIGUU, selected_hel(1), selected_col(1))' + % (base_proc_id, chan_scalar) + + hel_post.format(idx='(1)', flav='IFLAV') + + col_scalar_call), + # vectorised (SMATRIX_MULTI) path: same routing. The MULTI wrapper + # itself keeps this subprocess's own name (it is defined in this + # auto_dsig); only the inner base-SMATRIX call + flavor are routed. + 'dsig_getflavor_vec': + ' FLAVOR(:) = DSIG_XGFLAV(:, IFLAV_VEC(IVEC))', + 'dsig_smatrix_vec_name': 'SMATRIX%d' % base_proc_id, + 'dsig_smatrix_vec_flav': 'DSIG_XGROUTE(IFLAV_VEC(IVEC))', + 'dsig_smatrix_vec_chan': chan_vec, + 'dsig_smatrix_vec_post': ( + hel_post.format(idx='(IVEC)', flav='IFLAV_VEC(IVEC)') + + col_vec_call), + } + + def _crossgroup_colsel_helper(self, proc_id, ncol, nflav, col_flat, + iproc=1): + """Emit XG_SELCOL, the crossing colour-selection helper for a + subprocess that gets its matrix element from a crossed base. It permutes + the base ME's published per-flow JAMP2 (COMMON/TO_XG_JAMP2, base flow + order) into this subprocess's flow order via DSIG_XGCOL (base flow -> + dep flow) and runs this subprocess's own SELECT_COLOR (its ICOLAMP row + + the live ICONFIG), so the returned flow is native to this subprocess -- + consistent with its ICOLUP and its sampled config, unlike a bare + base->dep index relabel of the base's own (mismatched) selection. The + DATA is column-major (flow fastest, then flavor); the writer wraps the + long line. + + ``iproc`` is SELECT_COLOR's matrix-element index, i.e. the ICOLAMP row to + mask with, and must be THIS subprocess's own. A cross-group dependent + (Track B) is alone in its P directory and is always 1; a within-group + router (Track A) shares the directory with its base and passes its own + proc_id -- the base's row is a different subprocess's and generally + allows a different set of flows at the same ICONFIG. + """ + return '\n'.join([ + ' SUBROUTINE XG_SELCOL%s(RCOL, IFLAV, IVEC, ICOL)' % proc_id, + ' IMPLICIT NONE', + " INCLUDE 'genps.inc'", + " INCLUDE 'nexternal.inc'", + " INCLUDE 'maxconfigs.inc'", + " INCLUDE 'maxamps.inc'", + " INCLUDE '../../Source/vector.inc'", + ' DOUBLE PRECISION RCOL', + ' INTEGER IFLAV, IVEC, ICOL', + ' INTEGER I', + ' INTEGER MAPCONFIG(0:LMAXCONFIGS), ICONFIG', + ' COMMON/TO_MCONFIGS/MAPCONFIG, ICONFIG', + ' DOUBLE PRECISION XG_JAMP2(0:MAXFLOW,VECSIZE_MEMMAX)', + ' COMMON/TO_XG_JAMP2/XG_JAMP2', + ' DOUBLE PRECISION JD(0:MAXFLOW)', + ' INTEGER DSIG_XGCOL(%d,%d)' % (ncol, nflav), + ' DATA DSIG_XGCOL /%s/' % col_flat, + # DSIG_XGCOL is normally a bijection onto 1..ncol, so every slot + # below JD(0) is written; zero first anyway, so a map that misses a + # flow degrades to "that flow has no weight" rather than feeding + # SELECT_COLOR an uninitialised one. + ' DO I=1,%d' % ncol, + ' JD(I) = 0D0', + ' ENDDO', + ' JD(0) = XG_JAMP2(0,IVEC)', + ' DO I=1,%d' % ncol, + ' JD(DSIG_XGCOL(I,IFLAV)) = XG_JAMP2(I,IVEC)', + ' ENDDO', + ' CALL SELECT_COLOR(RCOL, JD, ICONFIG, %s, ICOL, IVEC)' % iproc, + ' END', + ]) + #=========================================================================== # write_auto_dsig_file #=========================================================================== - def write_auto_dsig_file(self, writer, matrix_element, proc_id = ""): + def write_auto_dsig_file(self, writer, matrix_element, proc_id = "", + crossgroup=None): """Write the auto_dsig.f file for the differential cross section - calculation, includes pdf call information""" + calculation, includes pdf call information. + + When ``crossgroup`` is given (Track B, cross-group crossing) this + subprocess has no matrix element of its own: it symlinks a base group's + crossing-aware SMATRIX and routes to it. The flavor lookup and the + SMATRIX call are then filled with the routed variants (see + _dsig_crossgroup_fills); everything else (PDFs, cuts, phase space) stays + this subprocess's own.""" if not matrix_element.get('processes') or \ not matrix_element.get('diagrams'): @@ -6409,6 +11979,26 @@ def write_auto_dsig_file(self, writer, matrix_element, proc_id = ""): replace_dict['proc_id'] = proc_id replace_dict['numproc'] = 1 + # Flavor lookup + SMATRIX call default to this subprocess's own matrix + # element; a cross-group dependent (Track B) overrides them below to route + # to a base group's symlinked crossing-aware SMATRIX. + replace_dict['dsig_xg_decl'] = '' + replace_dict['dsig_xg_decl_vec'] = '' + replace_dict['dsig_xg_decl_multi'] = '' + replace_dict['dsig_xg_helper'] = '' + replace_dict['dsig_getflavor'] = \ + ' CALL GET_FLAVOR%s(IFLAV, FLAVOR)' % proc_id + replace_dict['dsig_smatrix_call'] = ( + ' CALL SMATRIX%s(P1, IFLAV, RHEL, RCOL,channel,1, DSIGUU,' + ' selected_hel(1), selected_col(1))' % proc_id) + # ... and the same for the vectorised (SMATRIX_MULTI) path. + replace_dict['dsig_getflavor_vec'] = \ + ' CALL GET_FLAVOR%s(IFLAV_VEC(IVEC), FLAVOR)' % proc_id + replace_dict['dsig_smatrix_vec_name'] = 'SMATRIX%s' % proc_id + replace_dict['dsig_smatrix_vec_flav'] = 'IFLAV_VEC(IVEC)' + replace_dict['dsig_smatrix_vec_chan'] = 'channels(IVEC)' + replace_dict['dsig_smatrix_vec_post'] = '' + # Set dsig_line if ninitial == 1: # No conversion, since result of decay should be given in GeV @@ -6487,8 +12077,15 @@ def write_auto_dsig_file(self, writer, matrix_element, proc_id = ""): replace_dict['ncomb']= ncomb helicity_lines = self.get_helicity_lines(matrix_element, add_nb_comb=True) replace_dict['helicity_lines'] = helicity_lines - - context = {'read_write_good_hel':True} + # Canonical helicity decoder tables for GET_NHEL: the per-event helicity + # label is the mixed-radix code, so GET_NHEL decodes it (per-leg states) + # rather than indexing an NHEL config table. + hel_data = self._helstate_data(matrix_element) + replace_dict['maxhel'] = hel_data['maxhel'] + replace_dict['nhstate_data'] = hel_data['nhstate_data'] + replace_dict['states_data'] = hel_data['states_data'] + + context = {'read_write_good_hel':True} if not isinstance(self, ProcessExporterFortranMEGroup): replace_dict['read_write_good_hel'] = self.read_write_good_hel(ncomb) context['nogrouping'] = True @@ -6520,9 +12117,14 @@ def write_auto_dsig_file(self, writer, matrix_element, proc_id = ""): f, pdg_to_group_pos, max_group_size)) for f in flav[0]] replace_dict['get_flavor_matrix'] += ' DATA (FLAVOR(i, %d),i= 1, NEXTERNAL) /%s/\n' % (i+1, ', '.join(flav_positions)) - + # Cross-group dependent (Track B): override the flavor lookup + SMATRIX + # call to route to the symlinked base group's crossing-aware SMATRIX. + if crossgroup is not None: + replace_dict.update( + self._dsig_crossgroup_fills(matrix_element, proc_id, crossgroup)) + if writer: file = open(pjoin(_file_path, \ 'iolibs/template_files/auto_dsig_v4.inc')).read() @@ -7292,6 +12894,210 @@ def write_driver(self, writer, ncomb, n_grouped_proc, v5=True): else: return replace_dict + def _module_color_flows(self, matrix_element): + """Return the colour-flow decomposition (leshouche ICOLUP) of an ME as a + list, one entry per flow, of (colour, anticolour) per leg in leg order. + None if the ME has no colour basis.""" + if not matrix_element.get('color_basis'): + return None + proc = matrix_element.get('processes')[0] + legs = proc.get_legs_with_decays() + ninitial = matrix_element.get_nexternal_ninitial()[1] + repr_dict = {l.get('number'): + proc.get('model').get_particle(l.get('id')).get_color() + * (-1) ** (1 + l.get('state')) for l in legs} + # get_flow_basis(): with the DDM color basis the basis elements are + # products of f's and have no single flow each, so the flows -- and the + # ICOLUP rows built from them -- come from the trace basis carried + # alongside, which is also what the JAMP array is indexed by. Without + # DDM it returns the basis itself. + flows = matrix_element.get('color_basis').get_flow_basis().\ + color_flow_decomposition(repr_dict, ninitial) + return [[tuple(cf[l.get('number')]) for l in legs] for cf in flows] + + @staticmethod + def _color_flow_canon(flow, states): + """Label-independent canonical form of one colour flow: the set of + (colour-leg, anticolour-leg) connections, with INITIAL-state legs + swapping the two roles so that every colour index connects to an + anticolour index (the LHE convention runs initial-state colour lines + 'through', so without this swap a label can sit in the same slot on two + legs and the flow is not a bijection). Shared by _router_colmap + (topology matching) and _color_flow_code.""" + col, anti = {}, {} + for leg, (c, a) in enumerate(flow): + if states[leg] is False: + c, a = a, c + if c: + col.setdefault(c, []).append(leg) + if a: + anti.setdefault(a, []).append(leg) + conns = set() + for lbl in set(list(col) + list(anti)): + for cc, aa in zip(sorted(col.get(lbl, [])), + sorted(anti.get(lbl, []))): + conns.add((cc, aa)) + return frozenset(conns) + + @staticmethod + def _color_flow_code(conns): + """Canonical integer code of a colour flow from its canonical + connections (see _color_flow_canon). + + Order the colour slots and the anticolour slots by leg -- a gluon holds + one slot of each kind, a sextet two -- then digit i is the index of the + anticolour slot that colour slot i connects to, and + + code = sum_i digit_i * N^i (N = number of anticolour slots) + + This is the colour analogue of the canonical helicity code. It is + injective over a process's colour basis, and crossing-covariant: + relabelling the legs with the crossing permutation carries the base + code onto the crossed process's own code (the initial-state flip is + what makes the connectivity invariant under a crossing, exactly as the + conjugate+state flip cancellation does for the helicity). Note the code + space is N^N while only the basis flows are realised, so -- like the + helicity allowed-list -- the codes are a sparse subset.""" + ordered = sorted(conns) + acol = sorted(a for _c, a in conns) + nslot = len(acol) + code = 0 + used = set() + for i, (_c, a) in enumerate(ordered): + slot = -1 + for j, aa in enumerate(acol): + if aa == a and j not in used: + slot = j + break + if slot < 0: + return None + used.add(slot) + code += slot * (nslot ** i) + return code + + @staticmethod + def _color_flow_slots(conns): + """(colour-slot legs, anticolour-slot legs) of a process, each ordered by + leg, read off one canonical flow. + + This is FLOW-INDEPENDENT process data -- which legs carry a colour resp. + anticolour index is fixed by the colour representations (after the + initial-state flip), not by which flow is picked -- so it is the colour + analogue of the per-leg helicity-state counts, and it is all a decoder + needs besides the code itself.""" + return ([c for c, _a in sorted(conns)], + sorted(a for _c, a in conns)) + + @staticmethod + def _color_flow_decode(code, colslots, acolslots): + """Inverse of _color_flow_code: rebuild a flow's canonical connections + from its code and the process's slot structure (see _color_flow_slots). + + digit_i = (code // N^i) %% N is the anticolour slot that colour slot i + connects to. NOTE: for a leg carrying two slots of the same kind (a + sextet) encode/decode must agree on the tie-break between its slots; + that case is untested.""" + nslot = len(acolslots) + if nslot == 0: + return frozenset() + conns = set() + for i, cleg in enumerate(colslots): + digit = (code // (nslot ** i)) % nslot + conns.add((cleg, acolslots[digit])) + return frozenset(conns) + + def _color_flow_codes(self, matrix_element): + """Canonical colour-flow codes of an ME, one per colour-basis flow in + basis order. None if the ME has no colour basis or a flow is not a clean + colour<->anticolour bijection.""" + flows = self._module_color_flows(matrix_element) + if not flows: + return None + states = [l.get('state') for l in + matrix_element.get('processes')[0].get_legs_with_decays()] + codes = [] + for fl in flows: + code = self._color_flow_code(self._color_flow_canon(fl, states)) + if code is None: + return None + codes.append(code) + return codes + + def _color_code_tables(self, matrix_element): + """Per-ME colour tables for the generated fortran, or None if the ME has + no usable colour code: (codes, colour-slot legs, anticolour-slot legs), + the two slot lists 1-based so they index the fortran leg arrays. + + This is ALL the colour data an ME needs, and it is per-ME rather than + per-(base, crossing) pair: the slot structure is flow-independent (see + _color_flow_slots) and the codes are label-independent, so any crossing + of this ME reuses the same three arrays.""" + flows = self._module_color_flows(matrix_element) + if not flows: + return None + # A negative tag marks a colour SEXTET (color_flow_decomposition stores + # it in the opposite slot, so one leg carries two slots of the same + # kind). The code has no room for that sign, and a decoder rebuilding + # the tags could not restore it, so leave those to the ICOLUP table. + if any(c < 0 or a < 0 for fl in flows for c, a in fl): + return None + states = [l.get('state') for l in + matrix_element.get('processes')[0].get_legs_with_decays()] + conns = [self._color_flow_canon(fl, states) for fl in flows] + codes = [self._color_flow_code(c) for c in conns] + if any(c is None for c in codes) or len(set(codes)) != len(codes): + return None + colslots, acolslots = self._color_flow_slots(conns[0]) + if not acolslots: + return None + # flow-independence is what lets a single table serve every crossing + for c in conns[1:]: + if self._color_flow_slots(c) != (colslots, acolslots): + return None + return (codes, [l + 1 for l in colslots], [l + 1 for l in acolslots]) + + #=========================================================================== + # get_colorflow_lines / write_colorflow_file + #=========================================================================== + def get_colorflow_lines(self, matrix_element, numproc): + """DATA lines of colorflow.inc for one subprocess: the canonical + colour-flow CODE of each flow plus the slot structure needed to decode + it (see _color_flow_code / _color_flow_decode). + + addmothers rebuilds the event's colour tags from these instead of + reading the ICOLUP table, which is why leshouche.inc can drop ICOLUP + whenever this is emitted. NCOLSLOT is 0 when the ME has no usable code + (no colour, a sextet, or an epsilon structure); addmothers then falls + back to ICOLUP, which get_leshouche_lines still writes in that case.""" + tables = self._color_code_tables(matrix_element) + if not tables: + return ["DATA NCOLSLOT(%d)/0/" % (numproc + 1)] + codes, colslots, acolslots = tables + return [ + "DATA NCOLSLOT(%d)/%d/" % (numproc + 1, len(colslots)), + "DATA (ICOLCSL(i,%d),i=1,%d)/%s/" % ( + numproc + 1, len(colslots), + ",".join(str(l) for l in colslots)), + "DATA (ICOLASL(i,%d),i=1,%d)/%s/" % ( + numproc + 1, len(acolslots), + ",".join(str(l) for l in acolslots)), + "DATA (ICOLCODE(i,%d),i=1,%d)/%s/" % ( + numproc + 1, len(codes), + ",".join(str(c) for c in codes)), + ] + + def write_colorflow_file(self, writer, matrix_element): + """Write colorflow.inc for a single (non-grouped) subprocess.""" + writer.writelines(self.get_colorflow_lines(matrix_element, 0)) + return True + + def write_leshouche_file(self, writer, matrix_element): + """Write leshouche.inc, without the ICOLUP table when the colour code + can supply the tags (see get_colorflow_lines).""" + writer.writelines(self.get_leshouche_lines(matrix_element, 0, + drop_icolup=True)) + return True + #=========================================================================== # write_addmothers #=========================================================================== @@ -7543,16 +13349,333 @@ class ProcessExporterFortranMEGroup(ProcessExporterFortranME): MadEvent subprocess group format.""" + # the color sum uses the DDM basis, the color flows the trace one + support_ddm_color_basis = True + ddm_needs_flow_basis = True + matrix_file = "matrix_madevent_group_v4.inc" grouped_mode = 'madevent' + # The group SMATRIX decodes an extended FLAV_IDX (M0) and the router lets + # crossed subprocesses share a base's matrix element, so this exporter can + # honour --use_crossing (the _check_crossing_support gate lets it through). + supports_crossing = True default_opt = {'clean': False, 'complex_mass':False, 'export_format':'madevent', 'mp': False, 'v5_model': True, 'output_options':{}, 'hel_recycling': True } - - + + + #=========================================================================== + # write_matrix_router_file + #=========================================================================== + def _router_colmap(self, router_me, base_me, cross): + """Map each base colour-flow index to this subprocess's flow index. + + The base picks a colour flow in its own basis and events are written + through this subprocess's ICOLUP, whose flow ORDER can differ (the + crossed colour reps decompose the shared colour basis in another order). + Crossing a base flow (leg j <- base flow leg perm^-1(j), colour <-> + anticolour when that leg swapped initial/final) gives the physical flow; + it is matched to the local flow of the same topology (label independent). + Returns a 1-based list indexed by the base flow; identity if unmatchable. + """ + bflows = self._module_color_flows(base_me) + rflows = self._module_color_flows(router_me) + if not bflows or not rflows or len(bflows) != len(rflows): + return list(range(1, len(rflows or []) + 1)) + nx = router_me.get_nexternal_ninitial()[0] + rstates = [l.get('state') for l in + router_me.get('processes')[0].get_legs_with_decays()] + perm, ic, _valid = self.get_crossing_permutation(cross, nx) + inv = [0] * nx + for s, leg in enumerate(perm): + inv[leg] = s + + def canon(flow): + # Topology (label independent), shared with the colour-flow code. + return self._color_flow_canon(flow, rstates) + + rindex = {} + for j, fl in enumerate(rflows): + rindex.setdefault(canon(fl), j + 1) + colmap = [] + for icol, bf in enumerate(bflows): + crossed = [] + for j in range(nx): + c, a = bf[inv[j]] + if ic[inv[j]] == -1: + c, a = a, c + crossed.append((c, a)) + colmap.append(rindex.get(canon(crossed), icol + 1)) + return colmap + + def write_matrix_router_file(self, writer, matrix_element, fortran_model, + proc_id="", config_map=[], subproc_number="", + routing=None, matrix_elements=None): + """Write a light matrix.f for a crossed subprocess that shares a base + subprocess's matrix element. It keeps only GET_FLAVOR (for the PDF) + and a router SMATRIX that, per flavor, calls the base SMATRIX with the + crossed FLAV_IDX from partition_crossing_classes; the heavy MATRIX is + not emitted. get_nhel lives in auto_dsig.f, so it is unaffected. + + Colour is NOT taken from the base's own selection. The base SMATRIX picks + its flow with SELECT_COLOR masked by the BASE's ICOLAMP row -- a different + subprocess's row, which at the live ICONFIG generally allows a different + set of flows -- so relabelling that index into this subprocess's flow + order (whatever the relabel) can hand the event a topology this + subprocess's own SELECT_COLOR would never pick, and the crossing-off + build never produces. Reselect natively instead, exactly as the + cross-group path does: permute the base's published per-flow JAMP2 + (COMMON/TO_XG_JAMP2, base flow order -- crossing-covariant, so these are + this subprocess's own per-flow weights) into this subprocess's flow order + and run SELECT_COLOR with THIS subprocess's proc_id as IPROC + (_crossgroup_colsel_helper, emitted into this file as XG_SELCOL). + + The base flow -> this subprocess's flow permutation is _router_colmap; + when it is not a usable bijection the reselect is skipped and the old + index relabel through the canonical colour-flow CODE is kept (decode the + base's code, relabel the legs with the crossing permutation, re-encode + and look it up in this subprocess's own code table, see _color_flow_code), + with the explicit COLMAP array as the last resort. + + Momenta, PDGs and the helicity index already come out in this + subprocess's own convention.""" + # Reuse the full builder (writer=None) to get the flavor table and the + # info/process/nexternal/max_flavor holes; nothing heavy is written. + replace_dict = self.write_matrix_element_v4( + None, matrix_element, fortran_model, proc_id=proc_id, + config_map=config_map, subproc_number=subproc_number) + dispatch = [] + decl = [] + # Shared temporaries for the runtime helicity encode below. The base + # returns its selected helicity as ITS canonical code; the event is + # written through THIS module's get_nhel, which decodes THIS + # (crossed) module's code -- so relabel by permuting the code's + # mixed-radix digits with the crossing permutation (GET_CROSS_PERM), + # exactly the dependent-vs-base relation dep_states[k]==base_states[PERM[k]]. + encode_used = False + col_used = False + baked_nhs = {} # base_index -> baked base-NHSTATE array name + baked_col = {} # base_index -> baked base colour table names + dep_col = self._color_code_tables(matrix_element) + # Multi-channel config remap. CHANNEL arrives as THIS subprocess's AMP2 + # slot (SUBDIAG = CONFSUB(, iconf), a diagram number in this + # module's numbering), but the base SMATRIX enhances AMP2(CHANNEL) in + # ITS numbering: AMP2 is filled by the BASE's diagrams evaluated at the + # CROSSED momenta, so the slot holding |this subprocess's diagram m|^2 is + # the base diagram carrying m's topology *under the crossing*. Translate + # the slot with the same map the cross-group path uses for DSIG_XGCONFIG + # -- and for the same reason; walking the base's own CONFSUB row instead + # would name the base diagram that shares the topology with legs left in + # place, which is not the one the crossed momenta filled. Per flavor, + # since each routes to its own base/crossing. Emitted only when some + # flavor is non-identity (it usually is not, the diagram numbering being + # largely crossing-covariant), so most routers are unchanged. + # + # The base applies the same map to its multi-channel row (xgrow_map in + # fill_crossing_replace_dict_me) and accepts it only as a permutation of + # ITS diagrams, so a base with a different diagram count is left alone on + # both sides -- crossing partners always have the same count, this only + # keeps the two ends from disagreeing. + ngraphs = len(matrix_element.get('diagrams')) + ident_cfg = list(range(1, ngraphs + 1)) + cfg_cache = {} # (base_index, cross) -> map; flavors often share one + configmap = [] + for (b, iflav) in routing: + key = (b, (iflav - 1) // len(matrix_elements[b] + .get_external_flavors_with_iden())) + if key not in cfg_cache: + cmap = self._crossgroup_configmap( + matrix_element, matrix_elements[b], key[1]) + if len(matrix_elements[b].get('diagrams')) != ngraphs: + cmap = ident_cfg + cfg_cache[key] = cmap + configmap.append(cfg_cache[key]) + chan_name = None + if any(cm != ident_cfg for cm in configmap): + chan_name = 'XGCONF_%s' % proc_id + decl.append(' INTEGER XCHAN') + decl.append(' INTEGER %s(%d,%d)' + % (chan_name, ngraphs, len(configmap))) + decl.append(' DATA %s /%s/' % ( + chan_name, ','.join(str(x) for col in configmap for x in col))) + # Per-flavor base-flow -> this-subprocess-flow permutation, and whether it + # supports the native colour reselect (see the docstring). It does when + # every flavor's map is a bijection of the shared colour basis, which also + # says the two flow spaces have the same size -- so the base's published + # JAMP2 fills this subprocess's JD exactly. Anything else (a flow that is + # not a clean colour<->anticolour bijection, an unmatchable topology, a + # colourless ME with no flows at all) keeps the historical index relabel. + ncol_dep = max(1, len(matrix_element.get('color_basis'))) + colmaps = [] + col_native = bool(routing) + for (base_index, iflav) in routing: + base_me = matrix_elements[base_index] + cm = self._router_colmap( + matrix_element, base_me, + (iflav - 1) // len(base_me.get_external_flavors_with_iden())) + colmaps.append(cm) + if len(cm) != ncol_dep \ + or ncol_dep != max(1, len(base_me.get('color_basis'))) \ + or sorted(cm) != list(range(1, ncol_dep + 1)): + col_native = False + if col_native: + # One helper for the whole router; the DATA is column-major (base flow + # fastest, then flavor), matching _crossgroup_colsel_helper. + replace_dict['smatrix_router_helper'] = self._crossgroup_colsel_helper( + proc_id, ncol_dep, len(colmaps), + ','.join(str(x) for cm in colmaps for x in cm), + iproc=proc_id) + for flav0, (base_index, iflav) in enumerate(routing): + base_me = matrix_elements[base_index] + nflav_base = len(base_me.get_external_flavors_with_iden()) + cross = (iflav - 1) // nflav_base + colmap = colmaps[flav0] + kw = 'IF' if flav0 == 0 else 'ELSE IF' + dispatch.append(' %s (IFLAV.EQ.%d) THEN' % (kw, flav0 + 1)) + chan = 'channel' + if chan_name: + # A config this subprocess has no diagram for gives CHANNEL=0; + # leave it alone (the base's own multi-channel block already + # handles what it gets) rather than indexing outside the table. + chan = 'XCHAN' + dispatch += [ + ' XCHAN = channel', + ' IF (channel.GE.1.AND.channel.LE.%d) XCHAN =' + ' %s(channel,%d)' % (ngraphs, chan_name, flav0 + 1)] + dispatch.append( + ' CALL SMATRIX%d(P, %d, RHEL, RCOL, %s, IVEC, ANS,' + ' IHEL, ICOL)' % (base_index + 1, iflav, chan)) + perm_called = False + # Encode the crossed helicity code (skip cross 0 = identity). + if cross != 0: + encode_used = True + perm_called = True + if base_index not in baked_nhs: + nsname = 'XNHS%d' % (base_index + 1) + nhstate = [len(s) for s in + base_me.get_helicity_per_particle()] + decl.append(' INTEGER %s(NEXTERNAL)' % nsname) + decl.append(' DATA %s /%s/' % ( + nsname, ','.join(str(n) for n in nhstate))) + baked_nhs[base_index] = nsname + nsname = baked_nhs[base_index] + dispatch += [ + ' CALL CR%d_GET_CROSS_PERM(%d, XPERM, XSGN, XDUMF)' + % (base_index + 1, iflav), + ' XHR = IHEL - 1', + ' DO XHK=NEXTERNAL,1,-1', + ' XBDIG(XHK) = MOD(XHR, %s(XHK))' % nsname, + ' XHR = XHR / %s(XHK)' % nsname, + ' ENDDO', + ' IHEL = 0', + ' DO XHK=1,NEXTERNAL', + ' IHEL = IHEL * %s(XPERM(XHK)) + XBDIG(XPERM(XHK))' + % nsname, + ' ENDDO', + ' IHEL = IHEL + 1', + ] + if col_native: + # Discard the base's ICOL entirely and reselect in this + # subprocess's own flow space, with its own ICOLAMP row -- the + # base's pick was masked with the base's row and can name a flow + # this subprocess would never emit. Unconditional: an identity + # colmap only says the two flow ORDERS agree, it says nothing + # about the two masks, and it is precisely the identity-colmap + # routers whose masks were found to disagree. + dispatch.append(' CALL XG_SELCOL%s(RCOL, %d, IVEC, ICOL)' + % (proc_id, flav0 + 1)) + continue + # Fallback: no usable per-flavor bijection, so keep the historical + # index relabel of the base's own selection. Skip it when the orders + # already agree (identity map). + if not (colmap and colmap != list(range(1, len(colmap) + 1))): + continue + base_col = self._color_code_tables(base_me) + if (dep_col and base_col + and len(base_col[1]) == len(dep_col[1]) + and len(base_col[2]) == len(dep_col[2])): + # Canonical route: translate through the colour-flow CODE. + # Decode the base's code into its connections, relabel the legs + # with the crossing permutation, re-encode in this subprocess's + # slot order and look the result up in its own code table. The + # tables are per-ME (shared by every crossing of the same base), + # where COLMAP was one array per base-flavor pair. + col_used = True + if base_index not in baked_col: + bcode, bcs, bas = base_col + names = ('XCCD%d' % (base_index + 1), + 'XCCS%d' % (base_index + 1), + 'XCAS%d' % (base_index + 1)) + for nm, vals in zip(names, (bcode, bcs, bas)): + decl.append(' INTEGER %s(%d)' % (nm, len(vals))) + decl.append(' DATA %s /%s/' % ( + nm, ','.join(str(x) for x in vals))) + baked_col[base_index] = names + cdn, csn, asn = baked_col[base_index] + ns = len(dep_col[1]) + if not perm_called: + dispatch.append( + ' CALL CR%d_GET_CROSS_PERM(%d, XPERM, XSGN,' + ' XDUMF)' % (base_index + 1, iflav)) + encode_used = True + dispatch += [ + ' IF (ICOL.GE.1.AND.ICOL.LE.%d) THEN' % len(colmap), + ' XCBAS = %s(ICOL)' % cdn, + ' XCNEW = 0', + ' DO XCI=1,%d' % ns, + ' XCL = XPERM(XDCS(XCI))', + ' XCJ = 1', + ' DO XCK=1,%d' % ns, + ' IF (%s(XCK).EQ.XCL) XCJ = XCK' % csn, + ' ENDDO', + ' XCD = MOD(XCBAS / %d**(XCJ-1), %d)' % (ns, ns), + ' XCL = XPERM(%s(XCD+1))' % asn, + ' DO XCK=1,%d' % ns, + ' IF (XDAS(XCK).EQ.XCL) XCD = XCK-1', + ' ENDDO', + ' XCNEW = XCNEW + XCD * %d**(XCI-1)' % ns, + ' ENDDO', + ' DO XCK=1,%d' % len(dep_col[0]), + ' IF (XDCD(XCK).EQ.XCNEW) ICOL = XCK', + ' ENDDO', + ' ENDIF', + ] + else: + # No usable code (no colour basis, or a flow that is not a + # clean colour<->anticolour bijection): keep the explicit map. + cname = 'COLMAP_%s_%d' % (proc_id, flav0 + 1) + decl.append(' INTEGER %s(%d)' % (cname, len(colmap))) + decl.append(' DATA %s /%s/' % ( + cname, ','.join(str(x) for x in colmap))) + dispatch.append(' IF (ICOL.GE.1.AND.ICOL.LE.%d)' + ' ICOL = %s(ICOL)' % (len(colmap), cname)) + if dispatch: + dispatch.append(' ENDIF') + if col_used: + dcode, dcs, das = dep_col + for nm, vals in (('XDCD', dcode), ('XDCS', dcs), ('XDAS', das)): + decl = [' INTEGER %s(%d)' % (nm, len(vals)), + ' DATA %s /%s/' % ( + nm, ','.join(str(x) for x in vals))] + decl + decl = [' INTEGER XCI, XCJ, XCK, XCD, XCL, XCNEW, XCBAS'] \ + + decl + if encode_used: + decl = [' INTEGER XPERM(NEXTERNAL), XSGN(NEXTERNAL), XDUMF', + ' INTEGER XBDIG(NEXTERNAL), XHR, XHK'] + decl + replace_dict['smatrix_router_decl'] = '\n'.join(decl) + replace_dict['smatrix_router_dispatch'] = '\n'.join(dispatch) + replace_dict.setdefault('smatrix_router_helper', '') + tpl = open(pjoin(_file_path, 'iolibs', 'template_files', + 'matrix_madevent_group_router_v4.inc')).read() + writer.writelines(misc.apply_template(tpl, replace_dict)) + # Router adds no new matrix-element calls; report the module's own color + # count so the group's maxflow sizing stays an upper bound. + calls, ncolor = replace_dict['return_value'] + return 0, ncolor + #=========================================================================== # generate_subprocess_directory #=========================================================================== @@ -7624,17 +13747,210 @@ def generate_subprocess_directory(self, subproc_group, except KeyError: self.proc_characteristic['hel_recycling'] = False self.opt['hel_recycling'] = False + + # Crossing merge: partition the group's matrix elements so that a base + # subprocess keeps its own (crossing-aware) matrix element and the others + # -- whose every flavor is a crossing of a base flavor -- get only a + # light router matrix.f that dispatches to the base SMATRIX with the + # crossed FLAV_IDX (see partition_crossing_classes / the router template). + group_use_crossing = ( + self.opt.get('use_crossing', False) + and not any(self.breaks_crossing_symmetry(proc) + for me in matrix_elements + for proc in me.get('processes'))) + if group_use_crossing: + crossing_bases, crossing_routing = \ + self.partition_crossing_classes(matrix_elements) + crossing_bases = set(crossing_bases) + # A base that actually serves a router must publish its per-flow JAMP2 + # (COMMON/TO_XG_JAMP2), so the router can reselect colour with its OWN + # ICOLAMP row instead of relabelling the base's masked pick -- see the + # XG_SELCOL call in write_matrix_router_file. Recorded before the write + # loop below, since a base can be written before or after its routers. + # Deliberately NOT merged into _crossgroup_base_mes: that set also + # drives the Track-B-only XGROW multi-channel row, which a within-group + # base must not get. + router_bases = getattr(self, '_router_base_mes', None) + if router_bases is None: + router_bases = self._router_base_mes = set() + for idep, route in enumerate(crossing_routing or []): + if route is None or idep in crossing_bases: + continue + for (base_index, _iflav) in route: + router_bases.add(id(matrix_elements[base_index])) + # Flag the run interface that this output relies on crossing: a shared + # matrix element is reused across physically distinct (crossed) initial + # states. That is fine for the unpolarised proton PDFs, but it is NOT + # compatible with per-beam polarisation or the EVA luminosity, which + # depend on the actual beam particle. Tag the limitation only when + # crossing is materially applied (a router, or a base that evaluates a + # cross>0 flavor), so ordinary polarised runs are not blocked for + # nothing. check_card_consistency turns this into a clear error. + crossing_applied = len(crossing_bases) < len(matrix_elements) or any( + (iflav - 1) // len(matrix_elements[base_index] + .get_external_flavors_with_iden()) > 0 + for route in crossing_routing if route is not None + for (base_index, iflav) in route) + if crossing_applied and \ + 'crossing' not in self.proc_characteristic['limitations']: + self.proc_characteristic['limitations'].append('crossing') + # Record each router's base->base helicity SIGN map tau, exactly as a + # cross-group dependent does (crossgroup_helunion.dat). A router sends + # its call into the base SMATRIX, and with helicity recycling that is + # the RECYCLED matrix_optim.f, whose helicity configs are baked + # into the HELAS calls -- it takes no runtime NHEL, so it cannot apply + # the crossing's slot PERMUTATION the way matrix_orig.f does + # (CR_APPLY_CROSSING_TABLE permutes NHEL along with the momenta). + # It can only apply the NSF sign flips, through IC. tau is exactly + # that residual transform, and optim row hb is non-zero for the + # crossing iff tau[hb] is good for the base -- so the base's own + # good-hel SUBSET is not closed under it, and a pruned optim silently + # drops part of the routed process's helicity sum. gen_ximprove bakes + # the optim over G_base U tau(G_base) from these lines (and skips the + # C-parity de-duplication, whose |M|^2 identity is only established + # for cross 0), which is what the Track B path already does. + for idep, route in enumerate(crossing_routing or []): + if route is None or idep in crossing_bases: + continue + for (base_index, iflav) in route: + base_me = matrix_elements[base_index] + nflav_base = len(base_me.get_external_flavors_with_iden()) + pi = self._crossgroup_base_helsignmap( + base_me, (iflav - 1) // nflav_base) + if pi is None: + # Not a clean permutation (the crossed legs' helicity + # states are not closed under negation). + # matrix_orig.f has a run-time escape for that -- + # GHIDX=0 makes it compute every helicity -- but the + # recycled optim is baked and has none, and we cannot say + # which configs the router needs. The all-zero row is the + # keep-every-config sentinel gen_ximprove understands. + pi = [0] * base_me.get_helicity_combinations() + # An identity tau (the crossing moves no leg between the + # initial and the final state) needs no extra config, but the + # line is still written: a non-empty perms list is also what + # marks this matrix element as shared by a crossing, which + # gen_ximprove needs to keep the C-parity de-duplication off. + perms = self._crossgroup_helperms.setdefault( + subprocdir, {}).setdefault(base_index + 1, []) + if pi not in perms: + perms.append(pi) + else: + crossing_bases, crossing_routing = None, None + # Per base: {crossing -> (dependent proc_id, dep-diagram -> base-diagram + # map)}. The base's multi-channel loop needs both to weight a routed call + # correctly -- the row says which configs to enumerate (they pair with + # GET_CHANNEL_CUT on the dependent's momenta), the map turns each of that + # subprocess's diagrams into the AMP2 slot the crossed evaluation filled. + # See fill_crossing_replace_dict_me. + base_xgrow = {} + if crossing_routing is not None: + cfg_cache = {} + for idep, route in enumerate(crossing_routing): + if route is None or idep in crossing_bases: + continue + for (base_index, iflav) in route: + base_me = matrix_elements[base_index] + cross = (iflav - 1) // len( + base_me.get_external_flavors_with_iden()) + if not cross: + continue + key = (idep, base_index, cross) + if key not in cfg_cache: + cfg_cache[key] = self._crossgroup_configmap( + matrix_elements[idep], base_me, cross) + base_xgrow.setdefault(base_index, {})[cross] = ( + idep + 1, cfg_cache[key]) + + def _xgrow_kw(ime): + """Crossing kwargs for this subprocess, or nothing at all. + + Only a Track-A base that actually has a crossed subprocess routed to + it needs the multi-channel row map. Everything else goes through an + exporter whose write_matrix_element_v4 does not take the crossing + kwargs -- notably the loop-induced one, and a loop-induced matrix + element never crosses anyway (see the perturbative gate in + generate_matrix_elements) -- so handing it the kwarg is a TypeError. + """ + xg = base_xgrow.get(ime) + return {'xgrow_map': xg} if xg else {} + for ime, matrix_element in \ enumerate(matrix_elements): - if self.opt['hel_recycling']: + crossgroup = self._crossgroup.get((group_number, ime)) + if crossgroup is not None: + # Cross-group dependent (Track B): this subprocess's matrix + # element is a crossing of a base group's, in another P directory. + # It generates NO matrix element of its own -- it symlinks the + # base group's compiled crossing-aware SMATRIX (built once there) + # and its auto_dsig routes to it with the crossed FLAV_IDX. Only + # the flavor table (for the PDF) and phase space stay local. + for fname in self._crossgroup_base_files(crossgroup['base_proc_id']): + ln(pjoin('..', crossgroup['base_dir'], fname), log=False) + # Reuse the base group's COMPILED objects (do not recompile the + # symlinked source): crossgroup.mk (included by the shared makefile) + # symlinks matrix_{orig,optim}.o from the base dir, building them + # there first. Also record the dir pair for the parallel top-level + # makefile written at finalize. + self.write_crossgroup_mk(crossgroup['base_dir'], + crossgroup['base_proc_id']) + self._crossgroup_dirs.append((subprocdir, crossgroup['base_dir'])) + # Record this dependent's base->base helicity SIGN map(s) tau so + # the base optim can be baked over G_base U tau(G_base) and + # shared. tau, not the GHREMAP sigma: the recycled optim gets only + # (PUSE, IC) and so realises the sign flips without the slot + # permutation -- see _crossgroup_base_helsignmap. + base_me = crossgroup['base_me'] + nflav_base = len(base_me.get_external_flavors_with_iden()) + perms = self._crossgroup_helperms.setdefault( + crossgroup['base_dir'], {}).setdefault( + crossgroup['base_proc_id'], []) + for iflav in crossgroup['flav_idx']: + pi = self._crossgroup_base_helsignmap( + base_me, (iflav - 1) // nflav_base) + if pi is None: + # Keep-every-config sentinel, as in the router branch. + pi = [0] * base_me.get_helicity_combinations() + # An identity tau adds no config, but the line still marks + # the base as crossing-shared for gen_ximprove. + if pi not in perms: + perms.append(pi) + # ncolor for maxflow sizing: crossing preserves the colour basis, + # so the dependent's own count is the base's. writer=None writes + # nothing, it only returns the flavor/colour bookkeeping. + rd = self.write_matrix_element_v4( + None, matrix_element, fortran_model, proc_id=str(ime+1), + config_map=subproc_group.get('diagram_maps')[ime], + subproc_number=group_number) + calls, ncolor = 0, rd['return_value'][1] + elif crossing_routing is not None and ime not in crossing_bases: + # A router shares a base's matrix element and holds no helicities + # to recycle. Name it matrix_router.f so the makefile globs it + # into both build targets while gen_ximprove (which recycles + # matrix*_orig.f) leaves it alone. + filename = 'matrix%d_router.f' % (ime+1) + calls, ncolor = self.write_matrix_router_file( + writers.FortranWriter(filename), matrix_element, + fortran_model, proc_id=str(ime+1), + config_map=subproc_group.get('diagram_maps')[ime], + subproc_number=group_number, + routing=crossing_routing[ime], + matrix_elements=matrix_elements) + elif self.opt['hel_recycling']: filename = 'matrix%d_orig.f' % (ime+1) - replace_dict = self.write_matrix_element_v4(None, + replace_dict = self.write_matrix_element_v4(None, matrix_element, fortran_model, proc_id=str(ime+1), config_map=subproc_group.get('diagram_maps')[ime], - subproc_number=group_number) + subproc_number=group_number, + **_xgrow_kw(ime)) calls,ncolor = replace_dict['return_value'] + # Emit the HELAS call sequence as matrix_origamp.f, one + # subroutine per amp_chunk_size statements, and leave the calls + # to them in MATRIX. Short sequences stay inline, so nothing + # below the high-multiplicity threshold changes at all. + self.write_amp_chunk_files(replace_dict, str(ime+1)) tfile = open(replace_dict['template_file']).read() file = misc.apply_template(tfile, replace_dict) # Add the split orders helper functions. @@ -7656,19 +13972,24 @@ def generate_subprocess_directory(self, subproc_group, writer = writers.FortranWriter('template_matrix%d.f' % (ime+1)) writer.uniformcase = False writer.writelines(file) - + + # ... and the template hel_recycle renders the unrolled call + # sequence into, one file per chunk, the same way. + self.write_amp_chunk_template(replace_dict, ime+1) + else: filename = 'matrix%d.f' % (ime+1) calls, ncolor = \ - self.write_matrix_element_v4(writers.FortranWriter(filename), + self.write_matrix_element_v4(writers.FortranWriter(filename), matrix_element, fortran_model, proc_id=str(ime+1), config_map=subproc_group.get('diagram_maps')[ime], - subproc_number=group_number) + subproc_number=group_number, + **_xgrow_kw(ime)) if second_exporter: process_exporter_cpp = second_exporter.oneprocessclass(matrix_element,second_helas, prefix=ime) @@ -7694,7 +14015,8 @@ def generate_subprocess_directory(self, subproc_group, filename = 'auto_dsig%d.f' % (ime+1) self.write_auto_dsig_file(writers.FortranWriter(filename), matrix_element, - str(ime+1)) + str(ime+1), + crossgroup=crossgroup) # Keep track of needed quantities tot_calls += int(calls) @@ -7725,7 +14047,7 @@ def generate_subprocess_directory(self, subproc_group, filename = 'auto_dsig.f' self.write_super_auto_dsig_file(writers.FortranWriter(filename), - subproc_group) + subproc_group, group_number) filename = 'coloramps.inc' self.write_coloramps_file(writers.FortranWriter(filename), @@ -7763,6 +14085,10 @@ def generate_subprocess_directory(self, subproc_group, self.write_leshouche_file(writers.FortranWriter(filename), subproc_group) + filename = 'colorflow.inc' + self.write_colorflow_file(writers.FortranWriter(filename), + subproc_group) + filename = 'maxamps.inc' # get number of non identical flavor for each matrix element file #for me in matrix_elements: @@ -7873,7 +14199,8 @@ def generate_subprocess_directory(self, subproc_group, #=========================================================================== # write_super_auto_dsig_file #=========================================================================== - def write_super_auto_dsig_file(self, writer, subproc_group): + def write_super_auto_dsig_file(self, writer, subproc_group, + group_number=None): """Write the auto_dsig.f file selecting between the subprocesses in subprocess group mode""" @@ -7968,11 +14295,137 @@ def write_super_auto_dsig_file(self, writer, subproc_group): file = open(pjoin(_file_path, \ 'iolibs/template_files/super_auto_dsig_group_v4.inc')).read() file = file % replace_dict + file += self.write_xgrow_routines(subproc_group, group_number) # Write the file writer.writelines(file) else: return replace_dict + + def write_xgrow_routines(self, subproc_group, group_number): + """Per-directory bodies of the XGROW helpers a cross-group (Track B) + base SMATRIX calls for its multi-channel row (see the me_confsub_j fill). + + The base's compiled matrix object is symlinked into every dependent P + directory, so it cannot carry the row itself: the row belongs to the + subprocess the call is FOR, and that subprocess's CONFSUB lives in ITS + directory. Each directory therefore links its own XGROW, resolved by + the linker exactly like genps.o (which is why GET_CHANNEL_CUT(P, I) in + the shared object already means the *dependent's* config I). + + * where the base is generated -- the identity: our own CONFSUB row. Only + cross 0 ever reaches it (a Track-B base group has no within-group + router, so its own auto_dsig calls it with a plain FLAV_IDX). + * in a dependent's directory -- the routed subprocess's own CONFSUB row, + each of its diagrams mapped to the base AMP2 slot the crossed + evaluation filled (_crossgroup_configmap, the same map its auto_dsig + uses for DSIG_XGCONFIG). + + Emitted here because auto_dsig.f is the one file written exactly once per + P directory, so a base serving several dependents in one directory still + gets a single definition. + """ + if group_number is None or not getattr(self, '_crossgroup', None): + return '' + mes = subproc_group.get('matrix_elements') + routines, seen = [], {} + # Bases generated in this directory: identity row. + base_ids = getattr(self, '_crossgroup_base_mes', set()) + for ime, me in enumerate(mes): + if id(me) in base_ids: + seen[ime + 1] = 'base' + routines.append( + '\n SUBROUTINE XGROW%(b)d(CROSS, XGJ)\n' + 'C Multi-channel row of SMATRIX%(b)d in the directory it\n' + 'C is generated in: its own. CROSS is always 0 here.\n' + ' IMPLICIT NONE\n' + " INCLUDE 'maxamps.inc'\n" + " INCLUDE 'maxconfigs.inc'\n" + ' INTEGER CROSS, XGJ(LMAXCONFIGS), I\n' + ' INTEGER CONFSUB(MAXSPROC,LMAXCONFIGS)\n' + " INCLUDE 'config_subproc_map.inc'\n" + ' DO I=1,LMAXCONFIGS\n' + ' XGJ(I) = CONFSUB(%(b)d, I)\n' + ' ENDDO\n' + ' RETURN\n' + ' END\n' % {'b': ime + 1}) + # Dependents routed out of this directory: their own row, remapped. + by_base = {} + for ime, me in enumerate(mes): + cg = self._crossgroup.get((group_number, ime)) + if cg is None: + continue + base_me = cg['base_me'] + nflav_base = len(base_me.get_external_flavors_with_iden()) + ngraphs_b = len(base_me.get('diagrams')) + nxc = (base_me.get_nexternal_ninitial()[0] + 1) ** 2 - 1 + for iflav in cg['flav_idx']: + cross = (iflav - 1) // nflav_base + if not 1 <= cross <= nxc: + continue + cmap = self._crossgroup_configmap(me, base_me, cross) + if sorted(cmap) != list(range(1, ngraphs_b + 1)): + continue # unusable map: leave the historical row + slot = by_base.setdefault( + cg['base_proc_id'], + {'nxc': nxc, 'ng': ngraphs_b, 'cols': [], 'cross': {}}) + col = (ime + 1, tuple(cmap)) + if col not in slot['cols']: + slot['cols'].append(col) + # Two subprocesses claiming the same crossing would be the same + # crossed process; keep the first and leave the rest alone. + slot['cross'].setdefault(cross, slot['cols'].index(col) + 2) + for b in sorted(by_base): + if b in seen: + # This directory both generates SMATRIX and routes to another + # directory's SMATRIX: one name, two bodies. That collision + # already exists for SMATRIX itself, so leave it alone. + logger.warning('Cross-group crossing: SMATRIX%d is both local ' + 'and routed in one directory; keeping the ' + 'historical multi-channel row.' % b) + continue + s = by_base[b] + # Column 1 is the fallback for a crossing this directory does not + # route (unreachable in practice): the first routed subprocess's own + # row, unmapped. + rows = [s['cols'][0][0]] + [c[0] for c in s['cols']] + cfgs = [list(range(0, s['ng'] + 1))] + cfgs += [[0] + list(c[1]) for c in s['cols']] + lines = [ + '\n SUBROUTINE XGROW%d(CROSS, XGJ)' % b, + 'C Multi-channel row of the symlinked SMATRIX%d for a call' % b, + 'C routed out of THIS directory: the routed subprocess own', + 'C CONFSUB row, each diagram mapped to the AMP2 slot the', + 'C crossed evaluation filled.', + ' IMPLICIT NONE', + " INCLUDE 'maxamps.inc'", + " INCLUDE 'maxconfigs.inc'", + ' INTEGER CROSS, XGJ(LMAXCONFIGS), I, IXR', + ' INTEGER CONFSUB(MAXSPROC,LMAXCONFIGS)', + " INCLUDE 'config_subproc_map.inc'", + ' INTEGER XGCOL(0:%d)' % s['nxc'], + self.format_integer_data_lines( + 'XGCOL', [s['cross'].get(c, 1) + for c in range(s['nxc'] + 1)]), + ' INTEGER XGROWP(%d)' % len(rows), + ' DATA XGROWP /%s/' % ','.join(str(x) for x in rows), + ' INTEGER XGCFG(0:%d,%d)' % (s['ng'], len(cfgs))] + for icol, col in enumerate(cfgs): + for st in range(0, len(col), 10): + chunk = col[st:st + 10] + lines.append(' DATA (XGCFG(I,%d),I=%d,%d) /%s/' + % (icol + 1, st, st + len(chunk) - 1, + ','.join(str(v) for v in chunk))) + lines += [' IXR = 1', + ' IF (CROSS.GE.0.AND.CROSS.LE.%d) IXR = XGCOL(CROSS)' + % s['nxc'], + ' DO I=1,LMAXCONFIGS', + ' XGJ(I) = XGCFG(CONFSUB(XGROWP(IXR), I), IXR)', + ' ENDDO', + ' RETURN', + ' END'] + routines.append('\n'.join(lines) + '\n') + return ''.join(routines) #=========================================================================== # write_mirrorprocs @@ -8267,11 +14720,21 @@ def write_leshouche_file(self, writer, subproc_group): for iproc, matrix_element in \ enumerate(subproc_group.get('matrix_elements')): all_lines.extend(self.get_leshouche_lines(matrix_element, - iproc)) + iproc, drop_icolup=True)) # Write the file writer.writelines(all_lines) return True + def write_colorflow_file(self, writer, subproc_group): + """Write colorflow.inc for a subprocess group (one entry per ME).""" + + all_lines = [] + for iproc, matrix_element in \ + enumerate(subproc_group.get('matrix_elements')): + all_lines.extend(self.get_colorflow_lines(matrix_element, iproc)) + writer.writelines(all_lines) + return True + def finalize(self,*args, second_exporter=None, **opts): @@ -11464,6 +17927,37 @@ def create_param_card(self, write_special=True): mssm_convert=True, write_special=write_special) +# The output formats that can serve a loop-induced ([noborn=]) process coming +# through the tree-level do_output: 'madevent' has the LoopInducedExporterME* +# exporters, and a plugin is free to bring its own. Every other format sends +# the LoopHelasMatrixElement to a tree-level exporter that cannot write it. +LOOP_INDUCED_FORMATS = ['madevent', 'plugin'] + +def loop_induced_not_supported_msg(format, process=None): + """Error text for an output format that has no MadLoop backend. + + A loop-induced ([noborn=]) process is exported by the *tree-level* output + machinery: master_interface only borrows the MadLoop interface to validate + the model, then switches back to 'MadGraph' and calls create_loop_induced. + So a format whose exporter cannot write a LoopHelasMatrixElement has to say + so here rather than let the tree-level exporter fail deep inside. + + The same matrix element is available through [sqrvirt=], which does stay in + the MadLoop interface and therefore reaches the MadLoop exporters. + """ + + orders = 'QCD' + if process: + try: + orders = ' '.join(process.get('perturbation_couplings')) or orders + except Exception: + pass + + return """The '%(format)s' output format does not support loop-induced processes. +Generate the process with [sqrvirt=%(orders)s] rather than [noborn=%(orders)s] to obtain the +same matrix element as a standalone MadLoop output, or use 'output madevent' +to integrate it.""" % {'format': format, 'orders': orders} + def ExportV4Factory(cmd, noclean, output_type='default', group_subprocesses=True, cmd_options={}): """ Determine which Export_v4 class is required. cmd is the command interface containing all potential usefull information. @@ -11583,8 +18077,14 @@ def ExportV4Factory(cmd, noclean, output_type='default', group_subprocesses=True opt.update({'clean': not noclean, 'complex_mass': cmd.options['complex_mass_scheme'], 'export_format':cmd._export_format, - 'mp': False, - 'sa_symmetry':False, + 'mp': False, + 'sa_symmetry':False, + # --use_crossing of the generate/add process command, and of the + # output command for this output: when off, the standalone + # matrix.f is written without any crossing machinery (see + # ProcessExporterFortranSA.write_matrix_element_v4). + 'use_crossing': getattr(cmd, '_use_crossing', True) + and getattr(cmd, '_output_use_crossing', True), 'model': cmd._curr_model.get('name'), 'v5_model': False if cmd._model_v4_path else True, 'running': cmd._curr_model.get('running_elements'), @@ -11613,6 +18113,16 @@ def ExportV4Factory(cmd, noclean, output_type='default', group_subprocesses=True opt['madanalysis5'] = cmd.options['madanalysis5_path'] if format == 'matrix' or format.startswith('standalone'): + # These formats are served by the tree-level exporter, which cannot + # write a LoopHelasMatrixElement. Unlike the 'madevent' branches + # below there is no loop-induced exporter to fall back on here, so + # point the user at the equivalent [sqrvirt=] generation instead: + # it goes through the MadLoop interface and yields the very same + # matrix element in a standalone MadLoop directory. + if isinstance(cmd._curr_amps[0], + loop_diagram_generation.LoopAmplitude): + raise InvalidCmd(loop_induced_not_supported_msg(format, + curr_proc)) return ProcessExporterFortranSA(cmd._export_dir, opt, format=format) elif format in ['madevent'] and group_subprocesses: diff --git a/madgraph/iolibs/gen_infohtml.py b/madgraph/iolibs/gen_infohtml.py index 2f5dee8e88..db308ea1d1 100755 --- a/madgraph/iolibs/gen_infohtml.py +++ b/madgraph/iolibs/gen_infohtml.py @@ -236,18 +236,28 @@ def define_info_tables(self): return text def get_diagram_nb(self, proc, id): - - path = os.path.join(self.dir, 'SubProcesses', proc, 'matrix%s.f' % id) + nb_diag = 0 - pat = re.compile(r'''Amplitude\(s\) for diagram number (\d+)''' ) - if not os.path.exists(path): - path = os.path.join(self.dir, 'SubProcesses', proc, 'matrix%s_orig.f' % id) + path = None + for suffix in ('%s.f', '%s_orig.f', '%s_router.f'): + cand = os.path.join(self.dir, 'SubProcesses', proc, + 'matrix' + suffix % id) + if os.path.exists(cand): + path = cand + break + # A crossing-router subprocess shares a base subprocess's matrix element + # (its matrix_router.f holds no diagrams of its own), so it has no + # diagram-number comment: count 0. + if path is None: + return 0 text = open(path).read() + match = None for match in re.finditer(pat, text): pass - nb_diag += int(match.groups()[0]) - + if match is not None: + nb_diag += int(match.groups()[0]) + return nb_diag diff --git a/madgraph/iolibs/helas_call_writers.py b/madgraph/iolibs/helas_call_writers.py index 2910e6a66d..5b5b4a126c 100755 --- a/madgraph/iolibs/helas_call_writers.py +++ b/madgraph/iolibs/helas_call_writers.py @@ -231,19 +231,87 @@ def get_matrix_element_calls(self, matrix_element): me = matrix_element.get('diagrams') matrix_element.reuse_outdated_wavefunctions(me) + # A current sum has to be written out once both currents are there. + # Doing it as soon as the later of the two is made keeps them alive: + # a slot is only handed on after its last use, and the cubic one is + # still needed by the amplitude the sum is for. + sums, uses, folded = self.get_quartic_current_sums(matrix_element) + slots = matrix_element.get_quartic_sum_me_ids() + after = {} + for i, (cubic, quartic, coeff) in enumerate(sums): + after.setdefault(max(cubic.get('number'), quartic.get('number')), + []).append(i) + res = [] + written = set() for diagram in matrix_element.get('diagrams'): - - - res.extend([ self.get_wavefunction_call(wf) for \ - wf in diagram.get('wavefunctions') ]) + + + for wf in diagram.get('wavefunctions'): + res.append(self.get_wavefunction_call(wf)) + for i in after.get(wf.get('number'), []): + # a wavefunction number can be listed by more than one + # diagram, and the sum must only be written once + if i in written: + continue + written.add(i) + cubic, quartic, coeff = sums[i] + res.extend(self.get_current_sum_lines( + slots[i], cubic, quartic, coeff)) res.append("# Amplitude(s) for diagram number %d" % \ diagram.get('number')) for amplitude in diagram.get('amplitudes'): - res.append(self.get_amplitude_call(amplitude)) + if amplitude.get('number') in folded: + # summed into another amplitude through a current sum + continue + res.append(self.get_amplitude_call_on_sums( + amplitude, uses.get(amplitude.get('number')), slots)) + + res.extend(self.get_amplitude_merge_lines(matrix_element)) return res + def get_quartic_current_sums(self, matrix_element): + """The current sums to write out. Only the Fortran writer knows how to + emit one, see FortranUFOHelasCallWriter.""" + + return [], {}, set() + + def get_current_sum_lines(self, number, cubic, quartic, coeff): + """Lines building one current sum. Fortran only.""" + + raise NotImplementedError + + def get_amplitude_call_on_sums(self, amplitude, substitution, slots): + """The amplitude call, reading the current sums in place of the cubic + currents they were built from. + + The slot is swapped on the mother itself and put back straight away, + the same way get_loop_amplitude_helas_calls relabels its externals.""" + + if not substitution: + return self.get_amplitude_call(amplitude) + + original = [] + for mother in amplitude.get('mothers'): + index = substitution.get(mother.get('number')) + if index is None: + continue + original.append((mother, mother.get('me_id'))) + mother.set('me_id', slots[index]) + try: + return self.get_amplitude_call(amplitude) + finally: + for mother, me_id in original: + mother.set('me_id', me_id) + + def get_amplitude_merge_lines(self, matrix_element): + """Lines summing the quartic contributions into the amplitude which + carries the same colour factor. Only the Fortran writer implements + this, see FortranUFOHelasCallWriter.""" + + return [] + def get_wavefunction_calls(self, wavefunctions): """Return a list of strings, corresponding to the Helas calls for the matrix element""" @@ -285,9 +353,12 @@ def get_wavefunction_call(self, wavefunction): call = fct(wavefunction) - if self.options['zerowidth_tchannel'] and wavefunction.is_t_channel(): - call, n = re.subn(r',\s*fk_(?!ZERO)\w*\s*,', ', ZERO,', str(call), flags=re.I) - if n: + if self.options['zerowidth_tchannel'] and wavefunction.is_t_channel(): + # The width i*M*Gamma is now dropped inside the ALOHA propagator + # routine itself, at runtime, for spacelike (P^2<0) momenta -- see + # aloha.t_channel_width / aloha_writers. We no longer rewrite the call + # to pass ZERO; we only flag a non-zero width here for the notice. + if re.search(r',\s*fk_(?!ZERO)\w*\s*,', str(call), flags=re.I): self.width_tchannel_set_tozero = True return call @@ -1027,6 +1098,60 @@ class FortranUFOHelasCallWriter(UFOHelasCallWriter): mp_prefix = check_param_card.ParamCard.mp_prefix + def get_amplitude_merge_lines(self, matrix_element): + """Sum every quartic contribution into the amplitude carrying the + same colour factor. + + The two share a colour factor up to a rational coefficient, so adding + them here lets the JAMPs run over the cubic diagrams only, which is + what the JAMP optimiser then has to work with. The amplitudes summed + away are dropped from the colour amplitudes by + HelasMatrixElement.get_color_amplitudes.""" + + merges = matrix_element.get_quartic_amplitude_merges() + if not merges: + return [] + # these were never computed: their current was summed instead + folded = self.get_quartic_current_sums(matrix_element)[2] + + res = ['# Sum the quartic contributions into their cubic partner'] + for source in sorted(merges): + if source in folded: + continue + target, coeff = merges[source] + if coeff == 1: + res.append('AMP(%d) = AMP(%d) + AMP(%d)' % + (target, target, source)) + elif coeff == -1: + res.append('AMP(%d) = AMP(%d) - AMP(%d)' % + (target, target, source)) + else: + res.append('AMP(%d) = AMP(%d) + (%.15e)*AMP(%d)' % + (target, target, float(coeff), source)) + return res + + def get_quartic_current_sums(self, matrix_element): + """The current sums, see HelasMatrixElement.get_quartic_current_sums""" + + return matrix_element.get_quartic_current_sums() + + def get_current_sum_lines(self, number, cubic, quartic, coeff): + """Sum the quartic current into the cubic one carrying the same colour + factor, so that the amplitude reading the sum gets both at once. + + Written as a call rather than as two assignments so that everything + reading these files -- the helicity recycling in particular, which + rebuilds the DAG from the calls alone -- sees an ordinary internal + wavefunction taking two mothers. sumw_1 and subw_1 live in + aloha_functions.f; the coefficient is always +-1, see + HelasMatrixElement.compute_quartic_current_sums.""" + + return ['CALL %s(%s,%s,%s)' % ( + 'SUMW_1' if coeff == 1 else 'SUBW_1', + self.format_helas_object('W(', '%d') % cubic.get('me_id'), + self.format_helas_object('W(', '%d') % quartic.get('me_id'), + self.format_helas_object('W(', '%d') % number)] + def __init__(self, argument={}, hel_sum = False, options={}): """Allow generating a HelasCallWriter from a Model.The hel_sum argument specifies if amplitude and wavefunctions must be stored specifying the @@ -1042,6 +1167,11 @@ def __init__(self, argument={}, hel_sum = False, options={}): self.use_flavor_mask = False self.me_n_flavors = 0 self.me_active_flavor_mask = None + # When True the external wavefunction NSF/NSV flag is multiplied by + # IC(i), letting the caller cross a leg between the initial and the + # final state. Only the exporters whose template passes a meaningful + # IC turn this on (see generate_external_wavefunction). + self.use_crossing_ic = False super(FortranUFOHelasCallWriter, self).__init__(argument, options=options) def format_helas_object(self, prefix, number): @@ -1203,14 +1333,30 @@ def generate_external_wavefunction(self,argument): else: call = call + "%(mass)s," call = call + "NHEL(%(number_external)d)," + wf_object = self.format_helas_object('W(', '%(me_id)d') if argument.get('spin') == 2: - call = call + "%(state_id)+d, FLAVOR(%(number_external)d),{0})".format(\ - self.format_helas_object('W(','%(me_id)d')) + suffix = ", FLAVOR(%(number_external)d)," + wf_object + ")" else: - call = call + "%(state_id)+d,{0})".format(\ - self.format_helas_object('W(','%(me_id)d')) - - call_function = lambda wf: call % wf.get_external_helas_call_dict() + suffix = "," + wf_object + ")" + # Two variants of the NSF/NSV flag: bare, or multiplied by IC so + # that the caller can flip a leg between the initial and the final + # state (crossing). Flipping that flag is what crosses the leg: + # helas stores the momentum as p*nsf and uses nhel*nsf. Only + # templates that actually pass a meaningful IC may use the second + # form -- several (e.g. the madevent MATRIX) declare IC as a local + # and never set it, so reading it there would give garbage. + call = (call + "%(state_id)+d" + suffix, + call + "%(state_id)+d*IC(%(number_external)d)" + suffix) + + if isinstance(call, tuple): + # The flag is read at emission time, not here: a single writer + # instance is reused across outputs (standalone then madevent), so + # the choice must not be baked into the cached call. + call_function = lambda wf: \ + call[1 if self.use_crossing_ic else 0] % \ + wf.get_external_helas_call_dict() + else: + call_function = lambda wf: call % wf.get_external_helas_call_dict() self.add_wavefunction(argument.get_call_key(), call_function) def generate_all_other_helas_objects(self,argument): @@ -1676,6 +1822,10 @@ def __init__(self, argument={}, options={}): self.use_flavor_mask = False self.me_n_flavors = 0 self.me_active_flavor_mask = None + # When True, external HELAS calls permute the helicity through perm[] + # and multiply their NSF flag by ic[] so a crossed leg flips (set by the + # standalone_cpp exporter around calculate_wavefunctions generation). + self.use_crossing_ic = False super(CPPUFOHelasCallWriter, self).__init__(argument, options=options) def _flavor_mask_prefix(self, obj, kind): @@ -1703,6 +1853,34 @@ def _flavor_mask_prefix(self, obj, kind): bit = (idx - 1) % 64 return 'if ((%s[%d] & (1ULL << %d)) != 0ULL) ' % (array, word, bit) + def _cpp_external_call(self, wf, routine, spin, is_boson): + """Build the ixxxxx/oxxxxx/vxxxxx/sxxxxx call for an external leg. + + When self.use_crossing_ic is False this reproduces the historical call + byte-for-byte. When True the helicity is read through perm[] and the NSF + flag is multiplied by ic[], so a leg the crossing moved between the + initial and the final state flips (helas folds the momentum sign change + and the helicity flip out of that flag).""" + n = wf.get('number_external') - 1 + me = wf.get('me_id') - 1 + if not is_boson: + # For fermions, need particle/antiparticle + nsf = - (-1) ** wf.get_with_flow('is_part') + else: + # For bosons (incl. scalars), need initial/final + nsf = (-1) ** (wf.get('state') == 'initial') + cross = getattr(self, 'use_crossing_ic', False) + hel_tok = ('hel[perm[%d]]' % n) if cross else ('hel[%d]' % n) + nsf_tok = ('%+d*ic[%d]' % (nsf, n)) if cross else ('%+d' % nsf) + if spin == 1: + return '%s(p[perm[%d]],%s,w[%d]);' % (routine, n, nsf_tok, me) + elif spin == 2: + return '%s(p[perm[%d]],mME[%d],%s,%s, flavor[%d],w[%d]);' % \ + (routine, n, n, hel_tok, nsf_tok, n, me) + else: + return '%s(p[perm[%d]],mME[%d],%s,%s,w[%d]);' % \ + (routine, n, n, hel_tok, nsf_tok, me) + def generate_helas_call(self, argument): """Routine for automatic generation of C++ Helas calls according to just the spin structure of the interaction. @@ -1743,50 +1921,17 @@ def generate_helas_call(self, argument): if isinstance(argument, helas_objects.HelasWavefunction) and \ not argument.get('mothers'): # String is just ixxxxx, oxxxxx, vxxxxx or sxxxxx - call = call + HelasCallWriter.mother_dict[\ + routine = HelasCallWriter.mother_dict[\ argument.get_spin_state_number()].lower() # Fill out with X up to 6 positions - call = call + 'x' * (6 - len(call)) - # Specify namespace for Helas calls - call = call + "(p[perm[%d]]," - if argument.get('spin') != 1: - # For non-scalars, need mass and helicity - call = call + "mME[%d],hel[%d]," - if argument.get('spin') == 2: - call = call + "%+d, flavor[%i],w[%d]);" - else: - call = call + "%+d,w[%d]);" - if argument.get('spin') == 1: - call_function = lambda wf: call % \ - (wf.get('number_external')-1, - # For boson, need initial/final here - (-1) ** (wf.get('state') == 'initial'), - wf.get('me_id')-1) - elif argument.is_boson(): - call_function = lambda wf: call % \ - (wf.get('number_external')-1, - wf.get('number_external')-1, - wf.get('number_external')-1, - # For boson, need initial/final here - (-1) ** (wf.get('state') == 'initial'), - wf.get('me_id')-1) - elif argument.get('spin') == 2: - call_function = lambda wf: call % \ - (wf.get('number_external')-1, - wf.get('number_external')-1, - wf.get('number_external')-1, - # For fermions, need particle/antiparticle - - (-1) ** wf.get_with_flow('is_part'), - wf.get('number_external')-1, - wf.get('me_id')-1) - else: - call_function = lambda wf: call % \ - (wf.get('number_external')-1, - wf.get('number_external')-1, - wf.get('number_external')-1, - # For fermions, need particle/antiparticle - - (-1) ** wf.get_with_flow('is_part'), - wf.get('me_id')-1) + routine = routine + 'x' * (6 - len(routine)) + spin = argument.get('spin') + is_boson = argument.is_boson() + # The crossing decision (use_crossing_ic) is read at emission time, + # inside the cached lambda, because one session reuses the writer + # across a crossing output and a plain one (see the fortran writer). + call_function = lambda wf: self._cpp_external_call( + wf, routine, spin, is_boson) else: if isinstance(argument, helas_objects.HelasWavefunction): outgoing = argument.find_outgoing_number() diff --git a/madgraph/iolibs/jamp_optimiser.py b/madgraph/iolibs/jamp_optimiser.py new file mode 100644 index 0000000000..2b985c9bd1 --- /dev/null +++ b/madgraph/iolibs/jamp_optimiser.py @@ -0,0 +1,793 @@ +################################################################################ +# +# Copyright (c) 2009 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 +# +################################################################################ +"""Language neutral part of the color flow (JAMP) optimisation. + +Every backend writes the same object: the matrix of coefficients giving each +color flow as a combination of the amplitudes, + + JAMP(i) = sum_j A(i,j) * AMP(j) + +Written out as it stands that is one line per non zero entry, which for a +multi-gluon process is tens of thousands of them. The search below replaces the +repeated pieces by shared sub-expressions, so that the matrix is left with far +fewer entries and a list of definitions to compute first. + +Two searches are here, and optimise_jamp_best picks between them: the plain +greedy scan, which takes whatever sub-expression is worth most at each step, +and the orbit equivariant one, which only takes whole orbits of the +permutations leaving the color basis invariant and so leaves the matrix +invariant at every step. + +Nothing here knows about fortran or C++: it takes the coefficient matrix and +gives back the reduced matrix and the definitions. The exporters print that in +their own language (get_JAMP_lines for fortran, build_jamp_plan for the +C++/cudacpp writer), and how they print it is what decides which of the +optimisations they may use -- see jamp_orbit_allowed and +jamp_greedy_tail_enabled. +""" + +from __future__ import absolute_import + +import bisect +import collections +import fractions +import logging +import time + +import madgraph.core.color_amp as color_amp +import madgraph.core.helas_objects as helas_objects +import madgraph.various.banner as banner_mod + +logger = logging.getLogger('madgraph.export_v4') + + +class JampOptimiser(object): + """The common sub-expression search over the JAMP coefficient matrix. + + Mixed into the exporters, which supply the printing.""" + + # Off by default: the plain output of a backend is the expanded one, and + # each exporter switches this on for itself. 'jamp_optim' in cmd_options + # (i.e. --jamp_optim=True|False at output time) wins over the class value. + jamp_optim = False + # how many times the JAMP optimisation called itself, for the record + myjamp_count = 0 + # take the power of i shared by every coefficient out before searching, so + # that the search walks over whole numbers (see optimise_jamp_matrix) + jamp_integer_walk = True + # Introduce the sub-expressions by whole orbits of the permutations leaving + # the color basis invariant instead of one at a time (see + # optimise_jamp_equivariant). Off by default, each backend switches it on + # for itself where it measured a gain. + jamp_orbit = False + # finish with the plain scan once the orbit rounds have nothing left to + # take as a whole (see jamp_greedy_tail_enabled) + jamp_greedy_tail = True + # up to this many entries in the matrix, both optimisations are run and the + # shorter result kept (see optimise_jamp_best) + jamp_compare_max_size = 20000 + # what the orbit rounds did, for an emission which wants to describe the + # definitions by one recipe per orbit rather than one by one + jamp_orbits = None + + def jamp_optim_enabled(self): + """Whether to run the optimisation, --jamp_optim first.""" + + cmd_options = getattr(self, 'cmd_options', None) or {} + if 'jamp_optim' in cmd_options: + return banner_mod.ConfigFile.format_variable( + cmd_options['jamp_optim'], bool, 'jamp_optim') + return self.jamp_optim + + @staticmethod + def jamp_matrix(color_amplitudes): + """The coefficient matrix all_element[(color flow, amplitude)] = value + of the color amplitudes, color flows numbered from 1 and amplitudes as + they number themselves. This is the input of the optimisation, and the + same value the expanded lines are written with.""" + + all_element = {} + # Every single amplitude carries a power of the number of colors in its + # coefficient, but a process only uses a handful of distinct powers, so + # build the corresponding fractions once instead of once per amplitude. + nc_powers = {} + for i, coeff_list in enumerate(color_amplitudes): + for (coefficient, amp_number) in coeff_list: + if not coefficient: + continue + try: + nc_power = nc_powers[coefficient[3]] + except KeyError: + nc_power = fractions.Fraction(3)**coefficient[3] + nc_powers[coefficient[3]] = nc_power + value = (1j if coefficient[2] else 1) * \ + coefficient[0] * coefficient[1] * nc_power + key = (i + 1, amp_number) + if key not in all_element: + all_element[key] = value + else: + all_element[key] += value + return all_element + + def jamp_walk_integers(self, all_element): + """Take the power of i shared by every coefficient out of the matrix + and return it, leaving all_element with whole numbers -- they compare + and hash exactly, and nothing has to be widened to complex. Returns + None when there is no such phase, all_element then being complex + throughout. The phase goes back on with jamp_apply_phase, so the lines + written are the same either way.""" + + phase = self.jamp_global_phase(all_element) \ + if self.jamp_integer_walk else None + if phase is not None: + whole = {} + for key, value in all_element.items(): + number = value / phase if phase != 1 else value + if isinstance(number, complex): + number = number.real + number = fractions.Fraction(number).limit_denominator(10**9) + if number.denominator != 1: + break + whole[key] = int(number) + else: + all_element.clear() + all_element.update(whole) + return phase + for key in all_element: + all_element[key] = complex(all_element[key]) + return None + + @staticmethod + def jamp_apply_phase(new_mat, phase): + """Put back the phase jamp_walk_integers took out. The definitions hold + ratios, which it cancels out of; only the coefficients left in the + matrix carry it.""" + + if phase is None or phase == 1: + return + for key in new_mat: + new_mat[key] = new_mat[key] * phase + + def optimise_jamp_matrix(self, all_element, symmetry=None, + matrix_element=None): + """Run the optimisation over the coefficient matrix and return + (new_mat, defs): + - defs is a list of (i, op1, op2, frac, nb): definition number i is + op1 + frac*op2, where a positive operand is an amplitude and a + negative one is the definition number -op; + - new_mat is what the matrix is left with, keyed the same way as the + input except that a negative amplitude index means the definition + of that number. + + With a matrix element and jamp_orbit_allowed saying so, the color basis + symmetry is read off the matrix and the sub-expressions are introduced + by whole orbits of it; the orbits are left in self.jamp_orbits. + + all_element is consumed (the optimisation works in place). The fortran + exporter runs the three steps itself, since it has its own way of + finding the matrix element the color amplitudes came from.""" + + if len(all_element) > 1000: + logger.info("Computing Color-Flow optimization [%s term]", + len(all_element)) + start_time = time.time() + else: + start_time = 0 + + self.myjamp_count = 0 + self.jamp_orbits = None + phase = self.jamp_walk_integers(all_element) + # the symmetry is read off the matrix once the phase is out of it, so + # that the columns compare as whole numbers + if symmetry is None and matrix_element is not None and \ + self.jamp_orbit_allowed(matrix_element): + symmetry = self.get_jamp_symmetry(matrix_element, all_element) + new_mat, defs = self.optimise_jamp(all_element, symmetry=symmetry) + self.jamp_apply_phase(new_mat, phase) + if start_time: + logger.info("Color-Flow passed to %s term in %ss. Introduce %i contraction", + len(new_mat), int(time.time()-start_time), len(defs)) + return new_mat, defs + + @staticmethod + def jamp_global_phase(all_element): + """The power of i every coefficient carries, when they all carry the + same one. A pure gluon process picks up one factor of i per f^abc, the + same for every term, so the whole matrix is real or wholly imaginary; + a quark line mixes the two and there is nothing to take out.""" + + phase = None + for value in all_element.values(): + if not value: + continue + number = complex(value) + if number.imag == 0: + here = 1 + elif number.real == 0: + here = 1j + else: + return None + if phase is None: + phase = here + elif phase != here: + return None + return phase + + @staticmethod + def index_jamp_matrix(all_element, nb_col): + """Sorted lists of the positions of the non zero entries of the matrix, + by line and by column. An entry which is present but zero does not + count, and neither does a column outside the 0..nb_col range, so that + these indices list exactly the entries the plain scan would look at.""" + + lines = collections.defaultdict(list) + columns = collections.defaultdict(list) + for (i, j), value in all_element.items(): + if value and j < nb_col: + lines[i].append(j) + columns[j].append(i) + for line in lines.values(): + line.sort() + for column in columns.values(): + column.sort() + return lines, columns + + @staticmethod + def common_jamp_lines(columns, nb_line, j1, j2): + """Lines, in increasing order, where both columns j1 and j2 are non + zero. Both column lists are sorted, so this is a plain merge.""" + + left, right = columns.get(j1, []), columns.get(j2, []) + res = [] + pos1 = pos2 = 0 + while pos1 < len(left) and pos2 < len(right): + if left[pos1] == right[pos2]: + if left[pos1] < nb_line: + res.append(left[pos1]) + pos1 += 1 + pos2 += 1 + elif left[pos1] < right[pos2]: + pos1 += 1 + else: + pos2 += 1 + return res + + def optimise_jamp(self, all_element, nb_line=0, nb_col=0, added=0, + symmetry=None): + """ optimise problem of type Y = A X + A is a matrix (all_element) + X is the fortran name of the input. + The code iteratively add sub-expression jtemp[sub_add] + and recall itself (this is add to the X size) + + With a symmetry (see get_jamp_symmetry) the sub-expressions are + introduced by whole orbits of that symmetry instead of one at a + time, so that the result can be written as one recipe per orbit. + The orbits are then left in self.jamp_orbits. + """ + if symmetry: + return self.optimise_jamp_best(all_element, symmetry) + + self.myjamp_count +=1 + + if not nb_line: + for i,j in all_element: + if i+1 > nb_line: + nb_line = i+1 + if j+1> nb_col: + nb_col = j+1 + if nb_col > 600 and added==0: + all_element1, all_element2 = {}, {} + for (k1,k2) in all_element: + if k2 >= nb_col//2: + all_element2[(k1,1+k2-(nb_col//2))] = all_element[(k1,k2)] + else: + all_element1[(k1,k2)] = all_element[(k1,k2)] + + all_element1, newdef1 = self.optimise_jamp(all_element1) + nb_added1 = len(newdef1) + + all_element2, newdef2 = self.optimise_jamp(all_element2) + + for (k1,k2) in all_element2: + if k2 >= 0: + all_element1[(k1,k2+(nb_col//2)-1)] = all_element2[(k1,k2)] + if k2 < 0: + all_element1[(k1,k2-nb_added1)] = all_element2[(k1,k2)] + # new_def format: added,j1,j2,R, max_count + for k, j1,j2, R, c in newdef2: + if j2 > 0: + k2 = j2+nb_col//2 -1 + else: + k2 = j2-nb_added1 + if j1 > 0: + k1 = j1+nb_col//2 -1 + else: + k1 = j1-nb_added1 + newdef1.append((k+nb_added1, k1, k2, R, c)) + if newdef1: + all_element, new_def = self.optimise_jamp(all_element1, nb_line=0, nb_col=0, added=len(newdef1)) + newdef1 = newdef1 + new_def + return all_element, newdef1 + + # Index of the non zero entries, by line and by column. The matrix is + # very sparse (a color flow only gets a small share of the amplitudes) + # so walking the whole 0..nb_col range for every entry, as looking the + # columns up one by one in the matrix amounts to, spends nearly all of + # its time discovering zeros. + lines, columns = self.index_jamp_matrix(all_element, nb_col) + + max_count = 0 + all_index = [] + # how many lines have the same ratio between two given columns, keyed + # by the two columns and the ratio at once rather than by nested + # dictionaries: this is the innermost loop of the whole optimisation + operation = collections.defaultdict(int) + for (i,j1), v1 in all_element.items(): + line = lines.get(i) + if not line: + continue + for j2 in line[bisect.bisect_right(line, j1):]: + key = (j1, j2, all_element[(i,j2)]/v1) + operation[key] += 1 + count = operation[key] + if count > max_count: + max_count = count + all_index = [key] + elif count == max_count: + all_index.append(key) + + if max_count <= 1: + return all_element, [] + + to_add = [] + for index in all_index: + j1,j2,R = index + first = True + # only the lines where both columns are filled can contribute; the + # substitutions done here can empty some of them, so the values + # still have to be read back from the matrix + for i in self.common_jamp_lines(columns, nb_line, j1, j2): + v1 = all_element.get((i,j1), 0) + v2 = all_element.get((i,j2), 0) + if not v1 or not v2: + continue + if v2/v1 == R: + if first: + first = False + added +=1 + to_add.append((added,j1,j2,R, max_count)) + + all_element[(i,-added)] = v1 + del all_element[(i,j1)] #= 0 + del all_element[(i,j2)] #= 0 + + logger.log(5,"Define %d new shortcut reused %d times", len(to_add), max_count) + new_element, new_def = self.optimise_jamp(all_element, nb_line=nb_line, nb_col=nb_col, added=added) + for one_def in to_add: + new_def.insert(0, one_def) + return new_element, new_def + + @staticmethod + def jamp_operation_count(new_mat, defs): + """Additions the result asks for: one per definition, plus what is left + in each line of the matrix.""" + + terms = collections.Counter() + for jamp, _var in new_mat: + terms[jamp] += 1 + return len(defs) + sum(max(0, count - 1) for count in terms.values()) + + @staticmethod + def jamp_definition_order(defs): + """The definitions in the order they can be computed while the + amplitudes are produced one at a time, together with the amplitude each + one is ready after. + + Returns (order, ready): order lists the definition numbers, ready maps + a definition number onto the last amplitude it needs (transitively). + Sorting by that amplitude keeps the list topological, since a + definition never needs fewer amplitudes than the ones it is built + from.""" + + ready = {} + rank = {} + for position, (i, amp1, amp2, _frac, _nb) in enumerate(defs): + last = 0 + for amp in (amp1, amp2): + last = max(last, amp if amp > 0 else ready[-amp]) + ready[i] = last + rank[i] = position + order = sorted(ready, key=lambda i: (ready[i], rank[i])) + return order, ready + + def optimise_jamp_best(self, all_element, symmetry): + """Taking whole orbits only pays once there is enough of them to share: + on a small matrix it can end up asking for more additions than the plain + scan, which is free to take whatever it likes. g g > t t~ g is such a + case, 46 additions against 39. + + Small matrices are cheap to optimise, so rather than guess where the + turn is, do both and keep the shorter. Above that size only the orbit + version is run: it wins by a wide margin on everything that big, and + the plain scan is the slow one there.""" + + orbit_element, orbit_defs = self.optimise_jamp_equivariant( + dict(all_element), symmetry) + if len(all_element) > self.jamp_compare_max_size: + return orbit_element, orbit_defs + + orbits = self.jamp_orbits + plain_element, plain_defs = self.optimise_jamp(dict(all_element)) + if self.jamp_operation_count(plain_element, plain_defs) < \ + self.jamp_operation_count(orbit_element, orbit_defs): + self.jamp_orbits = None + return plain_element, plain_defs + self.jamp_orbits = orbits + return orbit_element, orbit_defs + + def jamp_orbit_allowed(self, matrix_element): + """Whether the orbit equivariant optimisation is used here, + --jamp_orbit first. A backend which only accepts it for some of the + templates it writes says so by overriding this.""" + + cmd_options = getattr(self, 'cmd_options', None) or {} + if 'jamp_orbit' in cmd_options: + return banner_mod.ConfigFile.format_variable( + cmd_options['jamp_orbit'], bool, 'jamp_orbit') + return self.jamp_orbit + + def jamp_greedy_tail_enabled(self): + """Whether the orbit rounds are finished off by the plain scan. What + the tail adds are ordinary sub-expressions, not orbits of anything, so + an emission which rebuilds the definitions from one recipe per orbit + cannot describe them; one which writes them down can.""" + + return self.jamp_greedy_tail + + #=========================================================================== + # Orbit equivariant version of the JAMP optimisation + #=========================================================================== + # A permutation of the external color indices which maps the color basis + # onto itself (see color_amp.ColorBasisSymmetry) also permutes the columns + # of the JAMP matrix, up to a sign. The whole matrix is then invariant, so + # the sub-expressions the optimisation looks for come in orbits: every one + # of them is worth exactly as much as the others. Introducing a whole orbit + # at a time, rather than one sub-expression at a time as the plain scan + # does, leaves the matrix invariant at every step, and the definitions can + # be written as one recipe per orbit. + + @staticmethod + def jamp_column_form(column): + """Canonical form of one column of the JAMP matrix up to a global sign, + together with the sign which was taken out.""" + + entries = sorted(column.items()) + first = entries[0][1] + sign = -1 if (first.real, first.imag) < (0., 0.) else 1 + return tuple((i, sign * value) for i, value in entries), sign + + @classmethod + def jamp_amp_permutation(cls, columns, induced): + """Permutation of the amplitudes induced by the permutation induced of + the color basis: return {amp: (amp, sign)} such that + + M[induced[i], sigma(j)] = sign(j) * M[i, j] + + or None if the columns are not mapped onto each other. + + Several amplitudes often have the very same column, so the columns are + gathered by their canonical form and one target is taken out of each + group at a time: looking the image up would not give a bijection.""" + + groups = collections.defaultdict(collections.deque) + for j in sorted(columns): + form, sign = cls.jamp_column_form(columns[j]) + groups[form].append((j, sign)) + + action = {} + for j in sorted(columns): + image = dict((induced[i - 1] + 1, value) + for i, value in columns[j].items()) + form, sign = cls.jamp_column_form(image) + group = groups.get(form) + if not group: + return None + target, target_sign = group.popleft() + factor = sign * target_sign + other = columns[target] + if len(other) != len(image) or \ + any(other.get(i) != factor * value + for i, value in image.items()): + return None + action[j] = (target, factor) + return action + + def get_jamp_symmetry(self, matrix_element, all_element): + """Permutations leaving the JAMP matrix invariant: for each of them the + permutation of the color basis lines, and the permutation of the + amplitude columns with the sign that goes with it. None when there is + none, or when the matrix element does not carry a color basis.""" + + if not isinstance(matrix_element, helas_objects.HelasMatrixElement): + return None + color_basis = matrix_element.get('color_basis') + if not color_basis or len(color_basis) < 2: + return None + symmetry = color_amp.ColorBasisSymmetry(sorted(color_basis.keys())) + if not symmetry.generators1: + return None + + columns = collections.defaultdict(dict) + for (i, j), value in all_element.items(): + if value: + columns[j][i] = value + if not columns: + return None + + nb_line = len(symmetry.keys1) + rowperms, actions = [], [] + for induced in symmetry.generators1: + action = self.jamp_amp_permutation(columns, induced) + if action is None: + continue + rowperms.append([0] + [induced[i] + 1 for i in range(nb_line)]) + actions.append(action) + if not actions: + return None + + # one line per orbit is enough to see every sub-expression: any other + # line is the image of one of them, and so are the sub-expressions it + # holds. This is what keeps the scan below from being quadratic in the + # number of terms of the whole matrix. + parent = list(range(nb_line + 1)) + + def find(x): + while parent[x] != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + for rowperm in rowperms: + for i in range(1, nb_line + 1): + ri, rj = find(i), find(rowperm[i]) + if ri != rj: + parent[ri] = rj + line_reps = [i for i in range(1, nb_line + 1) if find(i) == i] + + return {'rowperms': rowperms, 'actions': actions, + 'nb_line': nb_line, 'line_reps': line_reps} + + @staticmethod + def jamp_operation_image(action, operation): + """Image of the sub-expression operation=(j1,j2,R) under one + permutation, and the factor relating the column the image defines to + the image of the column operation defines.""" + + j1, j2, ratio = operation + first, sign1 = action[j1] + second, sign2 = action[j2] + if first < second: + return (first, second, ratio * sign2 / sign1), sign1 + return (second, first, sign1 / (sign2 * ratio)), sign2 * ratio + + def optimise_jamp_equivariant(self, all_element, symmetry): + """Same optimisation as optimise_jamp, but introducing whole orbits of + sub-expressions at a time so that the result is closed under the + symmetry. Fills self.jamp_orbits with, for every definition, the orbit + it belongs to and the definition and permutation it comes from.""" + + actions = [dict(action) for action in symmetry['actions']] + line_reps = symmetry['line_reps'] + added = 0 + defs = [] + # (orbit, parent definition, permutation) for every definition + tree = [] + # the definitions introduced together: none of them uses another, so + # they can be reordered freely + levels = [] + nb_orbit = 0 + + while True: + columns = collections.defaultdict(list) + lines = collections.defaultdict(list) + for (i, j), value in all_element.items(): + if value: + columns[j].append(i) + lines[i].append(j) + for line in lines.values(): + line.sort() + + # every sub-expression is the image of one living on a + # representative line, so only those have to be looked at + candidates = set() + for i in line_reps: + line = lines.get(i, []) + for pos, j1 in enumerate(line): + value = all_element[(i, j1)] + for j2 in line[pos + 1:]: + candidates.add((j1, j2, all_element[(i, j2)] / value)) + + max_count = 0 + best = [] + for operation in candidates: + count = len(self.jamp_operation_lines(all_element, columns, + operation)) + if count > max_count: + max_count, best = count, [operation] + elif count == max_count: + best.append(operation) + if max_count <= 1: + break + + orbits = self.jamp_operation_orbits(actions, best) + first_of_level = added + 1 + for orbit, parent in orbits: + rows = dict((operation, + self.jamp_operation_lines(all_element, columns, + operation)) + for operation in orbit) + if not self.jamp_orbit_usable(rows): + continue + index = {} + for operation in orbit: + added += 1 + index[operation] = added + origin, permutation = parent[operation] + tree.append((nb_orbit, index[origin] if origin else 0, + permutation)) + defs.append((added, operation[0], operation[1], + operation[2], len(rows[operation]))) + nb_orbit += 1 + for operation, new in index.items(): + j1, j2 = operation[0], operation[1] + for i in rows[operation]: + all_element[(i, -new)] = all_element[(i, j1)] + del all_element[(i, j1)] + del all_element[(i, j2)] + for action in actions: + for operation, new in index.items(): + image, factor = self.jamp_operation_image(action, + operation) + action[-new] = (-index[image], factor) + if added < first_of_level: + # nothing could be introduced as a whole orbit + break + levels.append((first_of_level, added)) + logger.log(5, "Define %d new shortcut reused %d times", + added - first_of_level + 1, max_count) + + self.jamp_orbits = {'tree': tree, 'nb_orbit': nb_orbit, + 'levels': levels, 'actions': actions, + 'symmetry': symmetry} + + if self.jamp_greedy_tail_enabled(): + # The orbit rounds stop while the JAMP lines still hold a good many + # terms, since an orbit can only be taken as a whole. The plain + # scan has no such scruple and can still shorten those lines. Its + # sub-expressions are not orbits of anything, so an emission which + # rebuilds them from the recipes cannot have them (see + # jamp_greedy_tail_enabled), but one writing them down does not + # care. + all_element, tail = self.optimise_jamp(all_element, added=added) + defs.extend(tail) + + return all_element, defs + + @staticmethod + def jamp_operation_lines(all_element, columns, operation): + """Lines where both columns of the sub-expression are still there with + its ratio. The values are read from the matrix as it is now, so lines + already taken by an orbit introduced before are simply gone.""" + + j1, j2, ratio = operation + res = [] + for i in columns.get(j1, ()): + value = all_element.get((i, j1), 0) + if not value: + continue + other = all_element.get((i, j2), 0) + if other and other / value == ratio: + res.append(i) + return res + + def jamp_operation_orbits(self, actions, operations): + """Orbits of the sub-expressions, walked breadth first, with the + (sub-expression, permutation) each of them is reached from.""" + + seen = set() + orbits = [] + for start in sorted(operations, key=lambda op: (op[0], op[1], + op[2].real, + op[2].imag)): + if start in seen: + continue + orbit, parent = [start], {start: (None, 0)} + seen.add(start) + queue = collections.deque([start]) + while queue: + current = queue.popleft() + for position, action in enumerate(actions): + image = self.jamp_operation_image(action, current)[0] + if image in seen: + continue + seen.add(image) + parent[image] = (current, position + 1) + orbit.append(image) + queue.append(image) + orbits.append((orbit, parent)) + return orbits + + @staticmethod + def jamp_i_power(factor): + """The exponent of i this factor is, or None when it is not one of the + four powers of i. The factors the optimisation produces are products of + signs and of the i the color coefficients carry, so this is what they + all are in practice.""" + + value = complex(factor) + for exponent, power in enumerate((1, 1j, -1, -1j)): + if value == power: + return exponent + return None + + @staticmethod + def jamp_definition_levels(defs): + """Group the definitions by how deep they sit in their own operands: + one which uses no other is at the first level, and any other one comes + after both of the ones it uses. Nothing inside a level uses anything + else of that level, so they can be reordered freely. + + Read off the operands rather than off the rounds of the optimisation, + so that whatever the plain scan adds at the end lands where it belongs. + The operands of a definition always come before it, so one pass is + enough.""" + + depth = {} + levels = collections.defaultdict(list) + for index, left, right, _ratio, _count in defs: + here = 0 + if left < 0: + here = max(here, depth[-left]) + if right < 0: + here = max(here, depth[-right]) + depth[index] = here + 1 + levels[here + 1].append(index) + return [levels[key] for key in sorted(levels)] + + @staticmethod + def jamp_orbit_usable(rows): + """Restrict an orbit to the entries only one of its sub-expressions + wants, and say whether what is left can be introduced as a whole. Which + of two sub-expressions of the same orbit gets a shared entry cannot be + decided in a way that commutes with the symmetry, so those entries are + left in the matrix and get another chance in a later round.""" + + sizes = set(len(use) for use in rows.values()) + if len(sizes) != 1 or sizes == set([0]): + return False + entry = collections.Counter() + for operation, use in rows.items(): + for i in use: + entry[(i, operation[0])] += 1 + entry[(i, operation[1])] += 1 + if max(entry.values()) == 1: + return True + for operation in list(rows): + rows[operation] = [i for i in rows[operation] + if entry[(i, operation[0])] == 1 + and entry[(i, operation[1])] == 1] + sizes = set(len(use) for use in rows.values()) + return len(sizes) == 1 and sizes != set([0]) diff --git a/madgraph/iolibs/template_files/addmothers.f b/madgraph/iolibs/template_files/addmothers.f index 85fb5b596d..a776539b69 100644 --- a/madgraph/iolibs/template_files/addmothers.f +++ b/madgraph/iolibs/template_files/addmothers.f @@ -65,7 +65,19 @@ subroutine addmothers(ip,jpart,pb,isym,jsym,rscale,aqcd,aqed,buff, integer icolup(2,nexternal,maxflow,maxsproc) include 'leshouche.inc' include 'coloramps.inc' - + +c Canonical colour-flow code: the event's colour tags are rebuilt from it +c instead of being read out of the ICOLUP table (which leshouche.inc then +c does not even write). NCOLSLOT(numproc) is the number of colour slots of +c that subprocess, or 0 when the flows have no usable code (no colour, a +c sextet, or an epsilon structure) -- then ICOLUP is there and is used. + integer ncolslot(maxsproc) + integer icolcsl(nexternal,maxsproc) + integer icolasl(nexternal,maxsproc) + integer icolcode(maxflow,maxsproc) + integer nslot,ccode,idig,icleg,ialeg,itag + include 'colorflow.inc' + logical OnBW(-nexternal:0) !Set if event is on B.W. common/to_BWEvents/ OnBW CHARACTER temp*600,temp0*7,integ*1,float*18 @@ -131,6 +143,33 @@ subroutine addmothers(ip,jpart,pb,isym,jsym,rscale,aqcd,aqed,buff, is_LC = .false. icol = abs(icol) endif + if (ncolslot(numproc).gt.0) then +c Rebuild the tags from the canonical colour-flow code. Slot k of the code +c connects colour slot ICOLCSL(k) to anticolour slot ICOLASL(digit+1); give +c each connection its own tag. icolalt is already zeroed above, so only the +c connected slots need writing. The two roles are swapped back on the +c initial-state legs: color_flow_decomposition reverses that pair to follow +c the les houches convention, and the code is built on the unreversed form. + nslot = ncolslot(numproc) + ccode = icolcode(icol,numproc) + do k=1,nslot + idig = mod(ccode/nslot**(k-1), nslot) + icleg = icolcsl(k,numproc) + ialeg = icolasl(idig+1,numproc) + itag = 500+k + if (icleg.le.nincoming) then + icolalt(2,isym(icleg,jsym))=itag + else + icolalt(1,isym(icleg,jsym))=itag + endif + if (ialeg.le.nincoming) then + icolalt(1,isym(ialeg,jsym))=itag + else + icolalt(2,isym(ialeg,jsym))=itag + endif + enddo + maxcolor=500+nslot + else do i=1,nexternal icolalt(1,isym(i,jsym))=icolup(1,i,icol,numproc) icolalt(2,isym(i,jsym))=icolup(2,i,icol,numproc) @@ -139,6 +178,7 @@ subroutine addmothers(ip,jpart,pb,isym,jsym,rscale,aqcd,aqed,buff, if (abs(icolup(2,i,icol, numproc)).gt.maxcolor) maxcolor=icolup(2,i,icol, numproc) enddo endif + endif diff --git a/madgraph/iolibs/template_files/auto_dsig_v4.inc b/madgraph/iolibs/template_files/auto_dsig_v4.inc index 4730c4fb51..23b623038c 100644 --- a/madgraph/iolibs/template_files/auto_dsig_v4.inc +++ b/madgraph/iolibs/template_files/auto_dsig_v4.inc @@ -106,7 +106,7 @@ c double precision P1(0:3, nexternal) integer channel double precision rwgt_value -C +%(dsig_xg_decl)sC C DATA C %(pdf_data)s @@ -166,7 +166,7 @@ C Continue only if IMODE is 0, 4 or 5 WGT = WGT * %(maxflavor)d endif endif - CALL GET_FLAVOR%(proc_id)s(IFLAV, FLAVOR) +%(dsig_getflavor)s %(passcuts_begin)s ## if( nogrouping) { ! for no grouping update the scale here (done in main autodsig for grouping @@ -214,7 +214,7 @@ C and IFLAV are still set above so SMATRIX gets valid arguments. rwgt_value=1d0 endif - CALL SMATRIX%(proc_id)s(P1, IFLAV, RHEL, RCOL,channel,1, DSIGUU, selected_hel(1), selected_col(1)) +%(dsig_smatrix_call)s DSIGUU = DSIGUU* rwgt_value @@ -389,9 +389,9 @@ c C Per-event MLM graph: igraphs(1) from REWGT (0 = no MLM) integer igraph(VECSIZE_MEMMAX) common/vec_igraph/igraph -C +%(dsig_xg_decl_vec)sC C DATA -C +C %(pdf_data_vec)s C ---------- C BEGIN CODE @@ -442,7 +442,7 @@ C Select a flavor combination (need to do here for right sign) %(get_channel_vec)s - CALL GET_FLAVOR%(proc_id)s(IFLAV_VEC(IVEC), FLAVOR) +%(dsig_getflavor_vec)s if (IMODE.eq.0) then ALL_RWGT(IVEC) = REWGT(all_PP(0,1,IVEC),FLAVOR,ivec) else @@ -549,22 +549,22 @@ C Per-event MLM graph: igraphs(1) from REWGT (0 = no MLM) INTEGER VECSIZE_USED integer ivec - +%(dsig_xg_decl_multi)s %(additional_header)s %(OMP_PREFIX)s DO IVEC=1, VECSIZE_USED - call SMATRIX%(proc_id)s(p_multi(0,1,IVEC), - & IFLAV_VEC(IVEC), + call %(dsig_smatrix_vec_name)s(p_multi(0,1,IVEC), + & %(dsig_smatrix_vec_flav)s, & hel_rand(IVEC), & col_rand(IVEC), - & channels(IVEC), + & %(dsig_smatrix_vec_chan)s, & IVEC, & out(IVEC), & selected_hel(IVEC), & selected_col(IVEC) - & ) + & )%(dsig_smatrix_vec_post)s ENDDO %(OMP_POSTFIX)s @@ -573,19 +573,32 @@ C Per-event MLM graph: igraphs(1) from REWGT (0 = no MLM) integer FUNCTION GET_NHEL%(proc_id)s(hel, ipart) c if hel>0 return the helicity of particule ipart for the selected helicity configuration -c if hel=0 return the number of helicity state possible for that particle +c if hel=0 return the number of helicity state possible for that particle implicit none - integer hel,i, ipart + integer hel, i, ipart Include 'nexternal.inc' - integer one_nhel(nexternal) - INTEGER NCOMB - PARAMETER ( NCOMB=%(ncomb)d) - INTEGER NHEL(NEXTERNAL,0:NCOMB) - %(helicity_lines)s - - get_nhel%(proc_id)s = nhel(ipart, iabs(hel)) + INTEGER MAXHEL + PARAMETER (MAXHEL=%(maxhel)d) + INTEGER NHSTATE(NEXTERNAL), STATES(MAXHEL,NEXTERNAL) + %(nhstate_data)s + %(states_data)s + INTEGER XGW, XGD, XGK + IF (hel.EQ.0) THEN +c Number of helicity states for particle ipart. + get_nhel%(proc_id)s = NHSTATE(ipart) + ELSE +c Decode the canonical mixed-radix helicity code into particle ipart's +c helicity value (last external leg = least-significant digit). + XGW = 1 + DO XGK = ipart+1, NEXTERNAL + XGW = XGW * NHSTATE(XGK) + ENDDO + XGD = MOD((IABS(hel)-1)/XGW, NHSTATE(ipart)) + get_nhel%(proc_id)s = STATES(XGD+1, ipart) + ENDIF return end +%(dsig_xg_helper)s %(ADDITIONAL_FCT)s diff --git a/madgraph/iolibs/template_files/check_sa.cpp b/madgraph/iolibs/template_files/check_sa.cpp index 9da836ec1b..ccc5c5773e 100644 --- a/madgraph/iolibs/template_files/check_sa.cpp +++ b/madgraph/iolibs/template_files/check_sa.cpp @@ -65,5 +65,7 @@ int main(int argc, char** argv){ cout << " -----------------------------------------------------------------------------" << endl; } +%(crossing_example)s + return 0; } diff --git a/madgraph/iolibs/template_files/check_sa.f b/madgraph/iolibs/template_files/check_sa.f index a5eb276740..a42d14be83 100644 --- a/madgraph/iolibs/template_files/check_sa.f +++ b/madgraph/iolibs/template_files/check_sa.f @@ -42,6 +42,19 @@ PROGRAM DRIVER INTEGER PDG_FOR_FLAVOR(NEXTERNAL,MAXFLAVOR) INTEGER FLAV_IDX INTEGER %(proc_prefix)sGET_FLAVOR_INDEX +C Signed per-leg PDG of a crossed process (filled by GET_PDG_FOR_FLAVOR), +C the two crossing-partner loop indices, and the number of flavor +C combinations; used only by the crossing-symmetry demonstration below. + INTEGER XPDG(NEXTERNAL) + INTEGER FLIP1, FLIP2, NFLAV +C Per-leg loop index and the two match flags of the crossing demonstration. + INTEGER XCK + LOGICAL XCVALID, XCMATCH +C Representative signed-PDG signatures of the crossed subprocesses folded +C into this matrix element; a crossing is demonstrated when its runtime PDG +C (GET_PDG_FOR_FLAVOR) matches one of them. + INTEGER XCSIG(NEXTERNAL, (NEXTERNAL+1)*(NEXTERNAL+1)) + INTEGER XCNSIG, XCS C C EXTERNAL C @@ -131,6 +144,8 @@ PROGRAM DRIVER write (*,*) "-----------------------------------------------------------------------------" enddo +%(crossing_example)s + if (%(use_density)s)then do I=1, MAXFLAVOR write (*,*) "==== density matrix for flavor", I, @@ -197,8 +212,8 @@ SUBROUTINE get_density_matrix(P, FLAVOR) c The value of alphas is 0 to keep the value of the param_card c The value of mu_r2 is set to 0 but it is a dummy variable at tree-level anyway - call %(prefix)sGET_DENSITY(P, POS, N_CHANGING, ALLOW_HEL, N_COMB, FLAVOR, 0d0, 0d0, INTER) - +%(density_call)s + SOL=0 DO I=1, N_COMB DO J = I, N_COMB diff --git a/madgraph/iolibs/template_files/cpp_process_class.inc b/madgraph/iolibs/template_files/cpp_process_class.inc index b64dfd4f2f..037bbeeb51 100644 --- a/madgraph/iolibs/template_files/cpp_process_class.inc +++ b/madgraph/iolibs/template_files/cpp_process_class.inc @@ -66,7 +66,23 @@ private: int igood[nflavors][ncomb]; int jhel[nflavors]; + // C-parity de-duplication of the helicity sum (uncrossed process only, see + // sigmaKin): flip[ihel] is the helicity row with every helicity negated (an + // involution, built once); csym_bad[flav] latches true once ANY row fails to + // pair up or ANY pair shows |M(ihel)| != |M(flip)| at a scan point, so the + // reuse is all-or-nothing per flavor and halves the loop uniformly; the good + // helicities of a flavor are then reduced to the lower-index representative + // of every surviving C-parity pair (igoodrep/nrep) carrying a doubled weight + // (repwgt), so the recycling sum computes one of the pair and counts it twice. + int flip[ncomb]; + bool flip_ready; + bool csym_bad[nflavors]; + int igoodrep[nflavors][ncomb]; + int nrep[nflavors]; + int repwgt[nflavors][ncomb]; + // function to compute missing symmetry factors after flavor consolidation int broken_sym(const int* flavor); +%(cross_member_decl)s }; diff --git a/madgraph/iolibs/template_files/cpp_process_function_definitions.inc b/madgraph/iolibs/template_files/cpp_process_function_definitions.inc index db3a44fee6..125140b12c 100644 --- a/madgraph/iolibs/template_files/cpp_process_function_definitions.inc +++ b/madgraph/iolibs/template_files/cpp_process_function_definitions.inc @@ -6,7 +6,8 @@ // Initialize process. CPPProcess::CPPProcess(string param_card_name) : - goodhel(), ntry(), sum_hel(), ngood(), igood(), jhel() + goodhel(), ntry(), sum_hel(), ngood(), igood(), jhel(), + flip(), flip_ready(), csym_bad(), igoodrep(), nrep(), repwgt() { // Instantiate the model class and set parameters that stay fixed during run SLHAReader slha(param_card_name, false); @@ -91,3 +92,5 @@ int CPPProcess::broken_sym(const int* flavor) } return total_factor; } + +%(ident_cross_function)s diff --git a/madgraph/iolibs/template_files/cpp_process_sigmaKin_function.inc b/madgraph/iolibs/template_files/cpp_process_sigmaKin_function.inc index dccadc17f6..a30adf9df4 100644 --- a/madgraph/iolibs/template_files/cpp_process_sigmaKin_function.inc +++ b/madgraph/iolibs/template_files/cpp_process_sigmaKin_function.inc @@ -6,47 +6,140 @@ std::complex **wfs; const int denominator = %(den_factors)s; // Flavor lookup table %(flavor_table)s +%(cross_tables_decode)s +const int* flavor = &flavor_table[%(fidx)s][0]; -const int* flavor = &flavor_table[flavor_id][0]; - -ntry[flavor_id]++; +ntry[%(fidx)s]++; // Define permutation -int perm[nexternal]; -for(int i = 0; i < nexternal; i++){ - perm[i]=i; -} +%(cross_perm_block)s double matrix_element = 0.; -if (sum_hel[flavor_id] == 0 || ntry[flavor_id] < 10){ - // Calculate the matrix element for all helicities +// C-parity helicity de-duplication (mirror of the fortran SMATRIX): flip[ihel] +// is the helicity row with every helicity negated, an involution built once +// from the helicity table. dedup_ok gates the reuse to the UNCROSSED process: +// a crossing permutes/sign-flips the helicities, so a base-row flip is no +// longer the crossed C-parity partner and the crossed flavors keep the full sum. +if (!flip_ready){ + for(int i = 0; i < ncomb; i++){ + flip[i] = i; + for(int j = 0; j < ncomb; j++){ + bool same = true; + for(int k = 0; k < nexternal; k++){ + if (helicities[j][k] != -helicities[i][k]){ + same = false; + } + } + if (same){ + flip[i] = j; + break; + } + } + } + flip_ready = true; +} +const bool dedup_ok = (%(csym_dedup_ok)s); + +if (sum_hel[%(fidx)s] == 0 || ntry[%(fidx)s] < 10){ + // Scan phase: calculate the matrix element for all helicities (full sum). + double tstore[ncomb] = {}; for(int ihel = 0; ihel < ncomb; ihel ++){ - if (goodhel[flavor_id][ihel] || ntry[flavor_id] < 2){ - calculate_wavefunctions(perm, helicities[ihel], flavor); + %(cross_ghidx_setup)sif (%(cross_goodhel_gate)s){ + calculate_wavefunctions(perm, helicities[ihel], flavor%(cross_cw_args)s); %(get_matrix_t_lines)s matrix_element += t; + tstore[ihel] = t; // Store which helicities give non-zero result - if (t != 0. && !goodhel[flavor_id][ihel]){ - goodhel[flavor_id][ihel]=true; - ngood[flavor_id] ++; - igood[flavor_id][ngood[flavor_id]] = ihel; + %(cross_goodhel_train)s + } + } + if (dedup_ok){ + // Drop the C-parity pairing of any row whose flipped partner gave a + // different |M|^2 (parity/C violation). One mismatch at any scan point + // permanently invalidates the pair (robust, like the zero-filter). + // All-or-nothing per flavor: a self-paired row (flip[ihel]==ihel) has no + // distinct partner, and one mismatching pair is enough to give up, so + // that when the reuse does run it halves the loop uniformly. + for(int ihel = 0; ihel < ncomb; ihel++){ + if (flip[ihel] == ihel){ + csym_bad[%(fidx)s] = true; + } else if (flip[ihel] > ihel){ + double a = tstore[ihel]; + double b = tstore[flip[ihel]]; + double diff = a - b; + if (diff < 0){ + diff = -diff; + } + double aa = a; + if (aa < 0){ + aa = -aa; + } + double bb = b; + if (bb < 0){ + bb = -bb; + } + if (diff > 1e-6 * (aa + bb)){ + csym_bad[%(fidx)s] = true; + } + } + } + // The reuse skips a good helicity only if its representative is itself + // a good helicity: a pair split across the good/dropped boundary would + // lose the skipped row entirely. Fold that into the flavor verdict. + for(int g = 1; g <= ngood[%(fidx)s]; g++){ + int ihel = igood[%(fidx)s][g]; + if (flip[ihel] == ihel || !goodhel[%(fidx)s][flip[ihel]]){ + csym_bad[%(fidx)s] = true; + } + } + // Reduce the good helicities to the lower-index representative of every + // surviving C-parity pair, carrying a doubled weight; the skipped + // higher-index partner has an identical |M|^2. + nrep[%(fidx)s] = 0; + for(int g = 1; g <= ngood[%(fidx)s]; g++){ + int ihel = igood[%(fidx)s][g]; + bool paired = !csym_bad[%(fidx)s]; + if (paired && ihel > flip[ihel]){ + continue; + } + nrep[%(fidx)s]++; + igoodrep[%(fidx)s][nrep[%(fidx)s]] = ihel; + if (paired){ + repwgt[%(fidx)s][nrep[%(fidx)s]] = 2; + } else { + repwgt[%(fidx)s][nrep[%(fidx)s]] = 1; } } } - jhel[flavor_id] = 0; - sum_hel[flavor_id]=min(sum_hel[flavor_id], ngood[flavor_id]); + jhel[%(fidx)s] = 0; + if (dedup_ok){ + sum_hel[%(fidx)s] = min(sum_hel[%(fidx)s], nrep[%(fidx)s]); + } else { + sum_hel[%(fidx)s] = min(sum_hel[%(fidx)s], ngood[%(fidx)s]); + } } else { - // Only use the "good" helicities - for(int j=0; j < sum_hel[flavor_id]; j++){ - jhel[flavor_id]++; - if (jhel[flavor_id] >= ngood[flavor_id]) jhel[flavor_id]=0; - double hwgt = double(ngood[flavor_id])/double(sum_hel[flavor_id]); - int ihel = igood[flavor_id][jhel[flavor_id]]; - calculate_wavefunctions(perm, helicities[ihel], flavor); + // Only use the "good" helicities (C-parity representatives when uncrossed). + int nsel = ngood[%(fidx)s]; + if (dedup_ok){ + nsel = nrep[%(fidx)s]; + } + for(int j=0; j < sum_hel[%(fidx)s]; j++){ + jhel[%(fidx)s]++; + if (jhel[%(fidx)s] >= nsel){ + jhel[%(fidx)s]=0; + } + double hwgt = double(nsel)/double(sum_hel[%(fidx)s]); + int ihel = igood[%(fidx)s][jhel[%(fidx)s]]; + double cwgt = 1.; + if (dedup_ok){ + ihel = igoodrep[%(fidx)s][jhel[%(fidx)s]]; + cwgt = double(repwgt[%(fidx)s][jhel[%(fidx)s]]); + } + calculate_wavefunctions(perm, helicities[ihel], flavor%(cross_cw_args)s); %(get_matrix_t_lines)s - matrix_element += t*hwgt; + matrix_element += t*hwgt*cwgt; } } -return matrix_element * broken_sym(flavor) / denominator; +%(cross_return)s diff --git a/madgraph/iolibs/template_files/f2py_flavor_dispatch.py b/madgraph/iolibs/template_files/f2py_flavor_dispatch.py index a2b70e503a..07c1b5fa5a 100644 --- a/madgraph/iolibs/template_files/f2py_flavor_dispatch.py +++ b/madgraph/iolibs/template_files/f2py_flavor_dispatch.py @@ -19,6 +19,26 @@ >>> me.initialisemodel('param_card.dat') >>> ans = me.get_value(P, alphas, nhel, 3) # by flavor index >>> ans = me.get_value(P, alphas, nhel, [1, -1, 2, -2]) # by flavor array + +Crossing / PDG matching +----------------------- +When the module was generated with crossing symmetry on, a single flavor index +also carries a *crossing*: the extended ``FLAV_IDX = cross*NFLAV + flav`` makes +the one generated matrix element evaluate any process related to it by moving +legs between the initial and the final state. The caller usually does not want +to think in those indices -- they have a physical process as a list of signed +PDG codes and want the right index. ``find_pdg`` does that lookup and +``matrix_element_pdg`` / ``get_value_pdg`` call straight through: + +>>> me.find_pdg([2, 21, 2, 21]) # u g > u g from a u u~ > g g module +4 +>>> ans = me.get_value_pdg(P, alphas, nhel, [2, 21, 2, 21]) + +The PDG list is matched in the leg order the momenta are given in: the index +``find_pdg`` returns is exactly the one to pass to the ``*_idx`` entry points +together with momenta in that same order. A crossed leg is conjugated (an +incoming ``u~`` that a crossing turns into an outgoing ``u`` matches pdg +2), +which is why the match is on signed PDG codes. """ import numbers @@ -102,6 +122,99 @@ def smatrixhel(self, p, hel, flavor): def get_value(self, p, alphas, nhel, flavor): return self._call('get_value', [p, alphas, nhel, flavor]) + # -- crossing / PDG matching --------------------------------------------- + def _find_one(self, suffix): + """Return the single module function whose (lowercased) name ends with + *suffix*, or None. Cached under a distinct key so it never collides + with the (array, idx) pairs stored by _resolve.""" + key = ('one', suffix) + if key in self._cache: + return self._cache[key] + found = None + for name in dir(self.module): + if name.lower().endswith(suffix): + found = getattr(self.module, name) + break + self._cache[key] = found + return found + + def flavor_layout(self): + """Return (nflav, nexternal, ncross) from GET_FLAVOR_LAYOUT. + + ncross = (nexternal+1)**2 is the number of crossing codes, so the + extended index ranges over 1 .. ncross*nflav. Raises if the module was + built without the crossing entry points (an old or non-standalone-v4 + output).""" + func = self._find_one('get_flavor_layout') + if func is None: + raise AttributeError( + "This module exposes no 'get_flavor_layout': it was not built " + "with the crossing/PDG entry points.") + nflav, nexternal, ncross = func() + return int(nflav), int(nexternal), int(ncross) + + def pdg_for_index(self, flav_idx): + """Signed per-leg PDG codes of the process an extended FLAV_IDX selects, + or None if the index names no valid flavor/crossing. + + The codes are in the leg order the momenta must be supplied in for that + index; a leg that the crossing moved between the initial and the final + state is conjugated.""" + func = self._find_one('get_pdg_for_flavor') + if func is None: + raise AttributeError( + "This module exposes no 'get_pdg_for_flavor': it was not built " + "with the crossing/PDG entry points.") + pdgs = tuple(int(x) for x in func(flav_idx)) + # The Fortran routine zero-fills PDGS for an index it cannot resolve. + if all(code == 0 for code in pdgs): + return None + return pdgs + + def _pdg_map(self): + """{signed-PDG-tuple: extended FLAV_IDX} over every valid index. + + Built once and cached. When two indices give the same PDG signature in + the same leg order (physically the same process, e.g. a crossing that + coincides with the identity for a symmetric flavor) the first is kept: + they evaluate to the same matrix element.""" + if 'pdg_map' in self._cache: + return self._cache['pdg_map'] + nflav, _nexternal, ncross = self.flavor_layout() + mapping = {} + for cross in range(ncross): + for flav in range(1, nflav + 1): + flav_idx = cross * nflav + flav + pdgs = self.pdg_for_index(flav_idx) + if pdgs is not None: + mapping.setdefault(pdgs, flav_idx) + self._cache['pdg_map'] = mapping + return mapping + + def find_pdg(self, pdgs): + """Extended FLAV_IDX whose crossed process is *pdgs* (signed, in the + given leg order), or None if no crossing of the generated matrix + element reproduces it.""" + return self._pdg_map().get(tuple(int(code) for code in pdgs)) + + def _require_pdg(self, pdgs): + flav_idx = self.find_pdg(pdgs) + if flav_idx is None: + raise ValueError( + "No crossing of the generated matrix element yields the " + "process %s" % (tuple(int(code) for code in pdgs),)) + return flav_idx + + def matrix_element_pdg(self, p, pdgs): + """SMATRIX for the process *pdgs*, reached through crossing. Momenta + must be given in the same leg order as *pdgs*.""" + return self.smatrix(p, self._require_pdg(pdgs)) + + def get_value_pdg(self, p, alphas, nhel, pdgs): + """get_value for the process *pdgs*, reached through crossing. Momenta + must be given in the same leg order as *pdgs*.""" + return self.get_value(p, alphas, nhel, self._require_pdg(pdgs)) + # -- pass-through for the model initialiser ------------------------------- def initialisemodel(self, path): for name in dir(self.module): diff --git a/madgraph/iolibs/template_files/f2py_splitter.py b/madgraph/iolibs/template_files/f2py_splitter.py index b10eea20aa..cb33cc3935 100644 --- a/madgraph/iolibs/template_files/f2py_splitter.py +++ b/madgraph/iolibs/template_files/f2py_splitter.py @@ -36,7 +36,44 @@ return end - + + subroutine %(f2py_prefix)sf77_smatrixhel_idx(procindex, flav_idx, npdg, p, ALPHAS, SCALE2, nhel, ANS) + use model_object + use aloha_object + IMPLICIT NONE +C Same as f77_smatrixhel, but selecting the matrix element by its slot in +C get_pdg_order/get_prefix (PROCINDEX, 1-based) and taking the extended flavor +C index (FLAV_IDX = cross*NFLAV + flav) as given rather than resolving it from +C the PDG codes. This is the only way in to a FOLDED crossed subprocess: it has +C no PDG entry of its own, so the dispatch above cannot name it, and the FLAVOR +C array cannot express a crossing (see matrix_standalone_f2py_flav_idx.inc). +C The alphas/scale2 setup is deliberately the same as in f77_smatrixhel. +CF2PY double precision, intent(in), dimension(0:3,npdg) :: p +CF2PY integer, intent(in) :: procindex +CF2PY integer, intent(in) :: flav_idx +CF2PY integer, intent(in) :: npdg +CF2PY double precision, intent(out) :: ANS +CF2PY double precision, intent(in) :: ALPHAS +CF2PY double precision, intent(in) :: SCALE2 + integer procindex, flav_idx, npdg, nhel + double precision p(*) + double precision ANS, ALPHAS, PI, SCALE2 + include 'coupl.inc' + + if (scale2.eq.0)then + PI = 3.141592653589793D0 + G = 2* DSQRT(ALPHAS*PI) + CALL UPDATE_AS_PARAM() + else + CALL UPDATE_AS_PARAM2(scale2, ALPHAS) + endif + + ANS = 0d0 +%(smatrixhel_idx)s + + return + end + subroutine %(f2py_prefix)sf77_density(pdgs, npdg, procid, P, POS, N_CHANGING, ALLOW_HEL, N_COMB, ALPHAS, SCALE2, INTER) IMPLICIT NONE CF2PY double precision, intent(in) :: p diff --git a/madgraph/iolibs/template_files/f2py_wrapper_all.inc b/madgraph/iolibs/template_files/f2py_wrapper_all.inc index 527056ae86..67b9e662b1 100644 --- a/madgraph/iolibs/template_files/f2py_wrapper_all.inc +++ b/madgraph/iolibs/template_files/f2py_wrapper_all.inc @@ -19,7 +19,33 @@ CF2PY double precision, intent(in) :: SCALE2 DOUBLE PRECISION ANS, ALPHAS,SCALE2 call %(f2py_prefix)sf77_smatrixhel(pdgs, procid, npdg, p, alphas, scale2, nhel, ans) - + + RETURN + END + + SUBROUTINE %(f2py_prefix)sSMATRIXHEL_IDX(PROCINDEX, FLAV_IDX, NPDG, P, + $ ALPHAS, SCALE2, NHEL, ANS) + IMPLICIT NONE +C Crossing-aware twin of SMATRIXHEL. PROCINDEX is the 1-based +C get_pdg_order slot of the matrix element and FLAV_IDX the extended +C flavor index (cross*NFLAV+flav) of the process to evaluate. A folded +C crossed subprocess is reachable only this way: it has no PDG entry of +C its own, and the FLAVOR array cannot carry a crossing. + +CF2PY double precision, intent(in), dimension(0:3,npdg) :: p +CF2PY integer, intent(in) :: procindex +CF2PY integer, intent(in) :: flav_idx +CF2PY integer, intent(in) :: npdg +CF2PY double precision, intent(out) :: ANS +CF2PY double precision, intent(in) :: ALPHAS +CF2PY double precision, intent(in) :: SCALE2 + INTEGER PROCINDEX, FLAV_IDX, NPDG, NHEL + DOUBLE PRECISION P(*) + DOUBLE PRECISION ANS, ALPHAS, SCALE2 + + call %(f2py_prefix)sf77_smatrixhel_idx(procindex, flav_idx, npdg, p, + $ alphas, scale2, nhel, ans) + RETURN END diff --git a/madgraph/iolibs/template_files/fortran_matrix_flavor_pdg_fct.inc b/madgraph/iolibs/template_files/fortran_matrix_flavor_pdg_fct.inc new file mode 100644 index 0000000000..61841c7f5c --- /dev/null +++ b/madgraph/iolibs/template_files/fortran_matrix_flavor_pdg_fct.inc @@ -0,0 +1,69 @@ + SUBROUTINE %(func_name)s(FLAV_IDX_IN, PDGS) +C Return the signed PDG code of every leg of the process FLAV_IDX_IN +C selects, INCLUDING the crossing it carries. +C +C This is the bridge between the two vocabularies of this file. Inside +C matrix.f a flavor is an unsigned group *position*: that is all the +C matrix element needs, since every member of a flavor group shares the +C couplings. A caller speaking PDG codes cannot work with that -- a +C position is meaningless without knowing the group and the leg -- and +C nothing else generated here maps one back. GET_FLAVOR_INDEX only goes +C the other way and only accepts positions. +C +C FLAV_IDX_IN is the *extended* index: it carries a flavor and a +C crossing (see GET_CROSS_PERM). The PDGs returned are therefore those +C of the process actually evaluated, i.e. after the crossing has moved +C the legs around AND conjugated every leg that swapped between the +C initial and the final state -- an incoming u~ crossed into a final +C slot comes back as an outgoing u. Handing an f2py caller the +C uncrossed PDGs would be useless: it is precisely the crossed +C signature it has to match a request against. +C +C Conjugation is NOT a sign flip: a self-conjugate particle (the gluon) +C is its own antiparticle. Both tables are therefore filled at export +C time with the model's own anti-pdg rule, and this routine only picks +C the one SGN designates. +C +C PDGS is set to 0 on every leg when FLAV_IDX_IN names no valid flavor +C or a crossing that cannot be applied, so a caller can test PDGS(1)==0 +C rather than having to pre-validate the index. + IMPLICIT NONE +%(nexternal_decl)s + INTEGER NFLAV + PARAMETER (NFLAV=%(nflav)d) +C +C ARGUMENTS +C + INTEGER FLAV_IDX_IN + INTEGER PDGS(NEXTERNAL) +CF2PY INTENT(IN) :: FLAV_IDX_IN +CF2PY INTENT(OUT) :: PDGS(NEXTERNAL) +C +C LOCAL +C + INTEGER FP_I, FP_FLAV + INTEGER FP_PDG_TABLE(NEXTERNAL, NFLAV) + INTEGER FP_ANTI_TABLE(NEXTERNAL, NFLAV) + DATA FP_PDG_TABLE /%(pdg_table_data)s/ + DATA FP_ANTI_TABLE /%(antipdg_table_data)s/ +%(pdg_cross_decl)s + + DO FP_I = 1, NEXTERNAL + PDGS(FP_I) = 0 + ENDDO +C Guard before decoding: a negative index would make the MOD/divide +C below wrap onto a valid-looking flavor and crossing. + IF (FLAV_IDX_IN .LT. 1) THEN + RETURN + ENDIF + +%(pdg_cross_decode)s + + IF (FP_FLAV .LT. 1 .OR. FP_FLAV .GT. NFLAV) THEN + RETURN + ENDIF + +%(pdg_cross_apply)s + + RETURN + END diff --git a/madgraph/iolibs/template_files/hel_warmup_v4.inc b/madgraph/iolibs/template_files/hel_warmup_v4.inc new file mode 100644 index 0000000000..201334e8fe --- /dev/null +++ b/madgraph/iolibs/template_files/hel_warmup_v4.inc @@ -0,0 +1,438 @@ + PROGRAM %(proc_prefix)sHEL_WARMUP +C************************************************************************** +C Helicity-recycling warm-up driver (standalone --hel_recycling). +C Links against matrix_orig.f (the un-recycled per-helicity MATRIX with +C init_mode instrumentation) and evaluates it over several RAMBO phase- +C space points and every flavor index, recording: +C * which helicity combinations ever contribute (good helicities), and +C * which amplitudes are zero (globally, or per helicity), +C then prints them in the exact format hel_recycle.py / gen_ximprove.py +C parse ('Matrix Element/Good Helicity:', 'Amplitude/ZEROAMP:', +C 'HEL/ZEROAMP:'). The generation step re-runs hel_recycle with this +C information to drop the dead work from the final matrix.f. +C************************************************************************** + use model_object + IMPLICIT NONE + INCLUDE "coupl.inc" + INCLUDE "nexternal.inc" + INCLUDE "ngraphs.inc" + INTEGER NCOMB + PARAMETER (NCOMB=%(ncomb)d) + INTEGER NFLAV + PARAMETER (NFLAV=%(nflav)d) + INTEGER NPS + PARAMETER (NPS=16) +C Phase-space points per CROSSED configuration (the uncrossed one keeps the +C full NPS): a crossing only adds rows to the union, so a coarser scan is +C enough and keeps the probe cheap when NCROSS is large. + INTEGER NPSCROSS + PARAMETER (NPSCROSS=4) +C Crossing codes to scan. 1 (identity only) when the process was generated +C without crossing symmetry. + INTEGER NCROSS + PARAMETER (NCROSS=%(hr_warmup_ncross)s) + REAL*8 LIMHEL + PARAMETER (LIMHEL=1D-12) + REAL*8 ZERO + PARAMETER (ZERO=0D0) +C LOCAL + INTEGER I, IHEL, ITRY, IFLV, CROSS, NPSUSE, FLAV_EXT + REAL*8 P(0:3,NEXTERNAL), PMASS(NEXTERNAL), TOTALMASS + REAL*8 PUSE(0:3,NEXTERNAL) + INTEGER JC(NEXTERNAL), ICUSE(NEXTERNAL) + INTEGER NHELDUM(NEXTERNAL), NHELOUT(NEXTERNAL), DUMFLAV + REAL*8 SQRTS, T, ANS, TSARR(NCOMB) + LOGICAL GOODHEL(NCOMB) +C C-parity de-duplication. FLIP(I) is the row whose helicity configuration +C is the full negation of row I (an involution); CSYM stays true only while +C EVERY pair gave the same |M|^2 at EVERY sampled point, flavor and +C crossing -- the same all-or-nothing verdict the standard SMATRIX reaches +C at run time. A self-paired row (all helicities zero) disables it, since +C then the pairing halves nothing. + INTEGER FLIP(NCOMB), JHEL, KHEL + LOGICAL CSYM, HELSAME +C EXTERNAL + DOUBLE PRECISION %(proc_prefix)sMATRIX + EXTERNAL %(proc_prefix)sMATRIX +%(hr_warmup_cross_decl)s +C SHARED WITH matrix_orig.f + INTEGER NHEL(NEXTERNAL, NCOMB) + COMMON/%(proc_prefix)sHEL_TABLE/NHEL + LOGICAL INIT_MODE + COMMON/%(proc_prefix)sto_determine_zero_hel/INIT_MODE + INTEGER %(proc_prefix)sCUR_IHEL + COMMON/%(proc_prefix)sto_cur_ihel/%(proc_prefix)sCUR_IHEL +C----- +C BEGIN CODE +C----- + CALL SETPARA('param_card.dat') + INCLUDE "pmass.inc" + TOTALMASS = 0D0 + DO I=1,NEXTERNAL + TOTALMASS = TOTALMASS + PMASS(I) + ENDDO + + INIT_MODE = .TRUE. + CALL %(proc_prefix)sRESET_ZEROAMP() + DO I=1,NCOMB + GOODHEL(I) = .FALSE. + ENDDO + +C Build the C-parity partner table once (fixed per process). + CSYM = .TRUE. + DO IHEL=1,NCOMB + FLIP(IHEL) = IHEL + DO JHEL=1,NCOMB + HELSAME = .TRUE. + DO KHEL=1,NEXTERNAL + IF (NHEL(KHEL,JHEL).NE.-NHEL(KHEL,IHEL)) HELSAME = .FALSE. + ENDDO + IF (HELSAME) THEN + FLIP(IHEL) = JHEL + GOTO 30 + ENDIF + ENDDO + 30 CONTINUE + IF (FLIP(IHEL).EQ.IHEL) CSYM = .FALSE. + ENDDO + +C Loop over every flavor index: a helicity that contributes for ANY flavor +C must be kept, since one recycled matrix.f serves them all. Same for the +C crossings: the recycled table bakes its helicity rows, and under a +C crossing those rows are used as the CROSSED configurations, so a row that +C any crossing needs must survive. Measuring each crossing here is what +C makes the recycled matrix.f a valid union over all of them. + DO IFLV=1, NFLAV + DO CROSS=0, NCROSS-1 + FLAV_EXT = CROSS*NFLAV + IFLV +%(hr_warmup_cross_skip)s + NPSUSE = NPS + IF (CROSS.NE.0) NPSUSE = NPSCROSS + DO ITRY=1, NPSUSE + IF (NINCOMING.EQ.1) THEN + SQRTS = PMASS(1) + ELSE + SQRTS = 500D0 + 250D0*ITRY + IF (SQRTS.LE.2D0*TOTALMASS) SQRTS = 2.1D0*TOTALMASS + 100D0*ITRY + ENDIF + CALL GET_MOMENTA(SQRTS, PMASS, P) + DO I=1, NEXTERNAL + JC(I) = +1 + ENDDO + PUSE(:,:) = P(:,:) + ICUSE(:) = JC(:) +%(hr_warmup_cross_apply)s + ANS = 0D0 + DO IHEL=1, NCOMB + %(proc_prefix)sCUR_IHEL = IHEL + T = %(proc_prefix)sMATRIX(PUSE, NHEL(1,IHEL), ICUSE, IFLV) + TSARR(IHEL) = T + ANS = ANS + T + ENDDO + DO IHEL=1, NCOMB + IF (DABS(TSARR(IHEL)) .GT. ANS*LIMHEL/NCOMB) GOODHEL(IHEL)=.TRUE. + ENDDO +C One mismatch anywhere permanently invalidates the C-parity reuse. +C A pair whose BOTH members sit below the good-helicity threshold is +C pure round-off (those rows are dropped from the recycled table +C anyway), so comparing them relatively would reject the reuse on +C noise -- e.g. 1.5D-30 against 3.1D-31 for a dead row of g g > t t~. + IF (CSYM) THEN + DO IHEL=1, NCOMB + IF (FLIP(IHEL).GT.IHEL) THEN + IF (DABS(TSARR(IHEL)) .GT. ANS*LIMHEL/NCOMB .OR. + & DABS(TSARR(FLIP(IHEL))) .GT. ANS*LIMHEL/NCOMB) THEN + IF (DABS(TSARR(IHEL)-TSARR(FLIP(IHEL))) .GT. + & 1D-6*(DABS(TSARR(IHEL))+DABS(TSARR(FLIP(IHEL))))) + & CSYM = .FALSE. + ENDIF + ENDIF + ENDDO + ENDIF + ENDDO + ENDDO + ENDDO + + DO IHEL=1, NCOMB + IF (GOODHEL(IHEL)) THEN + WRITE(*,*) 'Matrix Element/Good Helicity: 1 ', IHEL + ENDIF + ENDDO +C Surviving C-parity pairs (representative first). The exporter drops the +C partner's amplitudes from the recycled matrix.f and copies the +C representative's |M|^2 into it instead. + IF (CSYM) THEN + DO IHEL=1, NCOMB + IF (FLIP(IHEL).GT.IHEL .AND. GOODHEL(IHEL) + & .AND. GOODHEL(FLIP(IHEL))) THEN + WRITE(*,*) 'CSYM PAIR: 1 ', IHEL, FLIP(IHEL) + ENDIF + ENDDO + ENDIF + CALL %(proc_prefix)sPRINT_ZERO_AMP() + END + + + SUBROUTINE %(proc_prefix)sRESET_ZEROAMP() + IMPLICIT NONE + INTEGER NCOMB, NGRAPHS + PARAMETER (NCOMB=%(ncomb)d, NGRAPHS=%(ngraphs)d) + LOGICAL %(proc_prefix)sZEROAMP(NCOMB, NGRAPHS) + COMMON/%(proc_prefix)sto_zeroamp/%(proc_prefix)sZEROAMP + %(proc_prefix)sZEROAMP(:,:) = .TRUE. + END + + + SUBROUTINE %(proc_prefix)sPRINT_ZERO_AMP() + IMPLICIT NONE + INTEGER NCOMB, NGRAPHS + PARAMETER (NCOMB=%(ncomb)d, NGRAPHS=%(ngraphs)d) + LOGICAL %(proc_prefix)sZEROAMP(NCOMB, NGRAPHS) + COMMON/%(proc_prefix)sto_zeroamp/%(proc_prefix)sZEROAMP + INTEGER I, J + LOGICAL ALL_FALSE + DO I=1, NGRAPHS + ALL_FALSE = .TRUE. + DO J=1, NCOMB + IF (.NOT.%(proc_prefix)sZEROAMP(J,I)) THEN + ALL_FALSE = .FALSE. + GOTO 20 + ENDIF + ENDDO + 20 CONTINUE + IF (ALL_FALSE) THEN + WRITE(*,*) 'Amplitude/ZEROAMP:', 1, I + ELSE + DO J=1, NCOMB + IF (%(proc_prefix)sZEROAMP(J,I)) THEN + WRITE(*,*) 'HEL/ZEROAMP:', 1, J, I + ENDIF + ENDDO + ENDIF + ENDDO + END + + + DOUBLE PRECISION FUNCTION DOT(P1,P2) +C 4-Vector Dot product + IMPLICIT NONE + DOUBLE PRECISION P1(0:3),P2(0:3) + DOT=P1(0)*P2(0)-P1(1)*P2(1)-P1(2)*P2(2)-P1(3)*P2(3) + END + + + SUBROUTINE GET_MOMENTA(ENERGY,PMASS,P) +C---- auxiliary function to change convention between MadGraph5_aMC@NLO and rambo +C---- four momenta. + IMPLICIT NONE + INCLUDE "nexternal.inc" +C ARGUMENTS + REAL*8 ENERGY,PMASS(NEXTERNAL),P(0:3,NEXTERNAL),PRAMBO(4,10),WGT +C LOCAL + INTEGER I + REAL*8 etot2,mom,m1,m2,e1,e2 + ETOT2=energy**2 + if(nincoming.eq.2) then + m1=pmass(1) + m2=pmass(2) + mom=(Etot2**2 - 2*Etot2*m1**2 + m1**4 - + & 2*Etot2*m2**2 - 2*m1**2*m2**2 + m2**4)/(4.*Etot2) + mom=dsqrt(mom) + e1=DSQRT(mom**2+m1**2) + e2=DSQRT(mom**2+m2**2) + P(0,1)=e1 + P(1,1)=0d0 + P(2,1)=0d0 + P(3,1)=mom + P(0,2)=e2 + P(1,2)=0d0 + P(2,2)=0d0 + P(3,2)=-mom + call rambo(nexternal-2,energy,pmass(nincoming+1),prambo,WGT) + DO I=3, NEXTERNAL + P(0,I)=PRAMBO(4,I-2) + P(1,I)=PRAMBO(1,I-2) + P(2,I)=PRAMBO(2,I-2) + P(3,I)=PRAMBO(3,I-2) + ENDDO + elseif(nincoming.eq.1) then + P(0,1)=energy + P(1,1)=0d0 + P(2,1)=0d0 + P(3,1)=0d0 + call rambo(nexternal-1,energy,pmass(2),prambo,WGT) + DO I=2, NEXTERNAL + P(0,I)=PRAMBO(4,I-1) + P(1,I)=PRAMBO(1,I-1) + P(2,I)=PRAMBO(2,I-1) + P(3,I)=PRAMBO(3,I-1) + ENDDO + endif + RETURN + END + + + SUBROUTINE RAMBO(N,ET,XM,P,WT) +C*********************************************************************** +C RAMBO +C RA(NDOM) M(OMENTA) B(EAUTIFULLY) O(RGANIZED) +C A DEMOCRATIC MULTI-PARTICLE PHASE SPACE GENERATOR +C AUTHORS: S.D. ELLIS, R. KLEISS, W.J. STIRLING +C*********************************************************************** + IMPLICIT REAL*8(A-H,O-Z) + INCLUDE "nexternal.inc" + DIMENSION XM(NEXTERNAL-NINCOMING),P(4,NEXTERNAL-NINCOMING) + DIMENSION Q(4,NEXTERNAL-NINCOMING),Z(NEXTERNAL-NINCOMING),R(4), + . B(3),P2(NEXTERNAL-NINCOMING),XM2(NEXTERNAL-NINCOMING), + . E(NEXTERNAL-NINCOMING),V(NEXTERNAL-NINCOMING),IWARN(5) + SAVE ACC,ITMAX,IBEGIN,IWARN + DATA ACC/1.D-14/,ITMAX/6/,IBEGIN/0/,IWARN/5*0/ + SAVE TWOPI, PO2LOG, Z + IF(IBEGIN.NE.0) GOTO 103 + IBEGIN=1 + TWOPI=8.*DATAN(1.D0) + PO2LOG=LOG(TWOPI/4.) + Z(2)=PO2LOG + DO 101 K=3,NEXTERNAL-NINCOMING + 101 Z(K)=Z(K-1)+PO2LOG-2.*LOG(DFLOAT(K-2)) + DO 102 K=3,NEXTERNAL-NINCOMING + 102 Z(K)=(Z(K)-LOG(DFLOAT(K-1))) + 103 IF(N.GT.1.AND.N.LT.101) GOTO 104 + PRINT 1001,N + STOP + 104 XMT=0. + NM=0 + DO 105 I=1,N + IF(XM(I).NE.0.D0) NM=NM+1 + 105 XMT=XMT+ABS(XM(I)) + IF(XMT.LE.ET) GOTO 201 + PRINT 1002,XMT,ET + STOP + 201 DO 202 I=1,N + r1=rn(1) + C=2.*r1-1. + S=SQRT(1.-C*C) + F=TWOPI*RN(2) + r1=rn(3) + r2=rn(4) + Q(4,I)=-LOG(r1*r2) + Q(3,I)=Q(4,I)*C + Q(2,I)=Q(4,I)*S*COS(F) + 202 Q(1,I)=Q(4,I)*S*SIN(F) + DO 203 I=1,4 + 203 R(I)=0. + DO 204 I=1,N + DO 204 K=1,4 + 204 R(K)=R(K)+Q(K,I) + RMAS=SQRT(R(4)**2-R(3)**2-R(2)**2-R(1)**2) + DO 205 K=1,3 + 205 B(K)=-R(K)/RMAS + G=R(4)/RMAS + A=1./(1.+G) + X=ET/RMAS + DO 207 I=1,N + BQ=B(1)*Q(1,I)+B(2)*Q(2,I)+B(3)*Q(3,I) + DO 206 K=1,3 + 206 P(K,I)=X*(Q(K,I)+B(K)*(Q(4,I)+A*BQ)) + 207 P(4,I)=X*(G*Q(4,I)+BQ) + WT=PO2LOG + IF(N.NE.2) WT=(2.*N-4.)*LOG(ET)+Z(N) + 209 IF(NM.NE.0) GOTO 210 + RETURN + 210 XMAX=SQRT(1.-(XMT/ET)**2) + DO 301 I=1,N + XM2(I)=XM(I)**2 + 301 P2(I)=P(4,I)**2 + ITER=0 + X=XMAX + ACCU=ET*ACC + 302 F0=-ET + G0=0. + X2=X*X + DO 303 I=1,N + E(I)=SQRT(XM2(I)+X2*P2(I)) + F0=F0+E(I) + 303 G0=G0+P2(I)/E(I) + IF(ABS(F0).LE.ACCU) GOTO 305 + ITER=ITER+1 + IF(ITER.LE.ITMAX) GOTO 304 + GOTO 305 + 304 X=X-F0/(X*G0) + GOTO 302 + 305 DO 307 I=1,N + V(I)=X*P(4,I) + DO 306 K=1,3 + 306 P(K,I)=X*P(K,I) + 307 P(4,I)=E(I) + RETURN + 1001 FORMAT(' RAMBO FAILS: # OF PARTICLES =',I5,' IS NOT ALLOWED') + 1002 FORMAT(' RAMBO FAILS: TOTAL MASS =',D15.6,' IS NOT', + . ' SMALLER THAN TOTAL ENERGY =',D15.6) + END + + + FUNCTION RN(IDUMMY) + REAL*8 RN,RAN + SAVE INIT + DATA INIT /1/ + IF (INIT.EQ.1) THEN + INIT=0 + CALL RMARIN(1802,9373) + END IF + 10 CALL RANMAR(RAN) + IF (RAN.LT.1D-16) GOTO 10 + RN=RAN + END + + + SUBROUTINE RANMAR(RVEC) +C Universal random number generator proposed by Marsaglia and Zaman. + IMPLICIT REAL*8(A-H,O-Z) + COMMON/ RASET1 / RANU(97),RANC,RANCD,RANCM + COMMON/ RASET2 / IRANMR,JRANMR + SAVE /RASET1/,/RASET2/ + UNI = RANU(IRANMR) - RANU(JRANMR) + IF(UNI .LT. 0D0) UNI = UNI + 1D0 + RANU(IRANMR) = UNI + IRANMR = IRANMR - 1 + JRANMR = JRANMR - 1 + IF(IRANMR .EQ. 0) IRANMR = 97 + IF(JRANMR .EQ. 0) JRANMR = 97 + RANC = RANC - RANCD + IF(RANC .LT. 0D0) RANC = RANC + RANCM + UNI = UNI - RANC + IF(UNI .LT. 0D0) UNI = UNI + 1D0 + RVEC = UNI + END + + + SUBROUTINE RMARIN(IJ,KL) +C Initializing routine for RANMAR. + IMPLICIT REAL*8(A-H,O-Z) + COMMON/ RASET1 / RANU(97),RANC,RANCD,RANCM + COMMON/ RASET2 / IRANMR,JRANMR + SAVE /RASET1/,/RASET2/ + I = MOD( IJ/177 , 177 ) + 2 + J = MOD( IJ , 177 ) + 2 + K = MOD( KL/169 , 178 ) + 1 + L = MOD( KL , 169 ) + DO 300 II = 1 , 97 + S = 0D0 + T = .5D0 + DO 200 JJ = 1 , 24 + M = MOD( MOD(I*J,179)*K , 179 ) + I = J + J = K + K = M + L = MOD( 53*L+1 , 169 ) + IF(MOD(L*M,64) .GE. 32) S = S + T + T = .5D0*T + 200 CONTINUE + RANU(II) = S + 300 CONTINUE + RANC = 362436D0 / 16777216D0 + RANCD = 7654321D0 / 16777216D0 + RANCM = 16777213D0 / 16777216D0 + IRANMR = 97 + JRANMR = 33 + END diff --git a/madgraph/iolibs/template_files/madevent_makefile_source b/madgraph/iolibs/template_files/madevent_makefile_source index 7339361d56..466c664fde 100644 --- a/madgraph/iolibs/template_files/madevent_makefile_source +++ b/madgraph/iolibs/template_files/madevent_makefile_source @@ -71,6 +71,14 @@ $(BINDIR)gensudgrid: $(GENSUDGRID) $(LIBDIR)libpdf.$(libext) $(LIBDIR)libgammaUP # Dependencies +# The model form-factor ALOHA routines in DHELAS and PDF/pdfwrap_lhapdf.f both do +# "use model_object", whose F90 module (model_object.mod) is produced by the +# MODEL build. Under a parallel (-j) top-level build libmodel, libdhelas and +# libpdf are otherwise made concurrently, so order libdhelas and libpdf after +# libmodel. Order-only (|) so they are not relinked when libmodel merely rebuilds. +$(LIBDIR)libdhelas.$(libext): | $(LIBDIR)libmodel.$(libext) +$(LIBDIR)libpdf.$(libext): | $(LIBDIR)libmodel.$(libext) + dsample.o: DiscreteSampler.o dsample.f genps.inc StringCast.o vector.inc pawgraph.o: vector.inc DiscreteSampler.o: StringCast.o diff --git a/madgraph/iolibs/template_files/madmatrix/MatrixElementKernels.cc b/madgraph/iolibs/template_files/madmatrix/MatrixElementKernels.cc index 872e4795e3..5aa8eae084 100644 --- a/madgraph/iolibs/template_files/madmatrix/MatrixElementKernels.cc +++ b/madgraph/iolibs/template_files/madmatrix/MatrixElementKernels.cc @@ -10,6 +10,7 @@ #include "GpuRuntime.h" // Includes the abstraction for Nvidia/AMD compilation #include "MemoryAccessMomenta.h" #include "MemoryBuffers.h" +#include "color_sum.h" // for blasColorSumTmpSize #include // for fetestexcept #include @@ -310,7 +311,7 @@ namespace mg5amcGpu , m_pHelJamps() , m_pHelNumerators() , m_pHelDenominators() - , m_colJamp2s( CPPProcess::ncolor * this->nevt() ) + , m_colJamp2s( CPPProcess::ncolor_flow * this->nevt() ) #ifdef MGONGPU_CHANNELID_DEBUG , m_hstChannelIds( this->nevt() ) #endif @@ -464,14 +465,10 @@ namespace mg5amcGpu m_pHelNumerators.reset( new DeviceBufferSimple( nGoodHel * CPPProcess::ndiagrams * nevt ) ); m_pHelDenominators.reset( new DeviceBufferSimple( nGoodHel * nevt ) ); #ifndef MGONGPU_HAS_NO_BLAS - // Create the "many-helicity" super-buffers of real/imag ncolor*nevt temporary buffers for cuBLAS/hipBLAS intermediate results in color_sum_blas -#if defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT - // Mixed precision mode: need two fptype2[ncolor*2*nevt] buffers and one fptype2[nevt] buffer per good helicity - if( m_blasColorSum ) m_pHelBlasTmp.reset( new DeviceBufferSimple2( nGoodHel * ( 2 * CPPProcess::ncolor * mgOnGpu::nx2 + 1 ) * nevt ) ); -#else - // Standard single/double precision mode: need one fptype2[ncolor*2*nevt] buffer per good helicity - if( m_blasColorSum ) m_pHelBlasTmp.reset( new DeviceBufferSimple2( nGoodHel * CPPProcess::ncolor * mgOnGpu::nx2 * nevt ) ); -#endif + // Create the "many-helicity" super-buffer of temporary buffers for the cuBLAS/hipBLAS intermediate + // results in color_sum_blas, and for the jamps gathered onto the ncolorfold color flows the color + // sum is folded onto (see blasColorSumTmpSize in color_sum.h, which is where the size is defined) + if( m_blasColorSum ) m_pHelBlasTmp.reset( new DeviceBufferSimple2( blasColorSumTmpSize( nGoodHel, nevt ) ) ); #endif // Return the number of good helicities return nGoodHel; diff --git a/madgraph/iolibs/template_files/madmatrix/check_sa.cc b/madgraph/iolibs/template_files/madmatrix/check_sa.cc index 68e93edb50..563c7e4dc6 100644 --- a/madgraph/iolibs/template_files/madmatrix/check_sa.cc +++ b/madgraph/iolibs/template_files/madmatrix/check_sa.cc @@ -386,7 +386,11 @@ namespace } }; - inline double rn() + // Persistent RANMAR state, re-seedable via reset_rng() so a caller can draw + // the SAME first phase-space point for several mass permutations (used by the + // crossing demo to show each crossed subprocess at the point a standalone run + // of it would generate). + inline Random& rng() { static Random rand; static bool init = true; @@ -395,10 +399,16 @@ namespace init = false; rand.rmarin( 1802, 9373 ); } + return rand; + } + inline void reset_rng() { rng().rmarin( 1802, 9373 ); } + + inline double rn() + { double ran; while( true ) { - ran = rand.ranmar(); + ran = rng().ranmar(); if( ran > 1e-16 ) break; } return ran; @@ -648,8 +658,10 @@ namespace } const std::vector masses( massesD.begin(), massesD.end() ); + // NB: feed the double-precision masses to the classic RAMBO, which works in + // double throughout: 'masses' is fptype and would not convert at FPTYPE=f. std::vector> point = - classic_rambo::get_momenta( CPPProcess::npari, (double)kEnergy, masses, rambowgt ); + classic_rambo::get_momenta( CPPProcess::npari, (double)kEnergy, massesD, rambowgt ); // alpha_s from the param card so the couplings match the Fortran/C++ // 'check' drivers (UMAMI otherwise falls back to a hardcoded g_s). @@ -749,6 +761,86 @@ namespace << std::string( SEP79, '-' ) << std::endl; } + // === Crossed subprocesses folded into this base matrix element === + // The exporter lists their extended flavor ids in crossing_demo.dat; show + // each at the RAMBO point generated for ITS OWN mass permutation (the crossed + // legs carry the same particles as the base, relabelled, so the crossed + // masses are a permutation of the base masses). + { + std::vector demo_ids; + std::ifstream fdemo( "crossing_demo.dat" ); + unsigned int _did; + while( fdemo >> _did ) demo_ids.push_back( _did ); + if( !demo_ids.empty() ) + { + std::cout << std::endl + << " Crossed processes folded into this matrix element:" + << std::endl; + for( unsigned int fid : demo_ids ) + { + // Crossed masses: base mass of the leg carrying the same |PDG|. + std::vector xmasses( CPPProcess::npar ); + for( int k = 0; k < CPPProcess::npar; ++k ) + { + const int pk = std::abs( CPPProcess::flavorPDG( (int)fid, k ) ); + double mk = 0.; + for( int j = 0; j < CPPProcess::npar; ++j ) + if( std::abs( CPPProcess::flavorPDG( 0, j ) ) == pk ) { mk = (double)masses[j]; break; } + xmasses[k] = mk; + } + double xwgt = 0.; + classic_rambo::reset_rng(); // draw the FIRST point for this mass permutation + std::vector> xpoint = + classic_rambo::get_momenta( CPPProcess::npari, (double)kEnergy, xmasses, xwgt ); + for( int ip4 = 0; ip4 < 4; ++ip4 ) + for( int ipar = 0; ipar < CPPProcess::npar; ++ipar ) + for( unsigned int ievt = 0; ievt < nevt; ++ievt ) + umamiMomenta[(std::size_t)ip4 * CPPProcess::npar * nevt + (std::size_t)ipar * nevt + ievt] = xpoint[ipar][ip4]; + std::fill( flvVec.begin(), flvVec.end(), fid ); +#ifdef MGONGPUCPP_GPUIMPL + gpuMemcpy( devUmamiMomenta.data(), umamiMomenta.data(), umamiMomenta.size() * sizeof( double ), gpuMemcpyHostToDevice ); + gpuMemcpy( devFlv.data(), flvVec.data(), nevt * sizeof( unsigned int ), gpuMemcpyHostToDevice ); +#endif + UmamiInputKey in_keys[3] = { UMAMI_IN_MOMENTA, UMAMI_IN_FLAVOR_INDEX, UMAMI_IN_ALPHA_S }; + UmamiOutputKey out_keys[1] = { UMAMI_OUT_MATRIX_ELEMENT }; +#ifdef MGONGPUCPP_GPUIMPL + const void* inputs[3] = { devUmamiMomenta.data(), devFlv.data(), devAlphaS.data() }; + void* outputs[1] = { devUmamiMEs.data() }; +#else + const void* inputs[3] = { umamiMomenta.data(), flvVec.data(), alphasVec.data() }; + void* outputs[1] = { umamiMEs.data() }; +#endif + UmamiStatus xst = umami_matrix_element( + umami_handle, nevt, nevt, 0, 3, in_keys, inputs, 1, out_keys, outputs ); + if( xst != UMAMI_SUCCESS ) + { + std::cerr << "ERROR! crossed umami_matrix_element failed (flavorID=" << fid << ")" << std::endl; + continue; + } +#ifdef MGONGPUCPP_GPUIMPL + gpuMemcpy( hstUmamiMEs.data(), devUmamiMEs.data(), nevt * sizeof( double ), gpuMemcpyDeviceToHost ); + const double* xmes = hstUmamiMEs.data(); +#else + const double* xmes = umamiMEs.data(); +#endif + std::cout << std::endl << " flavorID " << fid << std::endl + << " PDG E px py pz" << std::endl; + for( int ipar = 0; ipar < CPPProcess::npar; ++ipar ) + std::cout << std::scientific << std::setprecision( 7 ) + << std::setw( 6 ) << CPPProcess::flavorPDG( (int)fid, ipar ) + << std::setw( 16 ) << xpoint[ipar][0] + << std::setw( 16 ) << xpoint[ipar][1] + << std::setw( 16 ) << xpoint[ipar][2] + << std::setw( 16 ) << xpoint[ipar][3] + << std::endl << std::defaultfloat; + std::cout << " Matrix element = " << std::scientific << std::setprecision( 16 ) + << xmes[0] << " GeV^" << kMEGeVExponent << std::endl + << std::defaultfloat + << std::string( SEP79, '-' ) << std::endl; + } + } + } + umami_free( umami_handle ); return 0; } diff --git a/madgraph/iolibs/template_files/madmatrix/color_sum.cc b/madgraph/iolibs/template_files/madmatrix/color_sum.cc index 30c6799932..903c6956a2 100644 --- a/madgraph/iolibs/template_files/madmatrix/color_sum.cc +++ b/madgraph/iolibs/template_files/madmatrix/color_sum.cc @@ -24,21 +24,21 @@ namespace mg5amcCpu %(color_matrix_lines)s #ifdef MGONGPUCPP_GPUIMPL - // The normalized color matrix (divide each column by denom) + // The normalized folded color matrix (divide each column by denom) template struct NormalizedColorMatrix { constexpr __host__ __device__ NormalizedColorMatrix() : value() { - for( int icol = 0; icol < ncolor; icol++ ) - for( int jcol = 0; jcol < ncolor; jcol++ ) - value[icol * ncolor + jcol] = colorMatrix[icol][jcol] / colorDenom[icol]; + for( int ifold = 0; ifold < ncolorfold; ifold++ ) + for( int jfold = 0; jfold < ncolorfold; jfold++ ) + value[ifold * ncolorfold + jfold] = colorMatrix[ifold][jfold] / colorDenom[ifold]; } - T value[ncolor * ncolor]; + T value[ncolorfold * ncolorfold]; }; - // The fptype2 version is the default used by kernels (supporting mixed floating point mode also in blas) - static __device__ fptype2 s_pNormalizedColorMatrix2[ncolor * ncolor]; + // The fptype2 version is the default used by kernels (supporting mixed floating point mode) + static __device__ fptype2 s_pNormalizedColorMatrixFold2[ncolorfold * ncolorfold]; #endif //-------------------------------------------------------------------------- @@ -51,7 +51,7 @@ namespace mg5amcCpu { first = false; constexpr NormalizedColorMatrix normalizedColorMatrix2; - gpuMemcpyToSymbol( s_pNormalizedColorMatrix2, normalizedColorMatrix2.value, ncolor * ncolor * sizeof( fptype2 ) ); + gpuMemcpyToSymbol( s_pNormalizedColorMatrixFold2, normalizedColorMatrix2.value, ncolorfold * ncolorfold * sizeof( fptype2 ) ); } } #endif @@ -71,64 +71,62 @@ namespace mg5amcCpu __host__ __device__ constexpr TriangularNormalizedColorMatrix() : value() { - for( int icol = 0; icol < ncolor; icol++ ) + for( int ifold = 0; ifold < ncolorfold; ifold++ ) { // Diagonal terms - value[icol][icol] = colorMatrix[icol][icol] / colorDenom[icol]; + value[ifold][ifold] = colorMatrix[ifold][ifold] / colorDenom[ifold]; // Off-diagonal terms - for( int jcol = icol + 1; jcol < ncolor; jcol++ ) - value[icol][jcol] = 2 * colorMatrix[icol][jcol] / colorDenom[icol]; + for( int jfold = ifold + 1; jfold < ncolorfold; jfold++ ) + value[ifold][jfold] = 2 * colorMatrix[ifold][jfold] / colorDenom[ifold]; } } - fptype2 value[ncolor][ncolor]; + fptype2 value[ncolorfold][ncolorfold]; }; static constexpr auto cf2 = TriangularNormalizedColorMatrix(); // Use the property that M is a real matrix (see #475): // we can rewrite the quadratic form (A-iB)(M)(A+iB) as AMA - iBMA + iBMA + BMB = AMA + BMB // In addition, on C++ use the property that M is symmetric (see #475), - // and also use constexpr to compute "2*" and "/colorDenom[icol]" once and for all at compile time: + // and also use constexpr to compute "2*" and "/colorDenom[ifold]" once and for all at compile time: // we gain (not a factor 2...) in speed here as we only loop over the up diagonal part of the matrix. // Strangely, CUDA is slower instead, so keep the old implementation for the moment. fptype_sv deltaMEs = { 0 }; #if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT fptype_sv deltaMEs_next = { 0 }; - // Mixed mode: merge two neppV vectors into one neppV2 vector - fptype2_sv jampR_sv[ncolor]; - fptype2_sv jampI_sv[ncolor]; - for( int icol = 0; icol < ncolor; icol++ ) - { - jampR_sv[icol] = fpvmerge( cxreal( allJamp_sv[icol] ), cxreal( allJamp_sv[ncolor + icol] ) ); - jampI_sv[icol] = fpvmerge( cximag( allJamp_sv[icol] ), cximag( allJamp_sv[ncolor + icol] ) ); - } -#else - const cxtype_sv* jamp_sv = allJamp_sv; #endif - // Loop over icol - for( int icol = 0; icol < ncolor; icol++ ) + // Gather the color flows the sum runs over: one per reversal pair when the color basis + // folds (ncolorfold < ncolor), every flow otherwise (ncolorfold == ncolor, identity map). + // NB in mixed mode the two neppV vectors of allJamp_sv, at icol and at ncolor+icol, are + // two halves of the event page and not two colors: it is the color index inside each of + // them which is gathered, and the two are merged into one neppV2 vector. + fptype2_sv jampR_sv[ncolorfold]; + fptype2_sv jampI_sv[ncolorfold]; + for( int ifold = 0; ifold < ncolorfold; ifold++ ) { - // Diagonal terms + const int icol = colorFoldRep[ifold]; #if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT - fptype2_sv& jampRi_sv = jampR_sv[icol]; - fptype2_sv& jampIi_sv = jampI_sv[icol]; + jampR_sv[ifold] = fpvmerge( cxreal( allJamp_sv[icol] ), cxreal( allJamp_sv[ncolor + icol] ) ); + jampI_sv[ifold] = fpvmerge( cximag( allJamp_sv[icol] ), cximag( allJamp_sv[ncolor + icol] ) ); #else - fptype2_sv jampRi_sv = (fptype2_sv)( cxreal( jamp_sv[icol] ) ); - fptype2_sv jampIi_sv = (fptype2_sv)( cximag( jamp_sv[icol] ) ); + jampR_sv[ifold] = (fptype2_sv)( cxreal( allJamp_sv[icol] ) ); + jampI_sv[ifold] = (fptype2_sv)( cximag( allJamp_sv[icol] ) ); #endif - fptype2_sv ztempR_sv = cf2.value[icol][icol] * jampRi_sv; - fptype2_sv ztempI_sv = cf2.value[icol][icol] * jampIi_sv; - // Loop over jcol - for( int jcol = icol + 1; jcol < ncolor; jcol++ ) + } + // Loop over ifold + for( int ifold = 0; ifold < ncolorfold; ifold++ ) + { + // Diagonal terms + fptype2_sv& jampRi_sv = jampR_sv[ifold]; + fptype2_sv& jampIi_sv = jampI_sv[ifold]; + fptype2_sv ztempR_sv = cf2.value[ifold][ifold] * jampRi_sv; + fptype2_sv ztempI_sv = cf2.value[ifold][ifold] * jampIi_sv; + // Loop over jfold + for( int jfold = ifold + 1; jfold < ncolorfold; jfold++ ) { // Off-diagonal terms -#if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT - fptype2_sv& jampRj_sv = jampR_sv[jcol]; - fptype2_sv& jampIj_sv = jampI_sv[jcol]; -#else - fptype2_sv jampRj_sv = (fptype2_sv)( cxreal( jamp_sv[jcol] ) ); - fptype2_sv jampIj_sv = (fptype2_sv)( cximag( jamp_sv[jcol] ) ); -#endif - ztempR_sv += cf2.value[icol][jcol] * jampRj_sv; - ztempI_sv += cf2.value[icol][jcol] * jampIj_sv; + fptype2_sv& jampRj_sv = jampR_sv[jfold]; + fptype2_sv& jampIj_sv = jampI_sv[jfold]; + ztempR_sv += cf2.value[ifold][jfold] * jampRj_sv; + ztempI_sv += cf2.value[ifold][jfold] * jampIj_sv; } fptype2_sv deltaMEs2 = ( jampRi_sv * ztempR_sv + jampIi_sv * ztempI_sv ); // may underflow #831 #if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT @@ -151,7 +149,7 @@ namespace mg5amcCpu #endif } #endif - +%(cpp_blas_color_sum)s //-------------------------------------------------------------------------- #ifdef MGONGPUCPP_GPUIMPL @@ -167,40 +165,42 @@ namespace mg5amcCpu allJamps = allJamps + ighel * nevtIfAllHelicities; // Jamps for one specific helicity ighel } using J_ACCESS = DeviceAccessJamp; - fptype jampR[ncolor]; - fptype jampI[ncolor]; - for( int icol = 0; icol < ncolor; icol++ ) + // Gather the color flows the sum runs over: one per reversal pair when the color basis + // folds (ncolorfold < ncolor), every flow otherwise (ncolorfold == ncolor, identity map) + fptype jampR[ncolorfold]; + fptype jampI[ncolorfold]; + for( int ifold = 0; ifold < ncolorfold; ifold++ ) { constexpr int ihel0 = 0; // the input buffer allJamps already points to a specific helicity - cxtype jamp = J_ACCESS::kernelAccessIcolIhelNhelConst( allJamps, icol, ihel0, nGoodHel ); - jampR[icol] = jamp.real(); - jampI[icol] = jamp.imag(); + cxtype jamp = J_ACCESS::kernelAccessIcolIhelNhelConst( allJamps, colorFoldRep[ifold], ihel0, nGoodHel ); + jampR[ifold] = jamp.real(); + jampI[ifold] = jamp.imag(); } - // Loop over icol + // Loop over ifold fptype deltaMEs = { 0 }; - for( int icol = 0; icol < ncolor; icol++ ) + for( int ifold = 0; ifold < ncolorfold; ifold++ ) { fptype2 ztempR = { 0 }; fptype2 ztempI = { 0 }; - fptype2 jampRi = jampR[icol]; - fptype2 jampIi = jampI[icol]; - // OLD IMPLEMENTATION (ihel3: symmetric square matrix) - Loop over all jcol - //for( int jcol = 0; jcol < ncolor; jcol++ ) + fptype2 jampRi = jampR[ifold]; + fptype2 jampIi = jampI[ifold]; + // OLD IMPLEMENTATION (ihel3: symmetric square matrix) - Loop over all jfold + //for( int jfold = 0; jfold < ncolorfold; jfold++ ) //{ - // fptype2 jampRj = jampR[jcol]; - // fptype2 jampIj = jampI[jcol]; - // ztempR += s_pNormalizedColorMatrix2[icol * ncolor + jcol] * jampRj; // use fptype2 version of color matrix - // ztempI += s_pNormalizedColorMatrix2[icol * ncolor + jcol] * jampIj; // use fptype2 version of color matrix + // fptype2 jampRj = jampR[jfold]; + // fptype2 jampIj = jampI[jfold]; + // ztempR += s_pNormalizedColorMatrixFold2[ifold * ncolorfold + jfold] * jampRj; // use fptype2 version of color matrix + // ztempI += s_pNormalizedColorMatrixFold2[ifold * ncolorfold + jfold] * jampIj; // use fptype2 version of color matrix //} - // NEW IMPLEMENTATION #475 (ihel3p1: triangular lower diagonal matrix) - Loop over jcol < icol - ztempR += s_pNormalizedColorMatrix2[icol * ncolor + icol] * jampRi; // use fptype2 version of color matrix - ztempI += s_pNormalizedColorMatrix2[icol * ncolor + icol] * jampIi; // use fptype2 version of color matrix - for( int jcol = 0; jcol < icol; jcol++ ) + // NEW IMPLEMENTATION #475 (ihel3p1: triangular lower diagonal matrix) - Loop over jfold < ifold + ztempR += s_pNormalizedColorMatrixFold2[ifold * ncolorfold + ifold] * jampRi; // use fptype2 version of color matrix + ztempI += s_pNormalizedColorMatrixFold2[ifold * ncolorfold + ifold] * jampIi; // use fptype2 version of color matrix + for( int jfold = 0; jfold < ifold; jfold++ ) { - fptype2 jampRj = jampR[jcol]; - fptype2 jampIj = jampI[jcol]; - ztempR += 2 * s_pNormalizedColorMatrix2[icol * ncolor + jcol] * jampRj; // use fptype2 version of color matrix - ztempI += 2 * s_pNormalizedColorMatrix2[icol * ncolor + jcol] * jampIj; // use fptype2 version of color matrix + fptype2 jampRj = jampR[jfold]; + fptype2 jampIj = jampI[jfold]; + ztempR += 2 * s_pNormalizedColorMatrixFold2[ifold * ncolorfold + jfold] * jampRj; // use fptype2 version of color matrix + ztempI += 2 * s_pNormalizedColorMatrixFold2[ifold * ncolorfold + jfold] * jampIj; // use fptype2 version of color matrix } deltaMEs += ztempR * jampRi; deltaMEs += ztempI * jampIi; @@ -216,23 +216,45 @@ namespace mg5amcCpu #ifdef MGONGPUCPP_GPUIMPL #ifndef MGONGPU_HAS_NO_BLAS -#if defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT + // Compact the jamps onto the ncolorfold color flows the sum runs over, one per reversal + // pair when the color basis folds (see color_sum_blas): the representative flows are not + // contiguous, so this is a gather through colorFoldRep and not a copy. Without a folding + // ncolorfold == ncolor and colorFoldRep is the identity, so this only converts. The + // conversion to fptype2 is a no-op except in mixed floating point mode (double to float). __global__ void - convertD2F_Jamps( fptype2* allJampsFpt2, // output: jamp[2][ncolor][ihel][nevt] for one specific helicity ihel + gatherFold_Jamps( fptype2* allJampsFold2, // output: jamp[2][ncolorfold][ihel][nevt] for one specific helicity ihel const fptype* allJamps, // input: jamp[2][ncolor][ihel][nevt] for one specific helicity ihel const int nhel ) // input: number of good helicities nGoodHel { const int nevt = gridDim.x * blockDim.x; const int ievt = blockDim.x * blockIdx.x + threadIdx.x; constexpr int ihel = 0; // the input buffer allJamps already points to a specific helicity - // NB! From a functional point of view, any striding will be ok here as long as ncolor*2*nevt elements are all correctly copied! + // NB! From a functional point of view, any striding will be ok here as long as ncolorfold*2*nevt elements are all correctly gathered! // NB! Just in case this may be better for performance reasons, however, the same striding as in compute_jamps and cuBLAS is used here for( int ix2 = 0; ix2 < mgOnGpu::nx2; ix2++ ) - for( int icol = 0; icol < ncolor; icol++ ) - allJampsFpt2[ix2 * ncolor * nhel * nevt + icol * nhel * nevt + ihel * nevt + ievt] = - allJamps[ix2 * ncolor * nhel * nevt + icol * nhel * nevt + ihel * nevt + ievt]; + for( int ifold = 0; ifold < ncolorfold; ifold++ ) + allJampsFold2[ix2 * ncolorfold * nhel * nevt + ifold * nhel * nevt + ihel * nevt + ievt] = + allJamps[ix2 * ncolor * nhel * nevt + colorFoldRep[ifold] * nhel * nevt + ihel * nevt + ievt]; + } + + // Gather the jamps of every good helicity into ghelAllJampsBuf, and return it + fptype2* + gatherFold_AllJamps( fptype2* ghelAllJampsBuf, // output: allJamps super-buffer[2][ncolorfold][nhel][nevt] + const fptype* ghelAllJamps, // input: allJamps super-buffer[2][ncolor][nhel][nevt] + gpuStream_t* ghelStreams, // input: cuda streams (index is ighel) + const int nhel, // input: number of good helicities + const int gpublocks, // input: cuda gpublocks + const int gputhreads ) // input: cuda gputhreads + { + const int nevt = gpublocks * gputhreads; + for( int ighel = 0; ighel < nhel; ighel++ ) + { + const fptype* hAllJamps = ghelAllJamps + ighel * nevt; // jamps for a single helicity ihel + fptype2* hAllJampsFold2 = ghelAllJampsBuf + ighel * nevt; // folded jamps for a single helicity ihel + gpuLaunchKernelStream( gatherFold_Jamps, gpublocks, gputhreads, ghelStreams[ighel], hAllJampsFold2, hAllJamps, nhel ); + } + return ghelAllJampsBuf; } -#endif #endif #endif @@ -261,60 +283,61 @@ namespace mg5amcCpu const fptype* ghelAllJamps, // input: allJamps super-buffer[2][ncol][nhel][nevt] for nhel good helicities fptype2* ghelAllBlasTmp, // tmp: allBlasTmp super-buffer for nhel good helicities gpuBlasHandle_t* pBlasHandle, // input: cuBLAS/hipBLAS handle -#if defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT gpuStream_t* ghelStreams, // input: cuda streams (index is ighel: only the first nhel <= ncomb are non-null) -#else - gpuStream_t* /*ghelStreams*/, // input: cuda streams (index is ighel: only the first nhel <= ncomb are non-null) -#endif const int nhel, // input: number of good helicities (nhel == nGoodHel) const int gpublocks, // input: cuda gpublocks const int gputhreads ) // input: cuda gputhreads { const int nevt = gpublocks * gputhreads; - // Get the address associated with the normalized color matrix in device memory + // As in color_sum_cpu and color_sum_kernel, the sum is folded onto one color flow per + // reversal pair: the jamps are first gathered from the ncolor flows they are written in + // down to the ncolorfold flows which are kept (see gatherFold_Jamps), and it is those + // which are multiplied by the folded color matrix. Without a folding ncolorfold == ncolor, + // the gather is the identity and this is the plain ncolor x ncolor color sum. + + // Get the address associated with the normalized folded color matrix in device memory static fptype2* devNormColMat = nullptr; - if( !devNormColMat ) gpuGetSymbolAddress( (void**)&devNormColMat, s_pNormalizedColorMatrix2 ); + if( !devNormColMat ) gpuGetSymbolAddress( (void**)&devNormColMat, s_pNormalizedColorMatrixFold2 ); + // The scratch buffer holds the BLAS intermediate results, the gathered jamps if they need + // a buffer of their own, and in mixed precision mode the fptype2 MEs: see the layout in + // blasColorSumTmpSize, which is what MatrixElementKernels.cc allocates. + fptype2* ghelAllZtempBoth = ghelAllBlasTmp; // start of the fptype2[ncolorfold*2*nhel*nevt] buffer + fptype2* ghelAllJampsBuf = ghelAllBlasTmp + ncolorfold * mgOnGpu::nx2 * nhel * nevt; // start of the second one, if there is one #if defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT - // Mixed precision mode: need two fptype2[2*ncolor*nhel*nevt] buffers and one fptype2[nhel*nevt] buffers for the nhel helicities - fptype2* ghelAllZtempBoth = ghelAllBlasTmp; // start of first fptype2[ncolor*2*nhel*nevt] buffer - fptype2* ghelAllJampsFpt2 = ghelAllBlasTmp + ncolor * mgOnGpu::nx2 * nhel * nevt; // start of second fptype2[ncolor*2*nhel*nevt] buffer - fptype2* ghelAllMEsFpt2 = ghelAllBlasTmp + 2 * ncolor * mgOnGpu::nx2 * nhel * nevt; // start of fptype2[nhel*nevt] buffer - // Convert jamps from double to float - for( int ighel = 0; ighel < nhel; ighel++ ) - { - const fptype* hAllJamps = ghelAllJamps + ighel * nevt; // jamps for a single helicity ihel - fptype2* hAllJampsFpt2 = ghelAllJampsFpt2 + ighel * nevt; // jamps for a single helicity ihel - gpuLaunchKernelStream( convertD2F_Jamps, gpublocks, gputhreads, ghelStreams[ighel], hAllJampsFpt2, hAllJamps, nhel ); - } - // Real and imaginary components - const fptype2* ghelAllJampsReal = ghelAllJampsFpt2; - const fptype2* ghelAllJampsImag = ghelAllJampsFpt2 + ncolor * nhel * nevt; + // Mixed precision mode: the jamps are gathered into a buffer of their own in any case, + // as they must be converted from double to float on the way + static_assert( blasColorSumNeedsJampBuffer() ); + fptype2* ghelAllMEsFpt2 = ghelAllBlasTmp + 2 * ncolorfold * mgOnGpu::nx2 * nhel * nevt; // start of the fptype2[nhel*nevt] buffer + const fptype2* ghelAllJampsFold2 = gatherFold_AllJamps( ghelAllJampsBuf, ghelAllJamps, ghelStreams, nhel, gpublocks, gputhreads ); #else - // Standard single or double precision mode: need one fptype2[ncolor*2*nhel*nevt] buffer static_assert( std::is_same::value ); - fptype2* ghelAllZtempBoth = ghelAllBlasTmp; // start of fptype2[ncolor*2*nhel*nevt] buffer fptype2* ghelAllMEsFpt2 = ghelAllMEs; - // Real and imaginary components - const fptype2* ghelAllJampsReal = ghelAllJamps; // this is not a cast (the two types are identical) - const fptype2* ghelAllJampsImag = ghelAllJamps + ncolor * nhel * nevt; // this is not a cast (the two types are identical) + // Without a folding there is nothing to gather and nothing to convert: read the jamps + // where compute_jamps wrote them (this is not a cast, the two types are identical) + const fptype2* ghelAllJampsFold2 = + ( blasColorSumNeedsJampBuffer() + ? gatherFold_AllJamps( ghelAllJampsBuf, ghelAllJamps, ghelStreams, nhel, gpublocks, gputhreads ) + : ghelAllJamps ); #endif // Real and imaginary components + const fptype2* ghelAllJampsReal = ghelAllJampsFold2; + const fptype2* ghelAllJampsImag = ghelAllJampsFold2 + ncolorfold * nhel * nevt; fptype2* ghelAllZtempReal = ghelAllZtempBoth; - fptype2* ghelAllZtempImag = ghelAllZtempBoth + ncolor * nhel * nevt; + fptype2* ghelAllZtempImag = ghelAllZtempBoth + ncolorfold * nhel * nevt; - // Note: striding for cuBLAS from DeviceAccessJamp: - // - ghelAllJamps(icol,ihel,ievt).real is ghelAllJamps[0 * ncolor * nhel * nevt + icol * nhel * nevt + ihel * nevt + ievt] - // - ghelAllJamps(icol,ihel,ievt).imag is ghelAllJamps[1 * ncolor * nhel * nevt + icol * nhel * nevt + ihel * nevt + ievt] + // Note: striding for cuBLAS from gatherFold_Jamps (that of DeviceAccessJamp, over ncolorfold): + // - ghelAllJampsFold2(ifold,ihel,ievt).real is ghelAllJampsFold2[0 * ncolorfold * nhel * nevt + ifold * nhel * nevt + ihel * nevt + ievt] + // - ghelAllJampsFold2(ifold,ihel,ievt).imag is ghelAllJampsFold2[1 * ncolorfold * nhel * nevt + ifold * nhel * nevt + ihel * nevt + ievt] - // Step 1: Compute Ztemp[ncolor][nhel*nevt] = ColorMatrix[ncolor][ncolor] * JampsVector[ncolor][nhel*nevt] for both real and imag + // Step 1: Compute Ztemp[ncolorfold][nhel*nevt] = ColorMatrix[ncolorfold][ncolorfold] * JampsVector[ncolorfold][nhel*nevt] for both real and imag // In this case alpha=1 and beta=0: the operation is Ztemp = alpha * ColorMatrix * JampsVector + beta * Ztemp fptype2 alpha1 = 1; fptype2 beta1 = 0; - const int ncolorM = ncolor; + const int ncolorM = ncolorfold; const int nevtN = nhel*nevt; - const int ncolorK = ncolor; + const int ncolorK = ncolorfold; checkGpuBlas( gpuBlasTgemm( *pBlasHandle, GPUBLAS_OP_N, // do not transpose ColMat GPUBLAS_OP_T, // transpose JampsV (new1) @@ -334,31 +357,31 @@ namespace mg5amcCpu &beta1, ghelAllZtempImag, ncolorM ) ); // Ztemp is ncolorM x nevtN - // Step 2: For each ievt, compute the dot product of JampsVector[ncolor][ievt] dot tmp[ncolor][ievt] + // Step 2: For each ievt, compute the dot product of JampsVector[ncolorfold][ievt] dot tmp[ncolorfold][ievt] // In this case alpha=1 and beta=1: the operation is ME = alpha * ( Tmp dot JampsVector ) + beta * ME // Use cublasSgemmStridedBatched to perform these batched dot products in one call fptype2 alpha2 = 1; fptype2 beta2 = 1; checkGpuBlas( gpuBlasTgemmStridedBatched( *pBlasHandle, - GPUBLAS_OP_N, // do not transpose JampsV (new1) - GPUBLAS_OP_N, // do not transpose Tmp - 1, 1, ncolor, // result is 1x1 (dot product) + GPUBLAS_OP_N, // do not transpose JampsV (new1) + GPUBLAS_OP_N, // do not transpose Tmp + 1, 1, ncolorfold, // result is 1x1 (dot product) &alpha2, - ghelAllJampsReal, nevtN, 1, // allJamps is nevtN x ncolor, stride 1 for each ievt column - ghelAllZtempReal, ncolor, ncolor, // allZtemp is ncolor x nevtN, with stride ncolor for each ievt column + ghelAllJampsReal, nevtN, 1, // allJamps is nevtN x ncolorfold, stride 1 for each ievt column + ghelAllZtempReal, ncolorfold, ncolorfold, // allZtemp is ncolorfold x nevtN, with stride ncolorfold for each ievt column &beta2, - ghelAllMEsFpt2, 1, 1, // output is a 1x1 result for each "batch" (i.e. for each ievt) - nevtN ) ); // there are nevtN (nhel*nevt) "batches" + ghelAllMEsFpt2, 1, 1, // output is a 1x1 result for each "batch" (i.e. for each ievt) + nevtN ) ); // there are nevtN (nhel*nevt) "batches" checkGpuBlas( gpuBlasTgemmStridedBatched( *pBlasHandle, - GPUBLAS_OP_N, // do not transpose JampsV (new1) - GPUBLAS_OP_N, // do not transpose Tmp - 1, 1, ncolor, // result is 1x1 (dot product) + GPUBLAS_OP_N, // do not transpose JampsV (new1) + GPUBLAS_OP_N, // do not transpose Tmp + 1, 1, ncolorfold, // result is 1x1 (dot product) &alpha2, - ghelAllJampsImag, nevtN, 1, // allJamps is nevtN x ncolor, stride 1 for each ievt column (new1) - ghelAllZtempImag, ncolor, ncolor, // allZtemp is ncolor x nevtN, with stride ncolor for each ievt column + ghelAllJampsImag, nevtN, 1, // allJamps is nevtN x ncolorfold, stride 1 for each ievt column (new1) + ghelAllZtempImag, ncolorfold, ncolorfold, // allZtemp is ncolorfold x nevtN, with stride ncolorfold for each ievt column &beta2, - ghelAllMEsFpt2, 1, 1, // output is a 1x1 result for each "batch" (i.e. for each ievt) - nevtN ) ); // there are nevt (nhel*nevt) "batches" + ghelAllMEsFpt2, 1, 1, // output is a 1x1 result for each "batch" (i.e. for each ievt) + nevtN ) ); // there are nevt (nhel*nevt) "batches" #if defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT // Convert MEs from float to double @@ -415,12 +438,8 @@ namespace mg5amcCpu assert( false ); // BLAS in async mode not supported for now } else { checkGpu( gpuDeviceSynchronize() ); // do not start the BLAS color sum for all helicities until the loop over helicities has completed - // Reset the tmp buffer -#if defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT - gpuMemset( ghelAllBlasTmp, 0, nGoodHel * nevt * ( 2 * ncolor * mgOnGpu::nx2 + 1 ) * sizeof( fptype2 ) ); -#else - gpuMemset( ghelAllBlasTmp, 0, nGoodHel * nevt * ( ncolor * mgOnGpu::nx2 ) * sizeof( fptype2 ) ); -#endif + // Reset the tmp buffer (same size as the one MatrixElementKernelDevice allocated) + gpuMemset( ghelAllBlasTmp, 0, blasColorSumTmpSize( nGoodHel, nevt ) * sizeof( fptype2 ) ); // Delegate the color sum to BLAS for color_sum_blas( ghelAllMEs, ghelAllJamps, ghelAllBlasTmp, pBlasHandle, ghelStreams, nGoodHel, gpublocks, gputhreads ); } diff --git a/madgraph/iolibs/template_files/madmatrix/color_sum.h b/madgraph/iolibs/template_files/madmatrix/color_sum.h index 347184c4e1..cbaef72dea 100644 --- a/madgraph/iolibs/template_files/madmatrix/color_sum.h +++ b/madgraph/iolibs/template_files/madmatrix/color_sum.h @@ -14,6 +14,11 @@ #include "CPPProcess.h" #include "GpuAbstraction.h" +#include +#ifdef MGONGPU_CPP_HAS_BLAS +#include // the batched C++ color sum keeps the jamps of every good helicity +#endif + #ifdef MGONGPUCPP_GPUIMPL namespace mg5amcGpu #else @@ -22,6 +27,42 @@ namespace mg5amcCpu { //-------------------------------------------------------------------------- +#ifdef MGONGPUCPP_GPUIMPL +#ifndef MGONGPU_HAS_NO_BLAS + // The BLAS color sum multiplies the jamps gathered onto the ncolorfold color flows the sum + // runs over (see color_sum_blas): does that gather need a buffer of its own? Not when it is + // the identity because the color basis does not fold, and there is no fptype2 conversion to + // do either - there the jamps are read where compute_jamps already wrote them, exactly as + // the color sum did before it was folded. + constexpr bool + blasColorSumNeedsJampBuffer() + { +#if defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT + return true; // mixed precision mode: the jamps must be converted from double to float +#else + return CPPProcess::ncolorfold < CPPProcess::ncolor; +#endif + } + + // The size of the ghelAllBlasTmp scratch buffer color_sum_blas needs, in fptype2 elements: + // one fptype2[ncolorfold*nx2*nhel*nevt] buffer for the BLAS intermediate results, one more + // for the gathered jamps if they need one, and in mixed precision mode one fptype2[nhel*nevt] + // buffer for the MEs, which are fptype elsewhere. This is the one place the size is defined: + // both the allocation (MatrixElementKernels.cc) and the reset (color_sum_gpu) come here. + constexpr std::size_t + blasColorSumTmpSize( const int nhel, const int nevt ) + { + std::size_t nfptype2PerEvent = ( blasColorSumNeedsJampBuffer() ? 2 : 1 ) * CPPProcess::ncolorfold * mgOnGpu::nx2; +#if defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT + nfptype2PerEvent += 1; // the fptype2 matrix elements +#endif + return nfptype2PerEvent * (std::size_t)nhel * (std::size_t)nevt; + } +#endif +#endif + + //-------------------------------------------------------------------------- + #ifdef MGONGPUCPP_GPUIMPL class DeviceAccessJamp { @@ -76,6 +117,23 @@ namespace mg5amcCpu //-------------------------------------------------------------------------- + // Only defined for processes whose color matrix is large enough that the + // BLAS call is worth setting up (see blas_wanted): the color sum for every + // good helicity of one event page in one go. +#ifndef MGONGPUCPP_GPUIMPL +#ifdef MGONGPU_CPP_HAS_BLAS + void + color_sum_cpu_blas( fptype* allMEs, // input/output: allMEs[nevt], add |M|^2 summed over all good helicities + fptype_sv* MEs_ighel, // output: [ncomb] running sum of |M|^2 up to ighel (first - and/or only - neppV page) + fptype_sv* MEs_ighel2, // output: [ncomb] the same for the second neppV page (mixed mode only) + const cxtype_sv* ghelAllJamp_sv, // input: jamp_sv[nGoodHel][nParity*ncolor] for all good helicities + const int nGoodHel, // input: number of good helicities + const int ievt0 ); // input: first event number in current C++ event page +#endif +#endif + + //-------------------------------------------------------------------------- + #ifdef MGONGPUCPP_GPUIMPL void color_sum_gpu( fptype* ghelAllMEs, // output: allMEs super-buffer for nGoodHel <= ncomb individual helicities (index is ighel) diff --git a/madgraph/iolibs/template_files/madmatrix/color_sum_blas.inc b/madgraph/iolibs/template_files/madmatrix/color_sum_blas.inc new file mode 100644 index 0000000000..0c6083f4a0 --- /dev/null +++ b/madgraph/iolibs/template_files/madmatrix/color_sum_blas.inc @@ -0,0 +1,170 @@ +// Copyright (C) 2020-2026 CERN and UCLouvain. +// Licensed under the GNU Lesser General Public License (version 3 or later). +// Integrated with the MadGraph7 project in Feb 2026. +// +// The C++ color sum for every good helicity at once, through BLAS. This is +// only written out for processes whose color matrix is large enough that the +// call is worth setting up (see blas_wanted); everywhere else color_sum.cc is +// character for character the file written before any of this existed. + + //-------------------------------------------------------------------------- + +#ifndef MGONGPUCPP_GPUIMPL +#ifdef MGONGPU_CPP_HAS_BLAS + +#if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT + constexpr int nParityCS = 2; // mixed mode merges two neppV pages into one call +#else + constexpr int nParityCS = 1; +#endif + + // The color matrix does not depend on the helicity, so the jamps of every + // good helicity (and of every event in the SIMD page) are columns of one + // right hand side and the whole color sum becomes two SYMM calls. SYMM is + // real, so the real and the imaginary part of JAMP go through separately, + // which is the same property of M being real that the scalar sum uses to + // rewrite (A-iB)M(A+iB) as AMA + BMB (see #475). + // + // The scalar sum walks the upper triangle with its off diagonal doubled; + // SYMM wants the whole symmetric matrix with each entry counted once. What + // is written out is normalized row by row by colorDenom[icol], which is not + // symmetric when the denominators differ; only the symmetric part of a + // matrix contributes to a quadratic form, so take it explicitly. When all + // the denominators agree (the usual case) this is exactly colorMatrix/denom. + struct SymmetricNormalizedColorMatrix + { + constexpr SymmetricNormalizedColorMatrix() + : value() + { + for( int ifold = 0; ifold < ncolorfold; ifold++ ) + for( int jfold = 0; jfold < ncolorfold; jfold++ ) + value[ifold * ncolorfold + jfold] = + ( colorMatrix[ifold][jfold] / colorDenom[ifold] + colorMatrix[jfold][ifold] / colorDenom[jfold] ) / 2; + } + fptype2 value[ncolorfold * ncolorfold]; + }; + + // The Fortran BLAS interface, which every implementation exports (the + // reference BLAS ships no CBLAS of its own). Column major, as in the + // Fortran color sum. + extern "C" + { + void dsymm_( const char* side, const char* uplo, const int* m, const int* n, + const double* alpha, const double* a, const int* lda, + const double* b, const int* ldb, + const double* beta, double* c, const int* ldc ); + void ssymm_( const char* side, const char* uplo, const int* m, const int* n, + const float* alpha, const float* a, const int* lda, + const float* b, const int* ldb, + const float* beta, float* c, const int* ldc ); + } + + static inline void + blas_symm( const int m, const int n, const double* a, const double* b, double* c ) + { + const char side = 'L', uplo = 'U'; + const double alpha = 1, beta = 0; + dsymm_( &side, &uplo, &m, &n, &alpha, a, &m, b, &m, &beta, c, &m ); + } + + static inline void + blas_symm( const int m, const int n, const float* a, const float* b, float* c ) + { + const char side = 'L', uplo = 'U'; + const float alpha = 1, beta = 0; + ssymm_( &side, &uplo, &m, &n, &alpha, a, &m, b, &m, &beta, c, &m ); + } + + void + color_sum_cpu_blas( fptype* allMEs, // input/output: allMEs[nevt], add |M|^2 summed over all good helicities + fptype_sv* MEs_ighel, // output: [ncomb] running sum of |M|^2 up to ighel (first - and/or only - neppV page) + fptype_sv* MEs_ighel2, // output: [ncomb] the same for the second neppV page (mixed mode only) + const cxtype_sv* ghelAllJamp_sv, // input: jamp_sv[nGoodHel][nParity*ncolor] for all good helicities + const int nGoodHel, // input: number of good helicities + const int ievt0 ) // input: first event number in current C++ event page + { + static constexpr auto cfsym = SymmetricNormalizedColorMatrix(); + constexpr int nevtB = nParityCS * neppV; // events covered by one call + const int ncol = nGoodHel * nevtB; // number of BLAS right hand side columns + // Column major scratch: JR/JI hold the ncolorfold x ncol jamps, ZR/ZI take + // the SYMM results and MEcol one |M|^2 per column. Kept on the heap and grown + // once per thread: for ncolorfold=60 and ncomb=128 this is a few hundred kB. + static thread_local std::vector scratch; + const size_t need = 4 * (size_t)ncolorfold * ncol + ncol; + if( scratch.size() < need ) scratch.resize( need ); + fptype2* JR = scratch.data(); + fptype2* JI = JR + (size_t)ncolorfold * ncol; + fptype2* ZR = JI + (size_t)ncolorfold * ncol; + fptype2* ZI = ZR + (size_t)ncolorfold * ncol; + fptype2* MEcol = ZI + (size_t)ncolorfold * ncol; + // Transpose the jamps into the column major right hand side: colour is the + // fast index, (helicity, event) the slow one. + for( int ighel = 0; ighel < nGoodHel; ighel++ ) + { + const cxtype_sv* jamp_sv = ghelAllJamp_sv + (size_t)ighel * nParityCS * ncolor; + for( int ip = 0; ip < nParityCS; ip++ ) + for( int ieppV = 0; ieppV < neppV; ieppV++ ) + { + const size_t off = (size_t)( ighel * nevtB + ip * neppV + ieppV ) * ncolorfold; + // Gather the color flows the sum runs over: one per reversal pair when + // the basis folds, every flow otherwise (identity map). The folded + // matrix already carries what the dropped partner contributes. + for( int ifold = 0; ifold < ncolorfold; ifold++ ) + { + const int icol = colorFoldRep[ifold]; +#ifdef MGONGPU_CPPSIMD + JR[off + ifold] = cxreal( jamp_sv[ip * ncolor + icol] )[ieppV]; + JI[off + ifold] = cximag( jamp_sv[ip * ncolor + icol] )[ieppV]; +#else + JR[off + ifold] = cxreal( jamp_sv[ip * ncolor + icol] ); + JI[off + ifold] = cximag( jamp_sv[ip * ncolor + icol] ); +#endif + } + } + } + // Ztemp[ncolorfold][ncol] = ColorMatrix[ncolorfold][ncolorfold] * Jamps[ncolorfold][ncol], real and imaginary parts apart + blas_symm( ncolorfold, ncol, cfsym.value, JR, ZR ); + blas_symm( ncolorfold, ncol, cfsym.value, JI, ZI ); + // |M|^2 for one (helicity, event) is the dot product of one column of Jamps with one column of Ztemp + for( int j = 0; j < ncol; j++ ) + { + const size_t off = (size_t)j * ncolorfold; + fptype2 me = 0; + for( int ifold = 0; ifold < ncolorfold; ifold++ ) + me += JR[off + ifold] * ZR[off + ifold] + JI[off + ifold] * ZI[off + ifold]; + MEcol[j] = me; // may underflow #831 + } + // *** STORE THE RESULTS *** + // NB: MEs_ighel carries the running sum over helicities of |M|^2, which the + // event by event choice of helicity needs. The color sum is no longer added + // to allMEs one helicity at a time, so build those running sums here, + // starting from whatever allMEs already held (fix #435). + using E_ACCESS = HostAccessMatrixElements; // non-trivial access: buffer includes all events + for( int ip = 0; ip < nParityCS; ip++ ) + { + fptype_sv* running = ( ip == 0 ? MEs_ighel : MEs_ighel2 ); + fptype* MEsp = E_ACCESS::ieventAccessRecord( allMEs, ievt0 + ip * neppV ); + fptype_sv& MEsp_sv = E_ACCESS::kernelAccess( MEsp ); + for( int ieppV = 0; ieppV < neppV; ieppV++ ) + { +#ifdef MGONGPU_CPPSIMD + fptype sum = MEsp_sv[ieppV]; + for( int ighel = 0; ighel < nGoodHel; ighel++ ) + { + sum += MEcol[ighel * nevtB + ip * neppV + ieppV]; + running[ighel][ieppV] = sum; + } +#else + fptype sum = MEsp_sv; + for( int ighel = 0; ighel < nGoodHel; ighel++ ) + { + sum += MEcol[ighel * nevtB + ip * neppV + ieppV]; + running[ighel] = sum; + } +#endif + } + MEsp_sv = running[nGoodHel - 1]; + } + } +#endif +#endif diff --git a/madgraph/iolibs/template_files/madmatrix/color_sum_blas_loop.inc b/madgraph/iolibs/template_files/madmatrix/color_sum_blas_loop.inc new file mode 100644 index 0000000000..4181314c01 --- /dev/null +++ b/madgraph/iolibs/template_files/madmatrix/color_sum_blas_loop.inc @@ -0,0 +1,23 @@ +#ifdef MGONGPU_CPP_HAS_BLAS + // The color matrix does not depend on the helicity, so keep the jamps of + // every good helicity and hand them all to BLAS in a single call after + // the loop (see color_sum_cpu_blas). Everything else the loop was doing - + // the jamp2 sums for the color selection, the numerators and the + // denominators - stays inside calculate_jamps and still happens once per + // helicity, exactly as before. + static thread_local std::vector ghelJamp_sv( (size_t)ncomb * nParity * ncolor ); + for( int ighel = 0; ighel < %(sigmakin_hel_bound)s; ighel++ ) + { + const int ihel = %(sigmakin_ihel_expr)s; + cxtype_sv* jamp_sv = ghelJamp_sv.data() + (size_t)ighel * nParity * ncolor; + for( int i = 0; i < nParity * ncolor; i++ ) jamp_sv[i] = cxzero_sv(); // calculate_jamps accumulates into jamp_sv + // **NB! in "mixed" precision, using SIMD, calculate_jamps computes MEs for TWO neppV pages with a single channelId! #924 + bool storeChannelWeights = allChannelIds != nullptr || allrnddiagram != nullptr; + calculate_jamps( ihel, allmomenta, allcouplings, iflavorVec, jamp_sv, storeChannelWeights, allNumerators, allDenominators, jamp2_sv, ievt00%(calc_jamps_ihlane_arg)s ); + } +#if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT + color_sum_cpu_blas( allMEs, MEs_ighel, MEs_ighel2, ghelJamp_sv.data(), %(sigmakin_hel_bound)s, ievt00 ); +#else + color_sum_cpu_blas( allMEs, MEs_ighel, nullptr, ghelJamp_sv.data(), %(sigmakin_hel_bound)s, ievt00 ); +#endif +#else diff --git a/madgraph/iolibs/template_files/madmatrix/coloramps.h b/madgraph/iolibs/template_files/madmatrix/coloramps.h index 027f1aa44e..1a69fd6145 100644 --- a/madgraph/iolibs/template_files/madmatrix/coloramps.h +++ b/madgraph/iolibs/template_files/madmatrix/coloramps.h @@ -63,6 +63,24 @@ namespace mgOnGpu %(is_LC)s }; + // Canonical colour-flow CODE of each colour flow (the MG7 colour encoding, the + // same integer the Fortran madevent output writes into colorflow.inc and that + // subprocesses.json carries as "color_codes"). colorflowcode[icol] is the + // self-describing code of colour flow icol (0-based, the select_col_and_diag + // index minus one). A consumer that writes the event colour returns THIS code + // instead of the raw flow index, and decodes it with the flow-independent slot + // structure ("color_slots" in subprocesses.json) rather than looking the flow + // up in an ICOLUP-style table. + // + // colorflowcode_valid is false when the flows have no usable code (a colour + // sextet's two-slot leg, or an epsilon/epsilon-bar structure): the caller then + // falls back to the per-flow tag table. See the fortran side in + // export_v4._color_flow_code and the encoder in export_mg7.get_color_code_tables. + constexpr bool colorflowcode_valid = %(colorflowcode_valid)s; + __device__ constexpr int colorflowcode[%(nb_color)s] = { // note: a trailing comma in the initializer list is allowed +%(colorflowcode_lines)s + }; + } #endif // COLORAMPS_H diff --git a/madgraph/iolibs/template_files/madmatrix/cpp_hel_amps_h.inc b/madgraph/iolibs/template_files/madmatrix/cpp_hel_amps_h.inc index 8dde0fff4b..bed053192e 100644 --- a/madgraph/iolibs/template_files/madmatrix/cpp_hel_amps_h.inc +++ b/madgraph/iolibs/template_files/madmatrix/cpp_hel_amps_h.inc @@ -48,6 +48,39 @@ namespace mg5amcCpu : pvec(pvec_sv), w(reinterpret_cast(w_sv)), flv_index(flv) {} }; + // Sum two currents standing for the same off shell line: the four gluon + // current and the pair of three gluon vertices it factorises into carry the + // same colour factor, so the amplitude reading the sum gets both + // contributions from a single call. See + // HelasMatrixElement.get_quartic_current_sums. The two share their momentum, + // so only the wavefunction is added and the rest is taken from the first. + template + __device__ inline void + SUMW_1( const ALOHAOBJ& V2, const ALOHAOBJ& V3, ALOHAOBJ& V1 ) + { + const cxtype_sv* wV2 = W_ACCESS::kernelAccessConst( V2.w ); + const cxtype_sv* wV3 = W_ACCESS::kernelAccessConst( V3.w ); + cxtype_sv* wV1 = W_ACCESS::kernelAccess( V1.w ); + for( int i = 0; i < ALOHAOBJ::np4; i++ ) V1.pvec[i] = V2.pvec[i]; + for( int i = 0; i < ALOHAOBJ::nw6; i++ ) wV1[i] = wV2[i] + wV3[i]; + V1.flv_index = V2.flv_index; + return; + } + + // As SUMW_1, for the contributions which enter with a minus sign. + template + __device__ inline void + SUBW_1( const ALOHAOBJ& V2, const ALOHAOBJ& V3, ALOHAOBJ& V1 ) + { + const cxtype_sv* wV2 = W_ACCESS::kernelAccessConst( V2.w ); + const cxtype_sv* wV3 = W_ACCESS::kernelAccessConst( V3.w ); + cxtype_sv* wV1 = W_ACCESS::kernelAccess( V1.w ); + for( int i = 0; i < ALOHAOBJ::np4; i++ ) V1.pvec[i] = V2.pvec[i]; + for( int i = 0; i < ALOHAOBJ::nw6; i++ ) wV1[i] = wV2[i] - wV3[i]; + V1.flv_index = V2.flv_index; + return; + } + struct FLV_COUPLING_VIEW { const int* const partner1; diff --git a/madgraph/iolibs/template_files/madmatrix/madmatrix.mk b/madgraph/iolibs/template_files/madmatrix/madmatrix.mk index c82234690e..17b34c4c0a 100644 --- a/madgraph/iolibs/template_files/madmatrix/madmatrix.mk +++ b/madgraph/iolibs/template_files/madmatrix/madmatrix.mk @@ -643,6 +643,39 @@ GPUFLAGS += $(BLASCXXFLAGS) #------------------------------------------------------------------------------- +#=== Configure defaults and check if user-defined choices exist for CPPBLAS + +# HASBLAS above is about cuBLAS/hipBLAS, which only a GPU build can use. CPPBLAS +# is the separate question of whether the C++ color sum goes through a host BLAS: +# the color matrix does not depend on the helicity, so all the good helicities of +# one event page are the columns of one SYMM call. Whether a BLAS carrying SYMM +# could be linked was settled when this directory was written out; it is only +# taken for processes whose color matrix is large enough to be worth it, and for +# those the generated color_sum.cc carries both paths (example: "make CPPBLAS=hasNoBlas"). +ifeq ($(CPPBLAS),) + ifeq ($(GPUCC),) # CPU-only build + override CPPBLAS = %(cpp_blas_default)s + else # the GPU build does its color sum on the device + override CPPBLAS = hasNoBlas + endif +endif + +override CPPBLASCXXFLAGS= +override CPPBLASLIBFLAGS= + +ifeq ($(CPPBLAS),hasBlas) + override CPPBLASCXXFLAGS += -DMGONGPU_CPP_HAS_BLAS + override CPPBLASLIBFLAGS += %(cpp_blas_libflags)s +else ifneq ($(CPPBLAS),hasNoBlas) + $(error Unknown CPPBLAS='$(CPPBLAS)': only 'hasBlas' and 'hasNoBlas' are supported) +endif +CXXFLAGS += $(CPPBLASCXXFLAGS) + +#$(info CPPBLAS=$(CPPBLAS)) +#$(info CPPBLASLIBFLAGS=$(CPPBLASLIBFLAGS)) + +#------------------------------------------------------------------------------- + #=== Configure Position-Independent Code CXXFLAGS += -fPIC GPUFLAGS += $(XCOMPILERFLAG) -fPIC @@ -773,7 +806,7 @@ $(LIBDIR)/lib$(MADMATRIX_COMMONLIB).so: $(SRC)/*.h $(SRC)/*.cc $(BUILDDIR)/.buil # Target (and build rules): process shared library (C++ or CUDA/HIP, selected by GPUCC) ifeq ($(GPUCC),) $(LIBDIR)/lib$(MADMATRIX_LIB).so: $(LIBDIR)/lib$(MADMATRIX_COMMONLIB).so $(objects_lib) - $(CXX) -shared -o $@ $(objects_lib) $(CXXLIBFLAGSRPATH2) -L$(LIBDIR) -l$(MADMATRIX_COMMONLIB) + $(CXX) -shared -o $@ $(objects_lib) $(CXXLIBFLAGSRPATH2) -L$(LIBDIR) -l$(MADMATRIX_COMMONLIB) $(CPPBLASLIBFLAGS) else $(LIBDIR)/lib$(MADMATRIX_LIB).so: $(LIBDIR)/lib$(MADMATRIX_COMMONLIB).so $(objects_lib) $(GPUCC) --shared -o $@ $(objects_lib) $(GPULIBFLAGSRPATH2) -L$(LIBDIR) -l$(MADMATRIX_COMMONLIB) $(BLASLIBFLAGS) diff --git a/madgraph/iolibs/template_files/madmatrix/madmatrix_standalone.mk b/madgraph/iolibs/template_files/madmatrix/madmatrix_standalone.mk index 618cb9e42a..2d9c6a1525 100644 --- a/madgraph/iolibs/template_files/madmatrix/madmatrix_standalone.mk +++ b/madgraph/iolibs/template_files/madmatrix/madmatrix_standalone.mk @@ -35,7 +35,7 @@ standalone_all: all.$(TAG) check_sa.exe # code (the AOSOA->SoA transposition kernel). ifeq ($(GPUCC),) check_sa.exe: $(standalone_objects) $(LIBDIR)/lib$(MADMATRIX_LIB).so $(LIBDIR)/lib$(MADMATRIX_COMMONLIB).so - $(CXX) -o $@ $(standalone_objects) $(CXXLIBFLAGSRPATH) -L$(LIBDIR) -l$(MADMATRIX_LIB) -l$(MADMATRIX_COMMONLIB) $(BLASLIBFLAGS) + $(CXX) -o $@ $(standalone_objects) $(CXXLIBFLAGSRPATH) -L$(LIBDIR) -l$(MADMATRIX_LIB) -l$(MADMATRIX_COMMONLIB) $(BLASLIBFLAGS) $(CPPBLASLIBFLAGS) else check_sa.exe: $(standalone_objects) $(LIBDIR)/lib$(MADMATRIX_LIB).so $(LIBDIR)/lib$(MADMATRIX_COMMONLIB).so $(GPUCC) -o $@ $(standalone_objects) $(GPULIBFLAGSRPATH) -L$(LIBDIR) -l$(MADMATRIX_LIB) -l$(MADMATRIX_COMMONLIB) $(BLASLIBFLAGS) diff --git a/madgraph/iolibs/template_files/madmatrix/process_cc.inc b/madgraph/iolibs/template_files/madmatrix/process_cc.inc index 4dff7e869d..a19ad0b979 100644 --- a/madgraph/iolibs/template_files/madmatrix/process_cc.inc +++ b/madgraph/iolibs/template_files/madmatrix/process_cc.inc @@ -37,6 +37,7 @@ #include #include // for feenableexcept, fegetexcept and FE_XXX #include // for FLT_MIN +#include // for std::abort #include #include #include diff --git a/madgraph/iolibs/template_files/madmatrix/process_class.inc b/madgraph/iolibs/template_files/madmatrix/process_class.inc index 59a6d07333..cbf55bacdb 100644 --- a/madgraph/iolibs/template_files/madmatrix/process_class.inc +++ b/madgraph/iolibs/template_files/madmatrix/process_class.inc @@ -56,6 +56,10 @@ static constexpr int ncomb = %(nbhel)d; // #helicity combinations: e.g. 16 for e+ e- -> mu+ mu- (2**4 = fermion spin up/down ** npar) static constexpr int ndiagrams = %(ndiagrams)d; // #Feynman diagrams: e.g. 3 for e+ e- -> mu+ mu- static constexpr int ncolor = %(ncolor)s; // the number of leading colors: e.g. 1 for e+ e- -> mu+ mu- + // The color structures the color flow is picked among. Same as ncolor, + // unless the color sum runs on a smaller basis (see set_color_flow_lines_cpp). + static constexpr int ncolor_flow = %(ncolor_flow)s; + static constexpr int ncolorfold = %(ncolorfold)s; // the number of color flows |M|^2 is summed over: one per reversal pair where the color basis folds, ncolor otherwise (see color_sum.cc) static constexpr int nmaxflavor = %(nmaxflavor)d; // the maximum number of flavor combinations // Hardcoded parameters for this process (constant class variables) diff --git a/madgraph/iolibs/template_files/madmatrix/process_function_definitions.inc b/madgraph/iolibs/template_files/madmatrix/process_function_definitions.inc index 082c373aa2..08bf730f11 100644 --- a/madgraph/iolibs/template_files/madmatrix/process_function_definitions.inc +++ b/madgraph/iolibs/template_files/madmatrix/process_function_definitions.inc @@ -83,6 +83,7 @@ namespace mg5amcCpu constexpr int npar = CPPProcess::npar; // #particles in total (external = initial + final): e.g. 4 for e+ e- -> mu+ mu- constexpr int ncomb = CPPProcess::ncomb; // #helicity combinations: e.g. 16 for e+ e- -> mu+ mu- (2**4 = fermion spin up/down ** npar) constexpr int ncolor = CPPProcess::ncolor; // the number of leading colors + constexpr int ncolor_flow = CPPProcess::ncolor_flow; // the color structures the color flow is picked among constexpr int nmaxflavor = CPPProcess::nmaxflavor; // the maximum number of flavor combinations // [NB: I am currently unable to get the right value of nwf in CPPProcess.h - will hardcode it in CPPProcess.cc instead (#644)] @@ -176,11 +177,12 @@ namespace mg5amcCpu #endif static int cNGoodHel; static int cGoodHel[ncomb]; +%(goodhel_percross_statics)s%(csym_statics)s // Host-side flavor table: single source of truth for PDG ids (used by both the // constructor copy into cFlavors and the public CPPProcess::flavorPDG accessor). %(all_flavors)s - +%(crossing_decl)s //-------------------------------------------------------------------------- #ifdef MGONGPUCPP_GPUIMPL @@ -265,7 +267,7 @@ namespace mg5amcCpu int CPPProcess::flavorPDG( int iflavor, int ipar ) { - return flavorPDGs[iflavor][ipar]; +%(flavorpdg_body)s } //-------------------------------------------------------------------------- @@ -549,9 +551,9 @@ namespace mg5amcCpu for( int ihel = 0; ihel < ncomb; ihel++ ) isGoodHel[ihel] = false; (void)iflavorVec; // flavor is forced below to scan every flavor combination unsigned int hgFlavorVec[maxtry0] = {}; // forced single-flavor index buffer - for( int iflav = 0; iflav < nmaxflavor; ++iflav ) +%(csym_gh_flip)s%(goodhel_percross_decl)s for( int iflav = 0; iflav < %(goodhel_scan_count)s; ++iflav ) { - for( int i = 0; i < maxtry0; ++i ) hgFlavorVec[i] = (unsigned int)iflav; + %(goodhel_scan_skip)sfor( int i = 0; i < maxtry0; ++i ) hgFlavorVec[i] = (unsigned int)iflav; for( int ipagV2 = 0; ipagV2 < npagV2; ++ipagV2 ) { #if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT /* clang-format off */ @@ -581,7 +583,7 @@ namespace mg5amcCpu #endif calculate_jamps( ihel, allmomenta, allcouplings, hgFlavorVec, jamp_sv, false, allNumerators, allDenominators, jamp2_sv, ievt00 ); //maxtry? color_sum_cpu( allMEs, jamp_sv, ievt00 ); - for( int ieppV = 0; ieppV < neppV; ++ieppV ) +%(csym_gh_record)s for( int ieppV = 0; ieppV < neppV; ++ieppV ) { const int ievt = ievt00 + ieppV; //std::cout << "sigmaKin_getGoodHel allMEs[ievt]=" << allMEs[ievt] << std::endl; @@ -589,20 +591,20 @@ namespace mg5amcCpu { //if ( !isGoodHel[ihel] ) std::cout << "sigmaKin_getGoodHel ihel=" << ihel << " TRUE" << std::endl; isGoodHel[ihel] = true; - } +%(goodhel_percross_record)s } #if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT const int ievt2 = ievt00 + ieppV + neppV; if( allMEs[ievt2] != 0 ) // NEW IMPLEMENTATION OF GETGOODHEL (#630): COMPARE EACH HELICITY CONTRIBUTION TO 0 { //if ( !isGoodHel[ihel] ) std::cout << "sigmaKin_getGoodHel ihel=" << ihel << " TRUE" << std::endl; isGoodHel[ihel] = true; - } +%(goodhel_percross_record)s } #endif } } - } +%(csym_gh_check)s } } // end loop over flavor combinations (per-flavor good-helicity union) - } +%(goodhel_percross_build)s } #endif //-------------------------------------------------------------------------- @@ -627,7 +629,7 @@ namespace mg5amcCpu #endif cNGoodHel = nGoodHel; for( int ihel = 0; ihel < ncomb; ihel++ ) cGoodHel[ihel] = goodHel[ihel]; - return nGoodHel; +%(csym_pairbuild)s return nGoodHel; } //-------------------------------------------------------------------------- @@ -770,7 +772,7 @@ namespace mg5amcCpu const fptype* allrndcol, // input: random numbers[nevt] for color selection const fptype* allrnddiagram, // input: random numbers[nevt] for diagram selection const unsigned int* allChannelIds, // input: multichannel channelIds[nevt] (1 to #diagrams); nullptr to disable SDE enhancement (fix #899/#911) - const fptype_sv* allJamp2s, // input: jamp2[ncolor][nevt] for color choice (nullptr if disabled) + const fptype_sv* allJamp2s, // input: jamp2[ncolor_flow][nevt] for color choice (nullptr if disabled) const fptype* allNumerators, // input: all numerators const fptype* allDenominators, // input: all denominators const int nevt ) // input: #events (for cuda: nevt == ndim == gpublocks*gputhreads) @@ -811,10 +813,10 @@ namespace mg5amcCpu assert( channelId <= mgOnGpu::nchannels ); // SANITY CHECK #919 #910 } // Determine the jamp2 for this event (TEMPORARY? could do this with a dedicated memory accessor instead...) - fptype_sv jamp2_sv[ncolor] = { 0 }; + fptype_sv jamp2_sv[ncolor_flow] = { 0 }; assert( allJamp2s != nullptr ); // sanity check using J2_ACCESS = DeviceAccessJamp2; - for( int icolC = 0; icolC < ncolor; icolC++ ) + for( int icolC = 0; icolC < ncolor_flow; icolC++ ) jamp2_sv[icolC] = J2_ACCESS::kernelAccessIcolConst( allJamp2s, icolC ); // NB (see #877): in the array channel2iconfig, the input index uses C indexing (channelId -1), the output index uses F indexing (iconfig) // NB (see #917): mgOnGpu::channel2iconfig returns an int (which may be -1), not an unsigned int! @@ -829,9 +831,9 @@ namespace mg5amcCpu printf( "INTERNAL ERROR! Cannot choose an event-by-event random color for channelId=%%d (invalid SDE iconfig=%%d\n > nconfig=%%d)", channelId, iconfig, mgOnGpu::nconfigSDE ); assert( iconfig <= (int)mgOnGpu::nconfigSDE ); // SANITY CHECK #917 } - fptype targetamp[ncolor] = { 0 }; + fptype targetamp[ncolor_flow] = { 0 }; // NB (see #877): explicitly use 'icolC' rather than 'icol' to indicate that icolC uses C indexing in [0, N_colors-1] - for( int icolC = 0; icolC < ncolor; icolC++ ) + for( int icolC = 0; icolC < ncolor_flow; icolC++ ) { if( icolC == 0 ) targetamp[icolC] = 0; @@ -841,9 +843,9 @@ namespace mg5amcCpu if( mgOnGpu::icolamp[iconfig - 1][icolC] ) targetamp[icolC] += jamp2_sv[icolC]; } //printf( "sigmaKin: ievt=%%4d rndcol=%%f\n", ievt, allrndcol[ievt] ); - for( int icolC = 0; icolC < ncolor; icolC++ ) + for( int icolC = 0; icolC < ncolor_flow; icolC++ ) { - if( allrndcol[ievt] < ( targetamp[icolC] / targetamp[ncolor - 1] ) ) + if( allrndcol[ievt] < ( targetamp[icolC] / targetamp[ncolor_flow - 1] ) ) { allselcol[ievt] = icolC + 1; // NB Fortran [1,ncolor], cudacpp [0,ncolor-1] //printf( "sigmaKin: ievt=%%d icol=%%d\n", ievt, icolC+1 ); diff --git a/madgraph/iolibs/template_files/madmatrix/process_matrix.inc b/madgraph/iolibs/template_files/madmatrix/process_matrix.inc index dff402fa3c..438365dfb2 100644 --- a/madgraph/iolibs/template_files/madmatrix/process_matrix.inc +++ b/madgraph/iolibs/template_files/madmatrix/process_matrix.inc @@ -8,23 +8,24 @@ ! Integrated with the MadGraph7 project in Feb 2026. !========================================================================== +%(jampflow_lines)s // *** COLOR CHOICE BELOW *** // Store the leading color flows for choice of color #ifndef MGONGPUCPP_GPUIMPL if( jamp2_sv ) // disable color choice if nullptr { - for( int icol = 0; icol < ncolor; icol++ ) - jamp2_sv[ncolor * iParity + icol] += cxabs2( jamp_sv[icol] ); // may underflow #831 + for( int icol = 0; icol < ncolor_flow; icol++ ) + jamp2_sv[ncolor_flow * iParity + icol] += cxabs2( %(jamp_flow)s[icol] ); // may underflow #831 } #else /* clang-format off */ assert( iParity == 0 ); // sanity check for J2_ACCESS using J2_ACCESS = DeviceAccessJamp2; if( colAllJamp2s ) // disable color choice if nullptr { - for( int icol = 0; icol < ncolor; icol++ ) + for( int icol = 0; icol < ncolor_flow; icol++ ) // NB: atomicAdd is needed after moving to cuda streams with one helicity per stream! - atomicAdd( &J2_ACCESS::kernelAccessIcol( colAllJamp2s, icol ), cxabs2( jamp_sv[icol] ) ); + atomicAdd( &J2_ACCESS::kernelAccessIcol( colAllJamp2s, icol ), cxabs2( %(jamp_flow)s[icol] ) ); } #endif /* clang-format on */ diff --git a/madgraph/iolibs/template_files/madmatrix/process_sigmaKin_function.inc b/madgraph/iolibs/template_files/madmatrix/process_sigmaKin_function.inc index 227301a6ea..bee2d504e6 100644 --- a/madgraph/iolibs/template_files/madmatrix/process_sigmaKin_function.inc +++ b/madgraph/iolibs/template_files/madmatrix/process_sigmaKin_function.inc @@ -16,7 +16,7 @@ const int nevt = gpublocks * gputhreads; gpuMemset( allMEs, 0, nevt * sizeof( fptype ) ); gpuMemset( ghelAllJamps, 0, cNGoodHel * ncolor * mgOnGpu::nx2 * nevt * sizeof( fptype ) ); - gpuMemset( colAllJamp2s, 0, ncolor * nevt * sizeof( fptype ) ); + gpuMemset( colAllJamp2s, 0, ncolor_flow * nevt * sizeof( fptype ) ); gpuMemset( ghelAllNumerators, 0, cNGoodHel * processConfig::ndiagrams * nevt * sizeof( fptype ) ); gpuMemset( ghelAllDenominators, 0, cNGoodHel * nevt * sizeof( fptype ) ); gpuMemset( ghelAllMEs, 0, cNGoodHel * nevt * sizeof( fptype ) ); @@ -96,8 +96,8 @@ // - shared: as the name says // - private: give each thread its own copy, without initialising // - firstprivate: give each thread its own copy, and initialise with value from outside -#define _OMPLIST0 allcouplings, allMEs, allmomenta, allrndcol, allrndhel, allselcol, allselhel, cGoodHel, cNGoodHel, npagV2 -#define _OMPLIST1 , allDenominators, allNumerators, allChannelIds, mgOnGpu::icolamp, mgOnGpu::channel2iconfig +#define _OMPLIST0 allcouplings, allMEs, allmomenta, allrndcol, allrndhel, allselcol, allselhel, cGoodHel, cNGoodHel, npagV2%(extra_omp_shared)s +#define _OMPLIST1 , allDenominators, allNumerators, allChannelIds, allDiagramIdsOut, allrnddiagram, iflavorVec, mgOnGpu::icolamp, mgOnGpu::channel2iconfig #pragma omp parallel for default( none ) shared( _OMPLIST0 _OMPLIST1 ) #undef _OMPLIST0 #undef _OMPLIST1 @@ -110,42 +110,42 @@ const int ievt00 = ipagV2 * neppV; // loop on one SIMD page (neppV events) at a time #endif // Running sum of partial amplitudes squared for event by event color selection (#402) - // (jamp2[nParity][ncolor][neppV] for the SIMD vector - or the two SIMD vectors - of events processed in calculate_jamps) - fptype_sv jamp2_sv[nParity * ncolor] = {}; + // (jamp2[nParity][ncolor_flow][neppV] for the SIMD vector - or the two SIMD vectors - of events processed in calculate_jamps) + fptype_sv jamp2_sv[nParity * ncolor_flow] = {}; fptype_sv MEs_ighel[ncomb] = {}; // sum of MEs for all good helicities up to ighel (for the first - and/or only - neppV page) #if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT fptype_sv MEs_ighel2[ncomb] = {}; // sum of MEs for all good helicities up to ighel (for the second neppV page) #endif - for( int ighel = 0; ighel < cNGoodHel; ighel++ ) +%(cpp_blas_helicity_loop)s%(csym_page_decl)s for( int ighel = 0; ighel < %(sigmakin_hel_bound)s; ighel++ ) { - const int ihel = cGoodHel[ighel]; - cxtype_sv jamp_sv[nParity * ncolor] = {}; // fixed nasty bug (omitting 'nParity' caused memory corruptions after calling calculate_jamps) +%(sigmakin_perlane_decl)s const int ihel = %(sigmakin_ihel_expr)s; +%(csym_me_before)s cxtype_sv jamp_sv[nParity * ncolor] = {}; // fixed nasty bug (omitting 'nParity' caused memory corruptions after calling calculate_jamps) // **NB! in "mixed" precision, using SIMD, calculate_jamps computes MEs for TWO neppV pages with a single channelId! #924 bool storeChannelWeights = allChannelIds != nullptr || allrnddiagram != nullptr; - calculate_jamps( ihel, allmomenta, allcouplings, iflavorVec, jamp_sv, storeChannelWeights, allNumerators, allDenominators, jamp2_sv, ievt00 ); + calculate_jamps( ihel, allmomenta, allcouplings, iflavorVec, jamp_sv, storeChannelWeights, allNumerators, allDenominators, jamp2_sv, ievt00%(calc_jamps_ihlane_arg)s ); color_sum_cpu( allMEs, jamp_sv, ievt00 ); MEs_ighel[ighel] = E_ACCESS::kernelAccess( E_ACCESS::ieventAccessRecord( allMEs, ievt00 ) ); #if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT MEs_ighel2[ighel] = E_ACCESS::kernelAccess( E_ACCESS::ieventAccessRecord( allMEs, ievt00 + neppV ) ); #endif - } +%(csym_weight)s }%(cpp_blas_helicity_loop_end)s // Event-by-event random choice of helicity #403 for( int ieppV = 0; ieppV < neppV; ++ieppV ) { const int ievt = ievt00 + ieppV; //printf( "sigmaKin: ievt=%%4d rndhel=%%f\n", ievt, allrndhel[ievt] ); - for( int ighel = 0; ighel < cNGoodHel; ighel++ ) + for( int ighel = 0; ighel < %(sigmakin_hel_bound)s; ighel++ ) { #if defined MGONGPU_CPPSIMD //printf( "sigmaKin: ievt=%%4d ighel=%%d MEs_ighel=%%f\n", ievt, ighel, MEs_ighel[ighel][ieppV] ); - const bool okhel = allrndhel[ievt] < ( MEs_ighel[ighel][ieppV] / MEs_ighel[cNGoodHel - 1][ieppV] ); + const bool okhel = allrndhel[ievt] < ( MEs_ighel[ighel][ieppV] / MEs_ighel[%(sigmakin_hel_bound)s - 1][ieppV] ); #else //printf( "sigmaKin: ievt=%%4d ighel=%%d MEs_ighel=%%f\n", ievt, ighel, MEs_ighel[ighel] ); - const bool okhel = allrndhel[ievt] < ( MEs_ighel[ighel] / MEs_ighel[cNGoodHel - 1] ); + const bool okhel = allrndhel[ievt] < ( MEs_ighel[ighel] / MEs_ighel[%(sigmakin_hel_bound)s - 1] ); #endif if( okhel ) { - const int ihelF = cGoodHel[ighel] + 1; // NB Fortran [1,ncomb], cudacpp [0,ncomb-1] +%(csym_sel_1)s const int ihelF = %(selected_hel_code_1)s; // NB Fortran [1,ncomb], cudacpp [0,ncomb-1] allselhel[ievt] = ihelF; //printf( "sigmaKin: ievt=%%4d ihel=%%4d\n", ievt, ihelF ); break; @@ -154,12 +154,12 @@ #if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT const int ievt2 = ievt00 + ieppV + neppV; //printf( "sigmaKin: ievt=%%4d rndhel=%%f\n", ievt2, allrndhel[ievt2] ); - for( int ighel = 0; ighel < cNGoodHel; ighel++ ) + for( int ighel = 0; ighel < %(sigmakin_hel_bound)s; ighel++ ) { //printf( "sigmaKin: ievt=%%4d ighel=%%d MEs_ighel=%%f\n", ievt2, ighel, MEs_ighel2[ighel][ieppV] ); - if( allrndhel[ievt2] < ( MEs_ighel2[ighel][ieppV] / MEs_ighel2[cNGoodHel - 1][ieppV] ) ) + if( allrndhel[ievt2] < ( MEs_ighel2[ighel][ieppV] / MEs_ighel2[%(sigmakin_hel_bound)s - 1][ieppV] ) ) { - const int ihelF = cGoodHel[ighel] + 1; // NB Fortran [1,ncomb], cudacpp [0,ncomb-1] +%(csym_sel_2)s const int ihelF = %(selected_hel_code_2)s; // NB Fortran [1,ncomb], cudacpp [0,ncomb-1] allselhel[ievt2] = ihelF; //printf( "sigmaKin: ievt=%%4d ihel=%%4d\n", ievt2, ihelF ); break; @@ -235,9 +235,9 @@ printf( "INTERNAL ERROR! Cannot choose an event-by-event random color for channelId=%%d (invalid SDE iconfig=%%d\n > nconfig=%%d)", channelId, iconfig, mgOnGpu::nconfigSDE ); assert( iconfig <= (int)mgOnGpu::nconfigSDE ); // SANITY CHECK #917 } - fptype targetamp[ncolor] = { 0 }; + fptype targetamp[ncolor_flow] = { 0 }; // NB (see #877): explicitly use 'icolC' rather than 'icol' to indicate that icolC uses C indexing in [0, N_colors-1] - for( int icolC = 0; icolC < ncolor; icolC++ ) + for( int icolC = 0; icolC < ncolor_flow; icolC++ ) { if( icolC == 0 ) targetamp[icolC] = 0; @@ -245,17 +245,17 @@ targetamp[icolC] = targetamp[icolC - 1]; #ifdef MGONGPU_CPPSIMD if( mgOnGpu::icolamp[iconfig - 1][icolC] ) targetamp[icolC] += - jamp2_sv[icolC + ncolor * ( ieppV / neppV )][ieppV %% neppV]; + jamp2_sv[icolC + ncolor_flow * ( ieppV / neppV )][ieppV %% neppV]; #else if( mgOnGpu::icolamp[iconfig - 1][icolC] ) targetamp[icolC] += - jamp2_sv[icolC + ncolor * ( ieppV / neppV )]; + jamp2_sv[icolC + ncolor_flow * ( ieppV / neppV )]; #endif } const int ievt = ievt00 + ieppV; //printf( "sigmaKin: ievt=%%4d rndcol=%%f\n", ievt, allrndcol[ievt] ); - for( int icolC = 0; icolC < ncolor; icolC++ ) + for( int icolC = 0; icolC < ncolor_flow; icolC++ ) { - if( allrndcol[ievt] < ( targetamp[icolC] / targetamp[ncolor - 1] ) ) + if( allrndcol[ievt] < ( targetamp[icolC] / targetamp[ncolor_flow - 1] ) ) { allselcol[ievt] = icolC + 1; // NB Fortran [1,ncolor], cudacpp [0,ncolor-1] //printf( "sigmaKin: ievt=%%d icol=%%d\n", ievt, icolC+1 ); @@ -291,7 +291,7 @@ const int ievt0 = ipagV * neppV; fptype* MEs = E_ACCESS::ieventAccessRecord( allMEs, ievt0 ); fptype_sv& MEs_sv = E_ACCESS::kernelAccess( MEs ); - MEs_sv = MEs_sv * broken_symmetry_factor(iflavorVec[ievt0]) / helcolDenominators[0]; +%(sigmakin_denominator)s if( mulChannelWeight && allChannelIds != nullptr ) // fix segfault #892 (not 'channelIds[0] != 0') { const unsigned int channelId = getChannelId( allChannelIds, ievt0, false ); diff --git a/madgraph/iolibs/template_files/madmatrix/umami.cc b/madgraph/iolibs/template_files/madmatrix/umami.cc index d19c93bb9b..571a323a3e 100644 --- a/madgraph/iolibs/template_files/madmatrix/umami.cc +++ b/madgraph/iolibs/template_files/madmatrix/umami.cc @@ -486,31 +486,46 @@ extern "C" std::vector permutation; std::size_t rounded_count; + // The SIMD grouping key is the REDUCED flavor (id % nmaxflavor), not the + // full extended flavorID. The extended id encodes both a reduced flavor and + // a crossing (id = cross*nmaxflavor + flavor). CPPProcess only requires the + // reduced flavor to be constant across a SIMD vector (the wavefunction + // flavor is read once per vector); the crossing is applied per event by the + // momentum gather, so one vector may legitimately mix crossings. Indexing + // the grouping arrays by the full id (as an earlier version did) overflowed + // them whenever a crossing was present (id >= nmaxflavor), corrupting the + // stack and crashing (SIGABRT/SIGSEGV). constexpr std::size_t flavor_count = CPPProcess::nmaxflavor; HostBufferBase flavor_indices( ((count + page_size2 - 1) / page_size2 + flavor_count) * page_size2 ); bool sort_flavors = vector_size > 1 && flavor_count > 1 && flavor_indices_in; - if ( sort_flavors ) + if ( sort_flavors ) { permutation.resize(count); std::size_t voffset = 0; std::size_t vector_indices[flavor_count] = {}; std::size_t vector_counts[flavor_count] = {}; // determine permutation of inputs such that all entries in a SIMD vector - // have the same flavor index + // share the same reduced flavor (they may still carry different crossings) for( std::size_t i_event = 0; i_event < count; ++i_event ) { unsigned int flav = flavor_indices_in[i_event + offset]; - auto& vcount = vector_counts[flav]; - auto& vindex = vector_indices[flav]; + unsigned int rflav = flav % (unsigned int)CPPProcess::nmaxflavor; + auto& vcount = vector_counts[rflav]; + auto& vindex = vector_indices[rflav]; if ( vcount == 0 ) { vindex = voffset * page_size2; + // Pre-fill the whole page with a valid padding id (crossing 0 of this + // reduced flavor) so that unused tail lanes never index the crossing + // tables out of range; real events overwrite their own slot below. for ( std::size_t i = 0; i < page_size2; ++i) { - flavor_indices[voffset * page_size2 + i] = flav; + flavor_indices[voffset * page_size2 + i] = rflav; } voffset += 1; } - permutation[i_event] = vindex + vcount; + const std::size_t slot = vindex + vcount; + permutation[i_event] = slot; + flavor_indices[slot] = flav; // per-event full extended id (flavor + crossing) vcount = (vcount + 1) % page_size2; } rounded_count = voffset * page_size2; diff --git a/madgraph/iolibs/template_files/makefile_sa_f_sp b/madgraph/iolibs/template_files/makefile_sa_f_sp index ad47e08a27..ab2cbeafbd 100644 --- a/madgraph/iolibs/template_files/makefile_sa_f_sp +++ b/madgraph/iolibs/template_files/makefile_sa_f_sp @@ -11,10 +11,20 @@ PROG = check # Keep this distinct from PDIR (basename) which is used for dylib naming below. PDIR_FULL:=$(shell dirname $(realpath --no-symlinks $(firstword matrix.f))) PROG_SPLITORDERS = check_sa_born_splitOrders -LINKLIBS = -L$(LIBDIR) -ldhelas -lmodel +BLASLIBS = +# The plain -L/-l part, without $(BLASLIBS). f2py needs it separately: see the +# matrix$(MENUM)py.so rule below. +LINKLIBS_NOBLAS = -L$(LIBDIR) -ldhelas -lmodel +LINKLIBS = $(LINKLIBS_NOBLAS) $(BLASLIBS) LIBS = $(LIBDIR)/libdhelas.$(libext) $(LIBDIR)/libmodel.$(libext) LIBS_SHARED = $(LIBDIR)/libdhelas.$(dylibext) $(LIBDIR)/libmodel.$(dylibext) -PROCESS= matrix.o +# matrix_getamp.f only exists with --hel_recycling, and only when the +# recycled helas block was large enough to be split out of MATRIX. There is one +# per core by default (--hel_recycling_files) precisely so that `make -j` can +# build them at once -- they are independent translation units, and the whole +# point of not leaving them in a single file is that one file is one core and +# one heap. +PROCESS= matrix.o $(patsubst %.f,%.o,$(wildcard matrix_getamp*.f)) CHECK_SA= check_sa.o CHECK_SA_SPLITORDERS= check_sa_born_splitOrders.o @@ -25,7 +35,21 @@ $(PROG): $(LIBS) $(PROCESS) $(CHECK_SA) makefile $(FC) $(FFLAGS) -o $(PROG) $(PROCESS) $(CHECK_SA) $(LINKLIBS) $(PROG_SPLITORDERS): $(PROCESS) $(CHECK_SA_SPLITORDERS) makefile $(LIBS) - $(FC) $(FFLAGS) -o $(PROG) $(PROCESS) $(CHECK_SA_SPLITORDERS) $(LINKLIBS) + $(FC) $(FFLAGS) -o $(PROG_SPLITORDERS) $(PROCESS) $(CHECK_SA_SPLITORDERS) $(LINKLIBS) + +# The recycled amplitude block gets its own flag so that the compile can be +# bought back when it is what has to give. It follows the global flag by +# default (AMP_FLAG is empty in make_opts) rather than being pinned at -O0: +# pinning looked free only because the standalone default carries no -O at all, +# and it is not free once the global flag is raised. g g > 5g, steady state per +# phase space point at GLOBAL_FLAG=-O2: 19.0 ms with this file at -O0 against +# 15.0 ms with it at -O2, i.e. the pin costs 27%. Unlike the un-recycled +# amplitudes, this block is not a flat sequence of external CALLs -- split_amps +# has replaced it with P1N_* plus CombineAmp array constructors, which the +# optimizer does have something to do on. Same conclusion, and same default, as +# AMP_FLAG on the madevent side. +matrix_getamp%.o: matrix_getamp%.f + $(FC) $(FFLAGS) $(AMP_FLAG) -c -o $@ $< driver.f: nexternal.inc pmass.inc ngraphs.inc coupl.inc @@ -43,12 +67,20 @@ ifeq ($(origin MENUM),undefined) MENUM=2 endif -libme$(PDIR).$(dylibext): $(LIBDIR)/libdhelas.$(dylibext) $(LIBDIR)/libmodel.$(dylibext) matrix.o - gfortran $(DYNLIBFLAG) $(RPATHFLAG)libme$(PDIR).$(dylibext) -o libme$(PDIR).$(dylibext) matrix.o ../../Source/DHELAS/*.o ../../Source/MODEL/*.o +libme$(PDIR).$(dylibext): $(LIBDIR)/libdhelas.$(dylibext) $(LIBDIR)/libmodel.$(dylibext) $(PROCESS) + gfortran $(DYNLIBFLAG) $(RPATHFLAG)libme$(PDIR).$(dylibext) -o libme$(PDIR).$(dylibext) $(PROCESS) ../../Source/DHELAS/*.o ../../Source/MODEL/*.o matrix$(MENUM)py.so: f2py_matrix_wrapper.f libme$(PDIR).$(dylibext) makefile touch __init__.py - LDFLAGS="-Wl,-rpath,$(HERE)" $(F2PY) -c f2py_matrix_wrapper.f -L$(HERE) -lme$(PDIR) $(LINKLIBS) -m matrix$(MENUM)py +# $(BLASLIBS) goes through LDFLAGS, NOT through the f2py argument list. On +# python>=3.12 f2py must use the meson backend, which does not understand +# '-framework Accelerate': it prints "Unknown option '-framework'", builds no +# module at all, and STILL EXITS 0 -- so the touch below would leave an empty +# matrix$(MENUM)py.so behind and the import would fail with a bare dlopen +# error. meson honours LDFLAGS, so the flag reaches the linker from there. + LDFLAGS="-Wl,-rpath,$(HERE) $(BLASLIBS)" $(F2PY) -c f2py_matrix_wrapper.f -L$(HERE) -lme$(PDIR) $(LINKLIBS_NOBLAS) -m matrix$(MENUM)py +# f2py names the module matrix$(MENUM)py.cpython--.so, which import +# picks up ahead of the bare .so; this only gives make its timestamp. touch matrix$(MENUM)py.so cp $(LIBDIR)/*$(dylibext) . diff --git a/madgraph/iolibs/template_files/matrix_goodhel_helper.inc b/madgraph/iolibs/template_files/matrix_goodhel_helper.inc index e49f496875..b519f7c179 100644 --- a/madgraph/iolibs/template_files/matrix_goodhel_helper.inc +++ b/madgraph/iolibs/template_files/matrix_goodhel_helper.inc @@ -7,7 +7,26 @@ LOGICAL GOODHEL(NCOMB, MAXFLAVPERPROC) INTEGER NTRY(MAXFLAVPERPROC) common/BLOCK_GOODHEL/NTRY,GOODHEL +C Persist the C-parity de-duplication verdict next to the good +C helicities. Reading the file skips the scan (NTRY is forced past +C MAXTRIES below), so a run that never completed its scan must hand on +C "broken" rather than the optimistic default -- otherwise the next job +C would de-duplicate on a pairing nothing ever verified. + INTEGER NCSYMTOT + PARAMETER (NCSYMTOT=MAXFLAVPERPROC*MAXSPROC) + INTEGER CSYMBAD(NCSYMTOT), NCSCAN(NCSYMTOT) + common/BLOCK_CSYM/CSYMBAD,NCSCAN + INTEGER CSYMOUT(NCSYMTOT) + INTEGER ICS write(stream_id,*) GOODHEL + do ICS=1,NCSYMTOT + if (CSYMBAD(ICS).eq.0.and.NCSCAN(ICS).ge.20) then + CSYMOUT(ICS) = 0 + else + CSYMOUT(ICS) = 1 + endif + enddo + write(stream_id,*) CSYMOUT return end @@ -22,8 +41,22 @@ LOGICAL GOODHEL(NCOMB, MAXFLAVPERPROC) INTEGER NTRY(MAXFLAVPERPROC) common/BLOCK_GOODHEL/NTRY,GOODHEL + INTEGER NCSYMTOT + PARAMETER (NCSYMTOT=MAXFLAVPERPROC*MAXSPROC) + INTEGER CSYMBAD(NCSYMTOT), NCSCAN(NCSYMTOT) + common/BLOCK_CSYM/CSYMBAD,NCSCAN + INTEGER IOCS read(stream_id,*) GOODHEL NTRY(:) = MAXTRIES + 1 +C Inherit the verdict. The good-helicity filter now gates the full-sum +C loop, so the scan can no longer be redone in this job (the rows it +C would compare are not all evaluated any more): treat a missing or +C unfinished verdict as "do not de-duplicate". + read(stream_id,*,iostat=IOCS) CSYMBAD + if (IOCS.ne.0) then + CSYMBAD(:) = 1 + endif + NCSCAN(:) = 20 return end diff --git a/madgraph/iolibs/template_files/matrix_madevent_ampchunk_v4.inc b/madgraph/iolibs/template_files/matrix_madevent_ampchunk_v4.inc new file mode 100644 index 0000000000..71abda7358 --- /dev/null +++ b/madgraph/iolibs/template_files/matrix_madevent_ampchunk_v4.inc @@ -0,0 +1,74 @@ + SUBROUTINE ORIGAMP%(proc_id)s_%(chunk_id)s(P,NHEL,IC,IVEC,FLAVOR,W,AMP%(amp_chunk_mask_arg)s) +C +%(process_lines)s +C +C One slice of the HELAS call sequence of MATRIX%(proc_id)s, in a file +C of its own: as one basic block inside one routine the sequence is +C what makes a high-multiplicity matrix element uncompilable, and +C split up it can also carry its own optimisation flag (AMP_FLAG). +C The slices run in order and share W and AMP by reference: a +C wavefunction slot is reused many times over the sequence, so a +C slot number does not identify a wavefunction and the whole +C array has to be threaded through. +C + use aloha_object + use model_object + IMPLICIT NONE +C +C CONSTANTS +C + INTEGER NGRAPHS + PARAMETER (NGRAPHS=%(ngraphs)d) + INTEGER NWAVEFUNCS + PARAMETER (NWAVEFUNCS=%(nwavefuncs)d) + REAL*8 ZERO + PARAMETER (ZERO=0D0) + COMPLEX*16 IMAG1 + PARAMETER (IMAG1=(0D0,1D0)) + include 'nexternal.inc' +C +C ARGUMENTS +C + REAL*8 P(0:3,NEXTERNAL) + INTEGER NHEL(NEXTERNAL) + INTEGER IC(NEXTERNAL) + INTEGER IVEC + INTEGER FLAVOR(NEXTERNAL) + type(aloha) W(NWAVEFUNCS) + COMPLEX*16 AMP(NGRAPHS) +%(amp_chunk_mask_decl)s +C +C LOCAL VARIABLES +C +C Needed for v4 models + COMPLEX*16 DUM0,DUM1 + DATA DUM0, DUM1/(0d0, 0d0), (1d0, 0d0)/ +C The fake widths are recomputed here rather than read out of the +C matrix element, which keeps them SAVEd locals: sharing them would +C mean turning those into a common block, i.e. editing every matrix +C element whether it is chunked or not. A handful of operations, +C done once. + %(fake_width_declaration)s + logical first + data first /.true./ + save first +C +C GLOBAL VARIABLES +C + include '../../Source/vector.inc' ! defines VECSIZE_MEMMAX + include 'coupl.inc' ! needs VECSIZE_MEMMAX (defined in vector.inc) + double precision bwcutoff + common/to_bwcutoff/ bwcutoff + double precision small_width_treatment + common/narrow_width/small_width_treatment +C ---------- +C BEGIN CODE +C ---------- + if (first) then + first=.false. + %(fake_width_definitions)s + endif +C HELAS CALLS BEGIN +%(helas_calls)s +C HELAS CALLS END + END diff --git a/madgraph/iolibs/template_files/matrix_madevent_ampchunk_v4_hel.inc b/madgraph/iolibs/template_files/matrix_madevent_ampchunk_v4_hel.inc new file mode 100644 index 0000000000..021c9d9dfa --- /dev/null +++ b/madgraph/iolibs/template_files/matrix_madevent_ampchunk_v4_hel.inc @@ -0,0 +1,78 @@ + SUBROUTINE OPTAMP%(proc_id)s_${chunk_id}(P,%(hel_matrix_ic_param)sIVEC,FLAVOR,W,AMP,TMP%(amp_chunk_mask_arg)s) +C +%(process_lines)s +C +C One slice of the helicity-recycled HELAS call sequence of +C MATRIX%(proc_id)s, in a file of its own. That unrolled sequence is +C essentially the whole recycled matrix element, and as one basic +C block inside one routine it is what makes it uncompilable; split up +C it can also carry its own optimisation flag (AMP_FLAG). +C The slices run in order and share W, TMP and AMP by reference: +C hel_recycle reuses a wavefunction slot many times over the +C sequence (so a slot number does not identify a wavefunction), and +C TMP carries the P1N result of a split amplitude into its +C CombineAmp partner, which a slice boundary may fall between. +C + use aloha_object + use model_object + IMPLICIT NONE +C +C CONSTANTS +C + INTEGER NGRAPHS + PARAMETER (NGRAPHS=%(ngraphs)d) + INTEGER NWAVEFUNCS + PARAMETER (NWAVEFUNCS=${nwavefuncs}) + INTEGER NCOMB + PARAMETER ( NCOMB=${ncomb}) + REAL*8 ZERO + PARAMETER (ZERO=0D0) + COMPLEX*16 IMAG1 + PARAMETER (IMAG1=(0D0,1D0)) + include 'nexternal.inc' +C +C ARGUMENTS +C + REAL*8 P(0:3,NEXTERNAL) +%(me_matrix_ic_decl)s + INTEGER IVEC + INTEGER FLAVOR(NEXTERNAL) + type(aloha) W(NWAVEFUNCS) + COMPLEX*16 AMP(NCOMB,NGRAPHS) + COMPLEX*16 TMP(%(wavefunctionsize)d) +%(amp_chunk_mask_decl)s +C +C LOCAL VARIABLES +C +C Needed for v4 models + COMPLEX*16 DUM0,DUM1 + DATA DUM0, DUM1/(0d0, 0d0), (1d0, 0d0)/ +C The fake widths are recomputed here rather than read out of the +C matrix element, which keeps them SAVEd locals: sharing them would +C mean turning those into a common block, i.e. editing every matrix +C element whether it is chunked or not. A handful of operations, +C done once. + %(fake_width_declaration)s + logical first + data first /.true./ + save first +C +C GLOBAL VARIABLES +C + include '../../Source/vector.inc' ! defines VECSIZE_MEMMAX + include 'coupl.inc' ! needs VECSIZE_MEMMAX (defined in vector.inc) + double precision bwcutoff + common/to_bwcutoff/ bwcutoff + double precision small_width_treatment + common/narrow_width/small_width_treatment +C ---------- +C BEGIN CODE +C ---------- + if (first) then + first=.false. + %(fake_width_definitions)s + endif +C HELAS CALLS BEGIN +${helas_calls} +C HELAS CALLS END + END diff --git a/madgraph/iolibs/template_files/matrix_madevent_group_router_v4.inc b/madgraph/iolibs/template_files/matrix_madevent_group_router_v4.inc new file mode 100644 index 0000000000..a8f06cecc7 --- /dev/null +++ b/madgraph/iolibs/template_files/matrix_madevent_group_router_v4.inc @@ -0,0 +1,55 @@ + SUBROUTINE SMATRIX%(proc_id)s(P, IFLAV, RHEL, RCOL, channel, IVEC, ANS, IHEL, ICOL) +C +%(info_lines)s +C +C MadGraph5_aMC@NLO for Madevent Version +C +C Crossing-symmetry router: this subprocess shares its matrix element with a +C base subprocess of the same group. Each of its flavors is a crossing of a +C base flavor, so instead of its own (heavy) MATRIX it dispatches to the base +C SMATRIX with the extended FLAV_IDX that reproduces the crossed process. The +C base SMATRIX crosses the momenta P (supplied in this subprocess's own leg +C order) and rebuilds the crossed denominator, so ANS is this subprocess's +C matrix element. Only the flavor table (GET_FLAVOR) is kept here for the PDF. +C +%(process_lines)s +C + IMPLICIT NONE + INCLUDE 'nexternal.inc' + REAL*8 P(0:3,NEXTERNAL), ANS + DOUBLE PRECISION RHEL, RCOL + INTEGER channel, IVEC, IFLAV, IHEL, ICOL +C Colour is reselected here, not taken from the base: the base's SELECT_COLOR +C masked its JAMP2 with the BASE's ICOLAMP row, which at the same ICONFIG can +C allow a different set of flows than this subprocess's own, so its pick may +C be a topology this subprocess never emits. XG_SELCOL below permutes the +C base's published per-flow JAMP2 into this subprocess's flow order and runs +C SELECT_COLOR with THIS subprocess's IPROC. (The helicity index needs no +C such treatment: it is a relabel of a choice made in a shared space.) +%(smatrix_router_decl)s + ANS = 0D0 + IHEL = 1 + ICOL = 1 +%(smatrix_router_dispatch)s + END + + + SUBROUTINE GET_FLAVOR%(proc_id)s(IFLAV, FLAVOR_OUT) +C Returns the flavor array for a given flavor index IFLAV + IMPLICIT NONE + INCLUDE 'nexternal.inc' + INTEGER IFLAV, I + INTEGER FLAVOR_OUT(NEXTERNAL) + INTEGER FLAVOR(NEXTERNAL,%(max_flavor)s) + %(get_flavor_matrix)s + FLAVOR_OUT(:) = FLAVOR(:, IFLAV) + END + + + SUBROUTINE PRINT_ZERO_AMP_%(proc_id)s() + INTEGER I + I = 1 + RETURN + END + +%(smatrix_router_helper)s diff --git a/madgraph/iolibs/template_files/matrix_madevent_group_v4.inc b/madgraph/iolibs/template_files/matrix_madevent_group_v4.inc index b1523fc0e2..69215f7c62 100644 --- a/madgraph/iolibs/template_files/matrix_madevent_group_v4.inc +++ b/madgraph/iolibs/template_files/matrix_madevent_group_v4.inc @@ -64,6 +64,7 @@ C INTEGER I,IDEN INTEGER FLAVOR(NEXTERNAL) INTEGER FLAVOR_FOR_SYM(NEXTERNAL) +%(smatrix_me_cross_decl)s C Per-row FLAVOR lookup used by BROKEN_SYM. The IFLAV-indexed FLAVOR C table above can collapse distinct same-flavor / different-flavor C leshouche rows that share the same coupling group; BROKEN_SYM needs @@ -80,7 +81,30 @@ C row-level information to apply the identical-particle correction. DATA NB_FAIL /0/ double precision get_channel_cut external get_channel_cut - +C C-parity helicity de-duplication of the init full-sum loop (uncrossed +C base process only): FLIP(I) is the helicity row with every helicity +C negated (an involution built once). The reuse is ALL-OR-NOTHING per +C (flavor,subprocess): CSYMBAD latches 1 as soon as ANY row fails to pair +C up (a self-paired row, FLIP(I)=I, has no distinct partner) or ANY pair +C shows |M(I)|^2 != |M(FLIP(I))|^2 at a scan point. Only then is exactly +C half the loop skipped, each survivor counted twice. That uniformity is +C what keeps the multi-channel weight exact: halving every row scales +C AMP2(J) and XTOT by the same 1/2, so AMP2(config)/XTOT is unchanged, +C whereas a partial (per-row) de-duplication would rescale AMP2 +C non-uniformly across diagrams. NCSCAN counts only the passes that really +C ran the scan -- NTRY alone is not enough, since read_good_hel restores +C it above the threshold without ever scanning. CSYMBAD/NCSCAN sit in a +C COMMON block (zero-initialised, like NTRY) so the good-hel file can +C carry the verdict across jobs. + INTEGER FLIP(NCOMB), JHEL, KHEL + REAL*8 TSMAX + LOGICAL HELSAME, DEDUP + INTEGER CSYMBAD(MAXFLAVPERPROC,MAXSPROC) + INTEGER NCSCAN(MAXFLAVPERPROC,MAXSPROC) + COMMON/BLOCK_CSYM/CSYMBAD,NCSCAN + SAVE FLIP + DATA FLIP/NCOMB*0/ + c C This is just to temporarily store the reference grid for helicity of the DiscreteSampler so as to obtain its number of entries with ref_helicity_grid%n_tot_entries type(SampledDimension) ref_helicity_grid @@ -90,6 +114,7 @@ C logical init_mode common /to_determine_zero_hel/init_mode DOUBLE PRECISION AMP2(MAXAMPS), JAMP2(0:MAXFLOW) +%(xg_jamp2_decl)s INTEGER NB_SPIN_STATE_in(2) @@ -124,19 +149,48 @@ C To be able to control when the matrix subroutine can add entries to the gr C C FUNCTION C - INTEGER BROKEN_SYM%(proc_id)s + INTEGER BROKEN_SYM%(proc_id)s%(blas_smatrix_decl)s C ---------- C BEGIN CODE C ---------- - CALL GET_FLAVOR%(proc_id)s(IFLAV, FLAVOR) - NTRY(IFLAV,%(proc_id)s)=NTRY(IFLAV,%(proc_id)s)+1 - +%(smatrix_me_cross_decode)s + CALL GET_FLAVOR%(proc_id)s(%(me_flav_key)s, FLAVOR) + NTRY(%(me_flav_key)s,%(proc_id)s)=NTRY(%(me_flav_key)s,%(proc_id)s)+1 +C C-parity partner of each helicity row (all helicities negated), built once. + IF (FLIP(1).EQ.0) THEN + DO I=1,NCOMB + FLIP(I)=I + DO JHEL=1,NCOMB + HELSAME=.TRUE. + DO KHEL=1,NEXTERNAL + IF (NHEL(KHEL,JHEL).NE.-NHEL(KHEL,I)) HELSAME=.FALSE. + ENDDO + IF (HELSAME) THEN + FLIP(I)=JHEL + EXIT + ENDIF + ENDDO + ENDDO +C A self-paired row (FLIP(I)=I, every helicity 0) has no distinct partner, +C so the loop could not be halved uniformly: refuse the reuse outright. + DO I=1,NCOMB + IF (FLIP(I).EQ.I) THEN + DO KHEL=1,MAXSPROC + DO JHEL=1,MAXFLAVPERPROC + CSYMBAD(JHEL,KHEL)=1 + ENDDO + ENDDO + ENDIF + ENDDO + ENDIF + DEDUP = NCSCAN(%(me_flav_key)s,%(proc_id)s).GE.20 .AND. CSYMBAD(%(me_flav_key)s,%(proc_id)s).EQ.0 .AND. (%(me_csym_cross_ok)s) + IF (multi_channel) THEN DO I=1,NDIAGS AMP2(I)=0D0 ENDDO ENDIF - JAMP2(0)=%(ncolor)d + JAMP2(0)=%(ncolor_flow)d DO I=1,INT(JAMP2(0)) JAMP2(I)=0D0 ENDDO @@ -148,18 +202,71 @@ C ---------- ! If the helicity grid status is 0, this means that it is not yet initialized. ! If HEL_PICKED==-1, this means that calls to other matrix where in initialization mode as well for the helicity. IF ((ISHEL.EQ.0.and.ISUM_HEL.eq.0).or.(DS_get_dim_status('Helicity').eq.0).or.(HEL_PICKED.eq.-1)) THEN - DO I=1,NCOMB - IF (GOODHEL(I,IFLAV,%(proc_id)s) .OR. NTRY(IFLAV,%(proc_id)s).LE.MAXTRIES.or.(ISUM_HEL.NE.0)) THEN - T=MATRIX%(proc_id)s(P ,NHEL(1,I),IFLAV,I,AMP2, JAMP2, IVEC) +%(blas_branch)s DO I=1,NCOMB + IF (GOODHEL(%(me_goodhel_idx)s,%(me_flav_key)s,%(proc_id)s) .OR. NTRY(%(me_flav_key)s,%(proc_id)s).LE.MAXTRIES.or.(ISUM_HEL.NE.0)%(smatrix_me_goodhel_or)s) THEN +C Fast phase: skip the higher-index C-parity partner (computed once at +C the lower index, counted twice below). + IF (DEDUP.AND.I.GT.FLIP(I)) CYCLE + T=%(blas_matrix_call)s %(beam_polarization)s IF (ISUM_HEL.NE.0.and.DS_get_dim_status('Helicity').eq.0.and.ALLOW_HELICITY_GRID_ENTRIES) then call DS_add_entry('Helicity',I,T) endif ANS=ANS+DABS(T) TS(I)=T +C The representative carries its skipped partner's identical |M|^2: +C copy TS(FLIP) (so the event-helicity CDF/DS grid pick both) and +C count it once more in ANS. + IF (DEDUP.AND.I.LT.FLIP(I)) THEN + ANS=ANS+DABS(T) + TS(FLIP(I))=T + IF (ISUM_HEL.NE.0.and.DS_get_dim_status('Helicity').eq.0.and.ALLOW_HELICITY_GRID_ENTRIES) + & call DS_add_entry('Helicity',FLIP(I),T) + ENDIF ENDIF ENDDO - IF(NTRY(IFLAV,%(proc_id)s).EQ.(MAXTRIES+1).and.DS_get_dim_status('Helicity').ne.-1) THEN +C Scan phase: drop the C-parity pairing of any row whose fully flipped +C partner gave a different |M|^2 (parity/C/polarization breaking). One +C mismatch at any scan point permanently invalidates the pair. +C A pair of rows that are BOTH numerically zero differ only by roundoff, +C and a purely RELATIVE test on that noise fails at random -- one such +C pair then vetoes every real pair, because the verdict is all-or-nothing. +C Measured on g g > t t~: rows 1/16 and 4/13 sit at |M|^2 ~ 1e-30 while +C the scan maximum is ~1.5e+02, and the de-duplication never engaged. +C So also require the difference to be significant against TSMAX, the +C largest |M|^2 of this scan point: a row that far down cannot bias the +C helicity sum whichever way it is paired, while a genuine parity +C violation still shows up at the relative level. + IF (%(me_csym_cross_ok)s.AND.NCSCAN(%(me_flav_key)s,%(proc_id)s).LT.20) THEN + NCSCAN(%(me_flav_key)s,%(proc_id)s)=NCSCAN(%(me_flav_key)s,%(proc_id)s)+1 + TSMAX=0D0 + DO I=1,NCOMB + IF (DABS(TS(I)).GT.TSMAX) TSMAX=DABS(TS(I)) + ENDDO + DO I=1,NCOMB + IF (FLIP(I).GT.I) THEN + IF (DABS(TS(I)-TS(FLIP(I))).GT.1D-6*(DABS(TS(I))+DABS(TS(FLIP(I)))) + & .AND.DABS(TS(I)-TS(FLIP(I))).GT.1D-12*TSMAX) THEN + CSYMBAD(%(me_flav_key)s,%(proc_id)s)=1 + ENDIF + ENDIF + ENDDO + ENDIF +C Report the surviving C-parity pairs to the helicity-recycling optimizer +C (parsed by gen_ximprove): once the scan has settled (NTRY=20), each +C representative I_optim.f and reuses TS(rep) for it. +C NTRY (not NCSCAN, which saturates at 20) provides the one-shot trigger; +C in init_mode the full-sum branch runs every call, so the two coincide. + IF (init_mode.AND.%(me_csym_cross_ok)s.AND.CSYMBAD(%(me_flav_key)s,%(proc_id)s).EQ.0.AND.NCSCAN(%(me_flav_key)s,%(proc_id)s).GE.20.AND.NTRY(%(me_flav_key)s,%(proc_id)s).EQ.20) THEN + DO I=1,NCOMB + IF (I.LT.FLIP(I).AND.DABS(TS(I)).GT.ANS*LIMHEL/NCOMB) THEN + PRINT *, 'CSYM PAIR: %(proc_id)s ', I, FLIP(I) + ENDIF + ENDDO + ENDIF + IF(NTRY(%(me_flav_key)s,%(proc_id)s).EQ.(MAXTRIES+1).and.DS_get_dim_status('Helicity').ne.-1) THEN call reset_cumulative_variable() ! avoid biais of the initialization ENDIF IF (ISUM_HEL.NE.0) then @@ -176,20 +283,20 @@ C ---------- CALL DS_SET_GRID_MODE('Helicity','init') endif ELSE - IF(NTRY(IFLAV,%(proc_id)s).LE.MAXTRIES)THEN + IF(NTRY(%(me_flav_key)s,%(proc_id)s).LE.MAXTRIES)THEN DO I=1,NCOMB IF(init_mode) THEN IF (DABS(TS(I)).GT.ANS*LIMHEL/NCOMB) THEN PRINT *, 'Matrix Element/Good Helicity: %(proc_id)s ', i, 'IMIRROR', IMIRROR ENDIF - ELSE IF (.NOT.GOODHEL(I,IFLAV,%(proc_id)s) .AND. (DABS(TS(I)).GT.ANS*LIMHEL/NCOMB)) THEN - GOODHEL(I,IFLAV,%(proc_id)s)=.TRUE. + ELSE IF (%(me_goodhel_train_guard)s.NOT.GOODHEL(%(me_goodhel_idx)s,%(me_flav_key)s,%(proc_id)s) .AND. (DABS(TS(I)).GT.ANS*LIMHEL/NCOMB)) THEN + GOODHEL(%(me_goodhel_idx)s,%(me_flav_key)s,%(proc_id)s)=.TRUE. NGOOD = NGOOD +1 - PRINT *,'Added good helicity ',I, 'for process %(proc_id)s flavor ',IFLAV,TS(I)*NCOMB/ANS,' in event ',NTRY(IFLAV,%(proc_id)s) + PRINT *,'Added good helicity ',I, 'for process %(proc_id)s flavor ',IFLAV,TS(I)*NCOMB/ANS,' in event ',NTRY(%(me_flav_key)s,%(proc_id)s) ENDIF ENDDO endif - IF(NTRY(IFLAV,%(proc_id)s).EQ.MAXTRIES)THEN + IF(NTRY(%(me_flav_key)s,%(proc_id)s).EQ.MAXTRIES)THEN ISHEL=MIN(ISUM_HEL,NGOOD) ENDIF ENDIF @@ -197,7 +304,7 @@ C ---------- C The helicity configuration was chosen already by genps and put in a common block defined in genps.inc. I = HEL_PICKED - T=MATRIX%(proc_id)s(P ,NHEL(1,I),IFLAV,I,AMP2, JAMP2, IVEC) + T=MATRIX%(proc_id)s(%(me_matrix_args)s%(blas_matrix_args)s) %(beam_polarization)s c Always one helicity at a time @@ -226,7 +333,7 @@ c Set right sign for ANS, based on sign of chosen helicity IF (MULTI_CHANNEL) THEN XTOT=0D0 DO I=1,LMAXCONFIGS - J = CONFSUB(%(proc_id)s, I) + J = %(me_confsub_j)s if (J.ne.0) then if(sde_strat.eq.1) then AMP2(J) = AMP2(J) * GET_CHANNEL_CUT(P, I) @@ -254,11 +361,13 @@ c Set right sign for ANS, based on sign of chosen helicity ELSE FLAVOR_FOR_SYM(:) = FLAVOR(:) ENDIF - ANS=ANS/DBLE(IDEN)*BROKEN_SYM%(proc_id)s(FLAVOR_FOR_SYM) +%(smatrix_me_iden_line)s +%(xg_jamp2_pub)s call select_color(rcol, jamp2, iconfig,%(proc_id)s, icol, ivec) END +%(crossing_routines_me)s SUBROUTINE GET_FLAVOR%(proc_id)s(IFLAV, FLAVOR_OUT) @@ -274,7 +383,7 @@ C Returns the flavor array for a given flavor index IFLAV END -REAL*8 FUNCTION MATRIX%(proc_id)s(P,NHEL,IFLAV, IHEL,AMP2, JAMP2, IVEC) +REAL*8 FUNCTION MATRIX%(proc_id)s(P,NHEL,%(me_matrix_ic_param)sIFLAV, IHEL,AMP2, JAMP2, IVEC%(blas_arg)s) C %(info_lines)s C @@ -296,8 +405,11 @@ C include 'genps.inc' include 'nexternal.inc' include 'maxamps.inc' - INTEGER NWAVEFUNCS, NCOLOR + INTEGER NWAVEFUNCS, NCOLOR, NCOLORFOLD PARAMETER (NWAVEFUNCS=%(nwavefuncs)d, NCOLOR=%(ncolor)d) + PARAMETER (NCOLORFOLD=%(ncolorfold)d) + INTEGER NCOLOR_FLOW + PARAMETER (NCOLOR_FLOW=%(ncolor_flow)d) REAL*8 ZERO PARAMETER (ZERO=0D0) COMPLEX*16 IMAG1 @@ -314,6 +426,7 @@ C ARGUMENTS C REAL*8 P(0:3,NEXTERNAL) INTEGER IFLAV +%(me_matrix_ic_decl)s INTEGER NHEL(NEXTERNAL), FLAVOR(NEXTERNAL) INTEGER IHEL INTEGER IVEC @@ -321,10 +434,15 @@ C C LOCAL VARIABLES C INTEGER I,J,M,N - COMPLEX*16 ZTEMP, TMP_JAMP(%(nb_temp_jamp)i) - INTEGER CF(NCOLOR*(NCOLOR+1)/2) + COMPLEX*16 ZTEMP +%(jamp_tmp_decl)s +%(jamp_decl)s + INTEGER CF(NCOLORFOLD*(NCOLORFOLD+1)/2) INTEGER DENOM, CF_INDEX - COMPLEX*16 AMP(NGRAPHS), JAMP(NCOLOR,NAMPSO) + COMMON /%(proc_prefix)scolor_matrix%(proc_id)s/ CF,DENOM + COMPLEX*16 AMP(NGRAPHS), JAMP(NCOLOR,NAMPSO)%(blas_decl)s +%(color_fold_decl)s +%(jampflow_decl)s type(aloha) W(NWAVEFUNCS) C Needed for v4 models COMPLEX*16 DUM0,DUM1 @@ -364,11 +482,13 @@ C C COLOR DATA C %(color_data_lines)s +%(color_fold_index)s C ---------- C BEGIN CODE C ---------- if (first) then first=.false. + CALL %(proc_prefix)sINIT_CF%(proc_id)s() %(fake_width_definitions)s if(init_mode) then @@ -384,6 +504,7 @@ AMP(:) = (0d0,0d0) JAMP(:,:) = (0d0,0d0) %(jamp_lines)s +%(jampflow_lines)s if(init_mode)then DO I=1, NGRAPHS @@ -393,18 +514,19 @@ JAMP(:,:) = (0d0,0d0) ENDDO endif +%(color_fold_gather)s%(blas_gather)s MATRIX%(proc_id)s = 0.D0 - DO M = 1, NAMPSO + DO M = 1, NAMPSO%(blas_gate)s CF_INDEX = 0 - DO I = 1, NCOLOR + DO I = 1, NCOLORFOLD ZTEMP = (0.D0,0.D0) - DO J = I, NCOLOR + DO J = I, NCOLORFOLD CF_INDEX = CF_INDEX + 1 - ZTEMP = ZTEMP + CF(CF_INDEX)*JAMP(J,M) + ZTEMP = ZTEMP + CF(CF_INDEX)*%(color_fold_array)s(J,M) ENDDO DO N = 1, NAMPSO %(select_configs_if)s - MATRIX%(proc_id)s = MATRIX%(proc_id)s + ZTEMP*DCONJG(JAMP(I,N)) + MATRIX%(proc_id)s = MATRIX%(proc_id)s + ZTEMP*DCONJG(%(color_fold_array)s(I,N)) %(select_configs_endif)s ENDDO ENDDO @@ -415,11 +537,11 @@ JAMP(:,:) = (0d0,0d0) %(amp2_lines)s endif - Do I = 1, NCOLOR + Do I = 1, NCOLOR_FLOW DO M = 1, NAMPSO DO N = 1, NAMPSO %(select_configs_if)s - Jamp2(i)=Jamp2(i)+DABS(DBLE(Jamp(i,m)*dconjg(Jamp(i,n)))) + Jamp2(i)=Jamp2(i)+DABS(DBLE(%(jamp_flow)s(i,m)*dconjg(%(jamp_flow)s(i,n)))) %(select_configs_endif)s enddo enddo @@ -465,4 +587,6 @@ JAMP(:,:) = (0d0,0d0) end -%(broken_sym_function)s +%(color_init_routine)s%(blas_routine)s + + %(broken_sym_function)s diff --git a/madgraph/iolibs/template_files/matrix_madevent_group_v4_hel.inc b/madgraph/iolibs/template_files/matrix_madevent_group_v4_hel.inc index 45bcdb8211..ab722b8bea 100644 --- a/madgraph/iolibs/template_files/matrix_madevent_group_v4_hel.inc +++ b/madgraph/iolibs/template_files/matrix_madevent_group_v4_hel.inc @@ -51,6 +51,7 @@ C INTEGER I,IDEN INTEGER FLAVOR(NEXTERNAL) INTEGER FLAVOR_FOR_SYM(NEXTERNAL) +%(smatrix_hel_cross_decl)s C Per-row FLAVOR lookup used by BROKEN_SYM. The IFLAV-indexed FLAVOR C table above can collapse distinct same-flavor / different-flavor C leshouche rows that share the same coupling group; BROKEN_SYM needs @@ -70,6 +71,7 @@ C GLOBAL VARIABLES C include '../../Source/vector.inc' ! defines VECSIZE_MEMMAX DOUBLE PRECISION AMP2(MAXAMPS), JAMP2(0:MAXFLOW) +%(xg_jamp2_decl)s C @@ -96,14 +98,15 @@ ${helicity_lines} C ---------- C BEGIN CODE C ---------- - CALL GET_FLAVOR%(proc_id)s(IFLAV, FLAVOR) +%(smatrix_hel_cross_decode)s + CALL GET_FLAVOR%(proc_id)s(%(me_flav_key)s, FLAVOR) IF (multi_channel) THEN DO I=1,NDIAGS AMP2(I)=0D0 ENDDO ENDIF - JAMP2(0)=%(ncolor)d + JAMP2(0)=%(ncolor_flow)d DO I=1,INT(JAMP2(0)) JAMP2(I)=0D0 ENDDO @@ -113,9 +116,13 @@ C ---------- TS(:) = 0d0 - call MATRIX%(proc_id)s(P ,IFLAV, TS, AMP2, JAMP2, IVEC) - DO I=1,NCOMB - T=TS(I) + call MATRIX%(proc_id)s(%(hel_matrix_call_args)s) +C C-parity de-duplication: a dropped partner's HELAS calls were never +C generated, so its TS() is 0 here; copy the representative's identical |M|^2 +C back into it (the recycled MATRIX above computed only the representatives). +${csym_reuse} + DO I=1,NCOMB + T=TS(I) DO JJ=1,nincoming IF(POL(JJ).NE.1d0.AND.NHEL(JJ,I).EQ.INT(SIGN(1d0,POL(JJ)))) THEN T=T*ABS(POL(JJ))*NB_SPIN_STATE_IN(JJ)/2d0 ! NB_SPIN_STATE(JJ)/2d0 is added for polarised beam @@ -144,7 +151,7 @@ c Set right sign for ANS, based on sign of chosen helicity IF (MULTI_CHANNEL) THEN XTOT=0D0 DO I=1,LMAXCONFIGS - J = CONFSUB(%(proc_id)s, I) + J = %(me_confsub_j)s if (J.ne.0)then if (sde_strat.eq.1)then AMP2(J) = AMP2(J) * GET_CHANNEL_CUT(P, I) @@ -167,11 +174,13 @@ c Set right sign for ANS, based on sign of chosen helicity ELSE FLAVOR_FOR_SYM(:) = FLAVOR(:) ENDIF - ANS=ANS/DBLE(IDEN)*BROKEN_SYM%(proc_id)s(FLAVOR_FOR_SYM) +%(smatrix_me_iden_line)s +%(xg_jamp2_pub)s call select_color(rcol, jamp2, iconfig,%(proc_id)s, icol, ivec) END +%(crossing_routines_me)s SUBROUTINE GET_FLAVOR%(proc_id)s(IFLAV, FLAVOR_OUT) @@ -187,7 +196,7 @@ C Returns the flavor array for a given flavor index IFLAV END -Subroutine MATRIX%(proc_id)s(P,IFLAV, TS, AMP2, JAMP2, IVEC) +Subroutine MATRIX%(proc_id)s(P,%(hel_matrix_ic_param)sIFLAV, TS, AMP2, JAMP2, IVEC) C %(info_lines)s C @@ -207,8 +216,11 @@ C include 'genps.inc' include 'nexternal.inc' include 'maxamps.inc' - INTEGER NWAVEFUNCS, NCOLOR + INTEGER NWAVEFUNCS, NCOLOR, NCOLORFOLD PARAMETER (NWAVEFUNCS=${nwavefuncs}, NCOLOR=%(ncolor)d) + PARAMETER (NCOLORFOLD=%(ncolorfold)d) + INTEGER NCOLOR_FLOW + PARAMETER (NCOLOR_FLOW=%(ncolor_flow)d) REAL*8 ZERO PARAMETER (ZERO=0D0) COMPLEX*16 IMAG1 @@ -227,6 +239,7 @@ C ARGUMENTS C REAL*8 P(0:3,NEXTERNAL) INTEGER IFLAV +%(me_matrix_ic_decl)s INTEGER FLAVOR(NEXTERNAL) REAL*8 TS(NCOMB) INTEGER IVEC @@ -234,11 +247,17 @@ C C LOCAL VARIABLES C INTEGER I,J,M,N,K - COMPLEX*16 ZTEMP,TMP_JAMP(%(nb_temp_jamp)i) + COMPLEX*16 ZTEMP +%(jamp_tmp_decl)s +%(jamp_decl)s COMPLEX*16 TMP(%(wavefunctionsize)d) - INTEGER CF(NCOLOR*(NCOLOR+1)) + INTEGER CF(NCOLORFOLD*(NCOLORFOLD+1)) INTEGER DENOM, CF_INDEX - COMPLEX*16 AMP(NCOMB,NGRAPHS), JAMP(NCOLOR,NAMPSO) + COMMON /%(proc_prefix)scolor_matrix%(proc_id)s/ CF,DENOM + COMPLEX*16 AMP(NCOMB,NGRAPHS), JAMP(NCOLOR,NAMPSO)%(blas_hel_decl)s +${hr_gather_decl} +%(color_fold_decl)s +%(jampflow_decl)s type(aloha) W(NWAVEFUNCS) C Needed for v4 models COMPLEX*16 DUM0,DUM1 @@ -272,52 +291,59 @@ C C COLOR DATA C %(color_data_lines)s +%(color_fold_index)s C ---------- C BEGIN CODE C ---------- if (first) then first=.false. + CALL %(proc_prefix)sINIT_CF%(proc_id)s() %(fake_width_definitions)s endif C Rebuild the FLAVOR(NEXTERNAL) array from the threaded flavor index. CALL GET_FLAVOR%(proc_id)s(IFLAV, FLAVOR) -%(flavor_mask_setup)s +%(flavor_mask_setup)s%(blas_hel_setup)s AMP(:,:) = (0d0,0d0) ${helas_calls} +C END OF RECYCLED HELAS BLOCK JAMP(:,:) = (0d0,0d0) - DO K = 1, NCOMB + DO ${hr_gather_open} ${jamp_lines} +%(color_fold_gather)s%(blas_hel_gather)s +%(jampflow_lines)s TS(K) = 0.D0 - DO M = 1, NAMPSO + DO M = 1, NAMPSO%(blas_hel_gate)s CF_INDEX = 0 - DO I = 1, NCOLOR + DO I = 1, NCOLORFOLD ZTEMP = (0.D0,0.D0) - DO J = I, NCOLOR + DO J = I, NCOLORFOLD CF_INDEX = CF_INDEX + 1 - ZTEMP = ZTEMP + CF(CF_INDEX)*JAMP(J,M) + ZTEMP = ZTEMP + CF(CF_INDEX)*%(color_fold_array)s(J,M) ENDDO ! J DO N = 1, NAMPSO %(select_configs_if)s - TS(K) = TS(K) + REAL(ZTEMP*DCONJG(JAMP(I,N))) + TS(K) = TS(K) + REAL(ZTEMP*DCONJG(%(color_fold_array)s(I,N))) %(select_configs_endif)s ENDDO ! N ENDDO ! I ENDDO ! M TS(K) = TS(K) / DENOM + ${dead_row_if} if(sde_strat.eq.1) then ${amp2_lines} endif - Do I = 1, NCOLOR + Do I = 1, NCOLOR_FLOW DO M = 1, NAMPSO DO N = 1, NAMPSO %(select_configs_if)s - Jamp2(i)=Jamp2(i)+DABS(DBLE(Jamp(i,m)*dconjg(Jamp(i,n)))) + Jamp2(i)=Jamp2(i)+DABS(DBLE(%(jamp_flow)s(i,m)*dconjg(%(jamp_flow)s(i,n)))) %(select_configs_endif)s enddo enddo Enddo - ENDDO ! K + ${dead_row_endif} + ENDDO ! ${hr_gather_close}%(blas_hel_finish)s END @@ -330,4 +356,6 @@ ${jamp_lines} end -%(broken_sym_function)s +%(color_init_routine)s%(blas_hel_routine)s + + %(broken_sym_function)s diff --git a/madgraph/iolibs/template_files/matrix_madevent_v4.inc b/madgraph/iolibs/template_files/matrix_madevent_v4.inc index 7b1d6bf1b9..df9b62b469 100644 --- a/madgraph/iolibs/template_files/matrix_madevent_v4.inc +++ b/madgraph/iolibs/template_files/matrix_madevent_v4.inc @@ -61,6 +61,30 @@ C INTEGER IDUM, NGOOD, J, JJ REAL XRAN1 EXTERNAL XRAN1 +C C-parity helicity de-duplication of the init full-sum loop (see below): +C FLIP(I) is the helicity row with every helicity negated (an involution +C built once). The de-duplication is ALL-OR-NOTHING per flavor: CSYMBAD(IFLAV) +C latches 1 as soon as ANY row fails to pair up (a self-paired row, FLIP(I)=I, +C has no distinct partner) or ANY pair shows |M(I)|^2 != |M(FLIP(I))|^2 at a +C scan point. Only when every row is in a genuine matched pair is the reuse +C enabled, and then exactly half the rows are evaluated and each counted twice. +C That uniformity is what makes the reuse safe for the multi-channel weight: +C AMP2(J) is accumulated inside MATRIX over this same loop, so a partial +C (per-row) de-duplication would rescale AMP2 non-uniformly across diagrams and +C bias AMP2(config)/XTOT even though the total |M|^2 stays exact. Halving every +C row scales AMP2(J) and XTOT by the same 1/2, leaving the ratio untouched. +C NCSCAN counts only the passes that actually ran the scan (NTRY alone is not +C enough: read_good_hel restores NTRY above the threshold without ever +C scanning, and the random-helicity branch bumps NTRY without a full sum). +C CSYMBAD/NCSCAN live in a COMMON block (zero-initialised, like NTRY above) so +C write_good_hel/read_good_hel can persist the verdict across jobs. + INTEGER FLIP(NCOMB), JHEL, KHEL + REAL*8 TSMAX + LOGICAL HELSAME, DEDUP + INTEGER CSYMBAD(MAXFLAVPERPROC), NCSCAN(MAXFLAVPERPROC) + COMMON/BLOCK_CSYM/CSYMBAD,NCSCAN + SAVE FLIP + DATA FLIP/NCOMB*0/ INTEGER FLAVOR(NEXTERNAL) INTEGER FLAVOR_FOR_SYM(NEXTERNAL) C Per-row FLAVOR lookup used by BROKEN_SYM. The IFLAV-indexed FLAVOR @@ -71,7 +95,7 @@ C row-level information to apply the identical-particle correction. %(get_flavor_row_matrix)s INTEGER IPSEL COMMON /SUBPROC/ IPSEL - INTEGER BROKEN_SYM%(proc_id)s + INTEGER BROKEN_SYM%(proc_id)s%(blas_smatrix_decl)s C C GLOBAL VARIABLES @@ -100,6 +124,34 @@ C BEGIN CODE C ---------- CALL GET_FLAVOR%(proc_id)s(IFLAV, FLAVOR) NTRY(IFLAV)=NTRY(IFLAV)+1 +C C-parity partner of each helicity row (all helicities negated): an +C involution, self-paired when every helicity is 0. Fixed per process, so +C built once (FLIP starts at 0). + IF (FLIP(1).EQ.0) THEN + DO I=1,NCOMB + FLIP(I)=I + DO JHEL=1,NCOMB + HELSAME=.TRUE. + DO KHEL=1,NEXTERNAL + IF (NHEL(KHEL,JHEL).NE.-NHEL(KHEL,I)) HELSAME=.FALSE. + ENDDO + IF (HELSAME) THEN + FLIP(I)=JHEL + EXIT + ENDIF + ENDDO + ENDDO +C A self-paired row (FLIP(I)=I, i.e. every helicity 0) has no distinct +C partner, so the loop could not be halved uniformly: refuse the +C de-duplication outright for every flavor in that case. + DO I=1,NCOMB + IF (FLIP(I).EQ.I) THEN + DO JHEL=1,MAXFLAVPERPROC + CSYMBAD(JHEL)=1 + ENDDO + ENDIF + ENDDO + ENDIF DO I=1,NEXTERNAL JC(I) = +1 ENDDO @@ -120,18 +172,64 @@ c WRITE(HEL_BUFF,'(20I5)') (0,I=1,NEXTERNAL) ENDDO ! If the helicity grid status is 0, this means that it is not yet initialized. +! C-parity de-duplication of this init full-sum loop is all-or-nothing per +! flavor and only kicks in once this flavor has actually completed the scan +! (NCSCAN>=20; the scan passes stay within MAXTRIES=25 so the reuse overlaps +! the grid build). A crossing is a separate subprocess directory in madevent, +! so every IFLAV row here is the uncrossed base process; polarized beams are +! self-excluded because the beam_polarization scaling makes the flipped +! partner's |M|^2 differ, so the scan latches CSYMBAD. + DEDUP = NCSCAN(IFLAV).GE.20 .AND. CSYMBAD(IFLAV).EQ.0 IF (ISUM_HEL.EQ.0.or.(DS_get_dim_status('Helicity').eq.0)) THEN - DO I=1,NCOMB +%(blas_branch)s DO I=1,NCOMB IF (GOODHEL(I,IFLAV) .OR. NTRY(IFLAV) .LE. MAXTRIES.OR.(ISUM_HEL.NE.0)) THEN - T=MATRIX%(proc_id)s(P,NHEL(1,I),IFLAV, IVEC) +C Fast phase: every row is in a matched pair (all-or-nothing), so the +C higher-index partner is skipped and the lower index counted twice. + IF (DEDUP.AND.I.GT.FLIP(I)) CYCLE + T=%(blas_matrix_call)s %(beam_polarization)s IF (ISUM_HEL.NE.0) then call DS_add_entry('Helicity',I,T) endif ANS=ANS+DABS(T) TS(I)=T +C The representative carries its skipped partner's identical +C contribution: copy |M|^2 to TS(FLIP) (so the per-helicity CDF and +C the DS grid pick both members), and count it once more in ANS. + IF (DEDUP.AND.I.LT.FLIP(I)) THEN + ANS=ANS+DABS(T) + TS(FLIP(I))=T + IF (ISUM_HEL.NE.0) call DS_add_entry('Helicity',FLIP(I),T) + ENDIF ENDIF ENDDO +C Scan phase: this pass evaluated every row, so it can test the pairing. +C A single mismatching pair (parity/C/polarization breaking) permanently +C disables the de-duplication for the whole flavor. +C A pair of rows that are BOTH numerically zero differ only by roundoff, +C and a purely RELATIVE test on that noise fails at random -- one such +C pair then vetoes every real pair, because the verdict is all-or-nothing. +C Measured on g g > t t~: rows 1/16 and 4/13 sit at |M|^2 ~ 1e-30 while +C the scan maximum is ~1.5e+02, and the de-duplication never engaged. +C So also require the difference to be significant against TSMAX, the +C largest |M|^2 of this scan point: a row that far down cannot bias the +C helicity sum whichever way it is paired, while a genuine parity +C violation still shows up at the relative level. + IF (NCSCAN(IFLAV).LT.20) THEN + NCSCAN(IFLAV)=NCSCAN(IFLAV)+1 + TSMAX=0D0 + DO I=1,NCOMB + IF (DABS(TS(I)).GT.TSMAX) TSMAX=DABS(TS(I)) + ENDDO + DO I=1,NCOMB + IF (FLIP(I).GT.I) THEN + IF (DABS(TS(I)-TS(FLIP(I))).GT.1D-6*(DABS(TS(I))+DABS(TS(FLIP(I)))) + & .AND.DABS(TS(I)-TS(FLIP(I))).GT.1D-12*TSMAX) THEN + CSYMBAD(IFLAV)=1 + ENDIF + ENDIF + ENDDO + ENDIF IF(NTRY(IFLAV).EQ.(MAXTRIES+1)) THEN call reset_cumulative_variable() ! avoid biais of the initialization ENDIF @@ -165,7 +263,7 @@ c WRITE(HEL_BUFF,'(20I5)') (0,I=1,NEXTERNAL) C The helicity configuration was chosen already by genps and put in a common block defined in genps.inc. I = HEL_PICKED - T=MATRIX%(proc_id)s(P ,NHEL(1,I),IFLAV, IVEC) + T=MATRIX%(proc_id)s(P ,NHEL(1,I),IFLAV, IVEC%(blas_matrix_args)s) %(beam_polarization)s c Always one helicity at a time ANS = T @@ -225,7 +323,7 @@ C Returns the flavor array for a given flavor index IFLAV END -REAL*8 FUNCTION MATRIX%(proc_id)s(P,NHEL,IFLAV, IVEC) +REAL*8 FUNCTION MATRIX%(proc_id)s(P,NHEL,IFLAV, IVEC%(blas_arg)s) use model_object use aloha_object C @@ -246,8 +344,9 @@ C include 'genps.inc' include 'nexternal.inc' include 'maxamps.inc' - INTEGER NWAVEFUNCS, NCOLOR + INTEGER NWAVEFUNCS, NCOLOR, NCOLORFOLD PARAMETER (NWAVEFUNCS=%(nwavefuncs)d, NCOLOR=%(ncolor)d) + PARAMETER (NCOLORFOLD=%(ncolorfold)d) REAL*8 ZERO PARAMETER (ZERO=0D0) COMPLEX*16 IMAG1 @@ -271,10 +370,14 @@ C C LOCAL VARIABLES C INTEGER I,J,M,N - COMPLEX*16 ZTEMP, TMP_JAMP(%(nb_temp_jamp)i) - INTEGER CF(NCOLOR*(NCOLOR+1)) + COMPLEX*16 ZTEMP +%(jamp_tmp_decl)s +%(jamp_decl)s + INTEGER CF(NCOLORFOLD*(NCOLORFOLD+1)) INTEGER CF_INDEX,DENOM - COMPLEX*16 AMP(NGRAPHS), JAMP(NCOLOR,NAMPSO) + COMMON /%(proc_prefix)scolor_matrix%(proc_id)s/ CF,DENOM + COMPLEX*16 AMP(NGRAPHS), JAMP(NCOLOR,NAMPSO)%(blas_decl)s +%(color_fold_decl)s type(aloha) W(NWAVEFUNCS) C Needed for v4 models COMPLEX*16 DUM0,DUM1 @@ -302,11 +405,13 @@ C C COLOR DATA C %(color_data_lines)s +%(color_fold_index)s C ---------- C BEGIN CODE C ---------- if (first) then first=.false. + CALL %(proc_prefix)sINIT_CF%(proc_id)s() %(fake_width_definitions)s endif @@ -317,18 +422,19 @@ AMP(:) = (0d0,0d0) %(helas_calls)s %(jamp_lines)s +%(color_fold_gather)s%(blas_gather)s MATRIX%(proc_id)s = 0.D0 - DO M = 1, NAMPSO + DO M = 1, NAMPSO%(blas_gate)s CF_INDEX = 0 - DO I = 1, NCOLOR + DO I = 1, NCOLORFOLD ZTEMP = (0.D0,0.D0) - DO J = I, NCOLOR + DO J = I, NCOLORFOLD CF_INDEX = CF_INDEX +1 - ZTEMP = ZTEMP + CF(CF_INDEX)*JAMP(J,M) + ZTEMP = ZTEMP + CF(CF_INDEX)*%(color_fold_array)s(J,M) ENDDO DO N = 1, NAMPSO IF (CHOSEN_SO_CONFIGS(SQSOINDEX%(proc_id)s(M,N))) THEN - MATRIX%(proc_id)s = MATRIX%(proc_id)s + ZTEMP*DCONJG(JAMP(I,N)) + MATRIX%(proc_id)s = MATRIX%(proc_id)s + ZTEMP*DCONJG(%(color_fold_array)s(I,N)) ENDIF ENDDO ENDDO @@ -349,4 +455,6 @@ AMP(:) = (0d0,0d0) END -%(broken_sym_function)s +%(color_init_routine)s%(blas_routine)s + + %(broken_sym_function)s diff --git a/madgraph/iolibs/template_files/matrix_standalone_crossing_v4.inc b/madgraph/iolibs/template_files/matrix_standalone_crossing_v4.inc new file mode 100644 index 0000000000..2d7e757ac5 --- /dev/null +++ b/madgraph/iolibs/template_files/matrix_standalone_crossing_v4.inc @@ -0,0 +1,389 @@ + SUBROUTINE %(proc_prefix)sGET_CROSS_PERM(FLAV_IDX_IN, PERM, SGN, + & FLAV_IDX) +C Decode the crossing carried by FLAV_IDX_IN into a slot permutation. +C +C CROSS = (FLAV_IDX_IN-1) / NFLAV +C FLAV_IDX = mod(FLAV_IDX_IN-1, NFLAV) + 1 ! used for masking/... +C I = CROSS / (NEXTERNAL+1) ! partner of particle 1 +C J = mod(CROSS, NEXTERNAL+1) ! partner of particle 2 +C +C Particle 1 is swapped with particle I and particle 2 with particle J; 0 +C means "leave that particle alone", so FLAV_IDX_IN in [1,NFLAV] gives +C CROSS=0 and the identity, keeping old callers untouched. The base is +C NEXTERNAL+1 rather than NEXTERNAL so that I and J run over 0..NEXTERNAL +C and can designate the last particle as well. +C +C Swapping moves the momentum and the helicity between the two slots and +C flips their NSF/NSV flag through IC. Flipping that flag is what actually +C crosses the leg: helas stores the momentum as p*nsf and uses nhel*nsf, +C so the momentum sign change and the helicity flip both follow from it. +C Momenta therefore stay physical (positive energy), which matters because +C the helas spinors take dsqrt(p(0)+pp). +C +C The crossing is nothing but a fixed relabelling of slots, so it is +C decoded once into +C PERM(K) : the input slot whose content lands in crossed slot K, +C SGN(K) : the NSF/NSV sign flip applied to crossed slot K, +C and the callers reuse it for as many momenta/helicity rows as they like +C (see APPLY_CROSSING_TABLE) instead of decoding per matrix element call. +C FLAV_IDX comes back 0 for a code that names no crossing at all (see the +C two rejections below). PERM/SGN are left a valid permutation whatever +C happens, so a caller that gathers momenta with them never reads out of +C range; 0 is what GET_PDG_FOR_FLAVOR and GET_AMP already treat as "not a +C flavor", so no caller needs a new return convention. + IMPLICIT NONE + INCLUDE 'nexternal.inc' + INTEGER NFLAV + PARAMETER (NFLAV=%(nflav)d) + INTEGER NCROSS + PARAMETER (NCROSS=(NEXTERNAL+1)*(NEXTERNAL+1)) +C ARGUMENTS + INTEGER FLAV_IDX_IN + INTEGER PERM(NEXTERNAL), SGN(NEXTERNAL) + INTEGER FLAV_IDX +C LOCAL + INTEGER CROSS, XI, XJ, XK + + FLAV_IDX = MOD(FLAV_IDX_IN-1, NFLAV) + 1 + CROSS = (FLAV_IDX_IN-1) / NFLAV + XI = CROSS / (NEXTERNAL+1) + XJ = MOD(CROSS, NEXTERNAL+1) + + DO XK = 1, NEXTERNAL + PERM(XK) = XK + SGN(XK) = 1 + ENDDO + +C Out of range, or an overlapping-swap code: both transpositions {1,XI} and +C {2,XJ} active AND sharing a slot compose into a 3-cycle that the consumers +C read with opposite orientation, so it is pure redundancy. + IF (CROSS .LT. 0 .OR. CROSS .GT. NCROSS-1 .OR. + & (XI.NE.0 .AND. XI.NE.1 .AND. XJ.NE.0 .AND. XJ.NE.2 .AND. + & (XI.EQ.2 .OR. XJ.EQ.1 .OR. XI.EQ.XJ))) THEN + FLAV_IDX = 0 + RETURN + ENDIF + +C XI==1 (resp. XJ==2) would swap a particle with itself: degenerate, so +C treated as "no crossing" just like 0. + IF (XI.NE.0 .AND. XI.NE.1) THEN + CALL %(proc_prefix)sSWAP_LEGS(1, XI, PERM, SGN) + ENDIF + IF (XJ.NE.0 .AND. XJ.NE.2) THEN + CALL %(proc_prefix)sSWAP_LEGS(2, XJ, PERM, SGN) + ENDIF + +C A crossing may only conjugate a leg that CHANGES SIDE. A transposition +C between two legs on the same side of the initial/final line conjugates +C both without moving either across, which is no crossing: for a 2 -> N +C process that is XI==2 / XJ==1, the beam swap, which must not conjugate +C anything (it would give e.g. u~ g > e+ ve d, not even charge conserving); +C for a 1 -> N one it is every XJ swap. + DO XK = 1, NEXTERNAL + IF (SGN(XK).EQ.-1 .AND. + & ((XK.LE.NINCOMING) .EQV. (PERM(XK).LE.NINCOMING))) THEN + FLAV_IDX = 0 + RETURN + ENDIF + ENDDO + + RETURN + END + + + SUBROUTINE %(proc_prefix)sSWAP_LEGS(SLOT_A, SLOT_B, PERM, SGN) +C Exchange two legs in the permutation being built and flip their NSF/NSV +C sign (see GET_CROSS_PERM). SGN is swapped along with PERM before being +C negated, so that two overlapping swaps compose exactly as they would if +C the momentum/helicity/IC arrays themselves were swapped in turn. + IMPLICIT NONE + INCLUDE 'nexternal.inc' + INTEGER SLOT_A, SLOT_B + INTEGER PERM(NEXTERNAL), SGN(NEXTERNAL) + INTEGER ITMP + + ITMP = PERM(SLOT_A) + PERM(SLOT_A) = PERM(SLOT_B) + PERM(SLOT_B) = ITMP + ITMP = SGN(SLOT_A) + SGN(SLOT_A) = SGN(SLOT_B) + SGN(SLOT_B) = ITMP + SGN(SLOT_A) = -SGN(SLOT_A) + SGN(SLOT_B) = -SGN(SLOT_B) + + RETURN + END + + + SUBROUTINE %(proc_prefix)sAPPLY_CROSSING_TABLE(FLAV_IDX_IN, NROW, + & P_IN, NHEL_IN, IC_IN, P, NHEL, IC, FLAV_IDX) +C Apply the crossing carried by FLAV_IDX_IN to one set of momenta / NSF +C flags and to NROW helicity rows at once (see GET_CROSS_PERM). +C +C SMATRIX uses this to permute its whole NHEL table in a single sweep +C before the helicity loop: the permutation does not depend on the row, so +C decoding it once per SMATRIX call rather than once per helicity is both +C cheaper and the reason MATRIX/GET_AMP can stay pure. +C P/NHEL/IC must not alias P_IN/NHEL_IN/IC_IN. + IMPLICIT NONE + INCLUDE 'nexternal.inc' +C ARGUMENTS + INTEGER FLAV_IDX_IN, NROW + REAL*8 P_IN(0:3,NEXTERNAL) + INTEGER NHEL_IN(NEXTERNAL,NROW), IC_IN(NEXTERNAL) + REAL*8 P(0:3,NEXTERNAL) + INTEGER NHEL(NEXTERNAL,NROW), IC(NEXTERNAL) + INTEGER FLAV_IDX +C LOCAL + INTEGER PERM(NEXTERNAL), SGN(NEXTERNAL) + INTEGER XK, XR + + CALL %(proc_prefix)sGET_CROSS_PERM(FLAV_IDX_IN, PERM, SGN, FLAV_IDX) + + DO XK = 1, NEXTERNAL + P(0,XK) = P_IN(0,PERM(XK)) + P(1,XK) = P_IN(1,PERM(XK)) + P(2,XK) = P_IN(2,PERM(XK)) + P(3,XK) = P_IN(3,PERM(XK)) + IC(XK) = SGN(XK)*IC_IN(PERM(XK)) + ENDDO + DO XR = 1, NROW + DO XK = 1, NEXTERNAL + NHEL(XK,XR) = NHEL_IN(PERM(XK),XR) + ENDDO + ENDDO + + RETURN + END + + + SUBROUTINE %(proc_prefix)sAPPLY_CROSSING(FLAV_IDX_IN, P_IN, NHEL_IN, + & IC_IN, P, NHEL, IC, FLAV_IDX) +C Single helicity row flavour of APPLY_CROSSING_TABLE; kept public so that +C an external (f2py) caller holding an extended FLAV_IDX can pre-apply the +C crossing before calling GET_AMP, which is pure and rejects an extended +C index (see its contract). + IMPLICIT NONE + INCLUDE 'nexternal.inc' + INTEGER FLAV_IDX_IN + REAL*8 P_IN(0:3,NEXTERNAL) + INTEGER NHEL_IN(NEXTERNAL), IC_IN(NEXTERNAL) + REAL*8 P(0:3,NEXTERNAL) + INTEGER NHEL(NEXTERNAL), IC(NEXTERNAL) + INTEGER FLAV_IDX + + CALL %(proc_prefix)sAPPLY_CROSSING_TABLE(FLAV_IDX_IN, 1, P_IN, + & NHEL_IN, IC_IN, P, NHEL, IC, FLAV_IDX) + + RETURN + END + + + INTEGER FUNCTION %(proc_prefix)sGET_SPINCOL_CROSS(CROSS) +C Initial-state spin*color average of the crossed process. +C +C Crossing changes which particles sit in the initial state (pulling a +C gluon in takes the color average from 3 to 8), but every particle of a +C flavor group shares its spin and color, and conjugation preserves both. +C So this half of the denominator is just the product of the per-particle +C spin*color (SPINCOL_PART, one entry per external leg) over the two legs +C the crossing puts in the initial state -- no per-crossing table needed. +C A crossing that cannot be applied returns 0, which SMATRIX / +C GET_PDG_FOR_FLAVOR / GET_ALL_INTER_CROSSED all map to a null result: this +C is the single gate that says whether a CROSS code is applicable at all. +C The flavor-dependent half is GET_IDENT_CROSS. + IMPLICIT NONE + INCLUDE 'nexternal.inc' + INTEGER NFLAV + PARAMETER (NFLAV=%(nflav)d) + INTEGER CROSS, XK, FACTOR, I, XIDX + INTEGER PERM(NEXTERNAL), SGN(NEXTERNAL) +C The DATA tables are emitted together (SPINCOL_PART and COUNTABLE here, +C plus the IDS_BASE/ANTIPID_BASE tables GET_IDENT_CROSS reads); each routine +C keeps its own copy rather than sharing a COMMON, which would need a BLOCK +C DATA unit to be DATA-initialised. + INTEGER SPINCOL_PART(0:NEXTERNAL-1) + INTEGER IDS_BASE(0:NEXTERNAL-1) + INTEGER ANTIPID_BASE(0:NEXTERNAL-1) + INTEGER COUNTABLE(0:NEXTERNAL-1) +%(iden_cross_lines)s + +C Decode through GET_CROSS_PERM rather than rebuilding the slot map here, +C so that which codes name a crossing at all is decided in exactly one +C place (it rejects out-of-range, overlapping-swap and same-side codes). + CALL %(proc_prefix)sGET_CROSS_PERM(CROSS*NFLAV+1, PERM, SGN, XIDX) + IF (XIDX .LT. 1) THEN + %(proc_prefix)sGET_SPINCOL_CROSS = 0 + RETURN + ENDIF +C Fail-safe for a decay chain: COUNTABLE is 0 for a leg that cannot be +C crossed (a leaf locked inside a decay block), and carrying one across the +C initial/final line would split its resonance. Generation already leaves +C such a crossing unrecorded, so nothing should ask for it -- this is the +C net under that, not the thing that makes it correct. + DO XK = 1, NEXTERNAL + IF (SGN(XK).EQ.-1 .AND. COUNTABLE(PERM(XK)-1).EQ.0) THEN + %(proc_prefix)sGET_SPINCOL_CROSS = 0 + RETURN + ENDIF + ENDDO +C Multiply the per-particle spin*color of the legs the crossing puts in the +C initial slots. + FACTOR = 1 + DO XK = 1, NINCOMING + FACTOR = FACTOR * SPINCOL_PART(PERM(XK)-1) + ENDDO + %(proc_prefix)sGET_SPINCOL_CROSS = FACTOR + + RETURN + END + + + INTEGER FUNCTION %(proc_prefix)sGET_IDENT_CROSS(CROSS, FLAVOR) +C Identical final state factor (product of n!) of the crossed process. +C +C Flavor dependent, hence computed here rather than tabulated on CROSS: +C d d~ > g u u~ crossed gives d g > d u u~ with nothing identical, while +C d d~ > g d d~ crossed gives d g > d d d~ with two identical d. BROKEN_SYM +C cannot be reused for this: its tables describe the uncrossed final state. +C +C Two crossed final legs are identical when they carry the same flavor +C group (same representative PDG, conjugated already when the leg swapped +C side) and the same position inside it. Neither the per-slot representative +C PDG nor the FLAVOR source slot is tabulated per crossing: both follow from +C the crossing PERM/IC (as GET_SPINCOL_CROSS / GET_CROSS_PERM build it) +C applied to two NEXTERNAL-long base tables -- IDS_BASE, the base PDG of each +C leg, and ANTIPID_BASE, its charge conjugate for a leg that swapped side. + IMPLICIT NONE + INCLUDE 'nexternal.inc' + INTEGER CROSS + INTEGER FLAVOR(NEXTERNAL) +C SPINCOL_PART is unused here (GET_SPINCOL_CROSS owns it) but must be +C declared because the shared DATA block below initialises it. + INTEGER SPINCOL_PART(0:NEXTERNAL-1) + INTEGER IDS_BASE(0:NEXTERNAL-1) + INTEGER ANTIPID_BASE(0:NEXTERNAL-1) +C COUNTABLE(leg)=1 for a single external leg, 0 for a leaf locked inside a +C decay block. A crossing never moves a block leaf (GET_SPINCOL_CROSS +C rejects any that would), so decay products keep their base slots and must +C be skipped here: their contribution to the identical-final factor is +C resonance-level, carried whole by IDENT_RESONANCE. For a non-decay process +C every leg is countable and IDENT_RESONANCE is 1, so this is the plain leaf +C count it always was. + INTEGER COUNTABLE(0:NEXTERNAL-1) + INTEGER IDENT_RESONANCE + PARAMETER (IDENT_RESONANCE=%(ident_resonance)d) +%(iden_cross_lines)s + INTEGER K, L, N, FACT, XI, XJ, XT, I + INTEGER PERM(NEXTERNAL), ICS(NEXTERNAL), BPID(NEXTERNAL) + LOGICAL USED(NEXTERNAL) + +C Rebuild the slot->leg map PERM and its initial/final sign flips ICS from +C CROSS (identity plus the crossing's two transpositions), exactly as in +C GET_SPINCOL_CROSS, then read each slot's representative PDG straight off +C IDS_BASE (ANTIPID_BASE where the leg changed side). + XI = CROSS / (NEXTERNAL+1) + XJ = MOD(CROSS, NEXTERNAL+1) + DO K = 1, NEXTERNAL + PERM(K) = K + ICS(K) = 1 + ENDDO + IF (XI.NE.0 .AND. XI.NE.1) THEN + XT = PERM(1) + PERM(1) = PERM(XI) + PERM(XI) = XT + ICS(1) = -ICS(1) + ICS(XI) = -ICS(XI) + ENDIF + IF (XJ.NE.0 .AND. XJ.NE.2) THEN + XT = PERM(2) + PERM(2) = PERM(XJ) + PERM(XJ) = XT + ICS(2) = -ICS(2) + ICS(XJ) = -ICS(XJ) + ENDIF + DO K = 1, NEXTERNAL + IF (ICS(K).EQ.1) THEN + BPID(K) = IDS_BASE(PERM(K)-1) + ELSE + BPID(K) = ANTIPID_BASE(PERM(K)-1) + ENDIF + ENDDO + +C FLAVOR is not permuted by the crossing, so slot K reads FLAVOR(PERM(K)), +C the actual flavor of the original leg that moved into it. Start from +C IDENT_RESONANCE (the resonance-level part of the identical factor, which a +C crossing never changes) and multiply in the n! of the countable final legs +C only -- a decay-block leaf is skipped, its symmetry already inside +C IDENT_RESONANCE. + DO K = 1, NEXTERNAL + USED(K) = .FALSE. + ENDDO + FACT = IDENT_RESONANCE + DO K = NINCOMING+1, NEXTERNAL + IF (USED(K)) CYCLE + IF (COUNTABLE(PERM(K)-1).EQ.0) CYCLE + N = 1 + DO L = K+1, NEXTERNAL + IF (USED(L)) CYCLE + IF (COUNTABLE(PERM(L)-1).EQ.0) CYCLE + IF (BPID(K).EQ.BPID(L) .AND. + $ FLAVOR(PERM(K)).EQ.FLAVOR(PERM(L))) THEN + USED(L) = .TRUE. + N = N + 1 + FACT = FACT * N + ENDIF + ENDDO + ENDDO + %(proc_prefix)sGET_IDENT_CROSS = FACT + + RETURN + END + + SUBROUTINE %(proc_prefix)sCROSS_GHIDX(CROSS, PERM, SGN, NHELCOL, + & GHIDX) +C Runtime good-helicity remap: send a crossed helicity row (given by its +C BASE-table config NHELCOL and the crossing's PERM/SGN from GET_CROSS_PERM) +C to the identity row whose shared GOODHEL bit gates it. This replaces the +C baked GHREMAP(NCROSS*NCOMB) table: the map is a fixed permutation, so it is +C cheaper to recompute it from the config than to store it -- permute and +C sign-flip the config, then re-encode it in the canonical mixed-radix order +C (the same STATES/NHSTATE the encoder/decoder use). +C GHIDX=0 when the crossing is not filterable (GHFILT flag: initial-initial +C swap, inapplicable, or a non-bijection); the caller then computes every +C helicity and never trains. For CROSS=0 (PERM identity, SGN +1) this returns +C IHEL, so the uncrossed path is unchanged. + IMPLICIT NONE + INCLUDE 'nexternal.inc' + INTEGER MAXHEL + PARAMETER (MAXHEL=%(maxhel)d) + INTEGER NCROSS + PARAMETER (NCROSS=(NEXTERNAL+1)*(NEXTERNAL+1)) + INTEGER CROSS, PERM(NEXTERNAL), SGN(NEXTERNAL) + INTEGER NHELCOL(NEXTERNAL), GHIDX + INTEGER I, K, D, TGT(NEXTERNAL) + INTEGER GHFILT(0:NCROSS-1) + INTEGER NHSTATE(NEXTERNAL), STATES(MAXHEL,NEXTERNAL) +%(ghfilt_data)s +%(nhstate_data)s +%(states_data)s + IF (GHFILT(CROSS).EQ.0) THEN + GHIDX = 0 + RETURN + ENDIF + DO K=1,NEXTERNAL + TGT(PERM(K)) = SGN(K)*NHELCOL(K) + ENDDO + GHIDX = 0 + DO K=1,NEXTERNAL + DO D=1,NHSTATE(K) + IF (STATES(D,K).EQ.TGT(K)) GOTO 7 + ENDDO + D = 1 + 7 CONTINUE + GHIDX = GHIDX*NHSTATE(K) + (D-1) + ENDDO + GHIDX = GHIDX + 1 + + RETURN + END diff --git a/madgraph/iolibs/template_files/matrix_standalone_f2py.inc b/madgraph/iolibs/template_files/matrix_standalone_f2py.inc index 9f9c015d44..0f37f611bc 100644 --- a/madgraph/iolibs/template_files/matrix_standalone_f2py.inc +++ b/madgraph/iolibs/template_files/matrix_standalone_f2py.inc @@ -225,6 +225,8 @@ C undefined, corrupting memory when the density matrix is written. RETURN END +%(f2py_flav_idx_wrappers)s + LOGICAL FUNCTION PY_%(proc_prefix)sIS_BORN_HEL_SELECTED(HELID) IMPLICIT NONE C diff --git a/madgraph/iolibs/template_files/matrix_standalone_f2py_flav_idx.inc b/madgraph/iolibs/template_files/matrix_standalone_f2py_flav_idx.inc new file mode 100644 index 0000000000..7755e57b1b --- /dev/null +++ b/madgraph/iolibs/template_files/matrix_standalone_f2py_flav_idx.inc @@ -0,0 +1,143 @@ +C ================================================================== +C f2py entry points taking the flavor index (FLAV_IDX) directly. +C +C These exist because the FLAVOR(NEXTERNAL) array cannot express a +C crossing: it holds unsigned group positions and is resolved through +C GET_FLAVOR_INDEX, which only ever returns 1..NFLAV. An *extended* +C FLAV_IDX = cross*NFLAV + flav carries both, so every entry point a +C crossing-aware caller needs must take the index, not the array. +C +C Only emitted for matrix_standalone_v4.inc, the one template that has +C GET_DENSITY_IDX / GET_ALL_INTER_IDX / GET_PDG_FOR_FLAVOR at all: the +C other standalone templates (matchbox, msP/msF, splitOrders) would +C fail to link against routines they never generate. +C ================================================================== + + SUBROUTINE PY_%(proc_prefix)sGET_PDG_FOR_FLAVOR(FLAV_IDX, PDGS) +C Per-leg signed PDG codes of the process FLAV_IDX selects, crossing +C included (legs permuted, and conjugated where they swapped between +C the initial and the final state). +C +C This is what lets a python caller work in PDG codes at all: it can +C enumerate the candidate FLAV_IDX values, ask each one what process +C it evaluates, and keep the one matching the request. All-zero means +C the index names no valid flavor/crossing. + IMPLICIT NONE + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) +CF2PY INTENT(IN) :: FLAV_IDX +CF2PY INTENT(OUT) :: PDGS(NEXTERNAL) + INTEGER FLAV_IDX + INTEGER PDGS(NEXTERNAL) + CALL %(proc_prefix)sGET_PDG_FOR_FLAVOR(FLAV_IDX, PDGS) + RETURN + END + + SUBROUTINE PY_%(proc_prefix)sGET_FLAVOR_LAYOUT(NFLAV_OUT, + & NEXTERNAL_OUT, NCROSS_OUT) +C The three constants a caller needs to build an extended FLAV_IDX at +C all: FLAV_IDX = cross*NFLAV + flav with cross in [0,NCROSS-1], and +C cross = I*(NEXTERNAL+1)+J. Without NFLAV the encoding is simply not +C expressible, and parsing it out of matrix.f (as the tests must) is +C not something a caller should have to do. + IMPLICIT NONE + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER NFLAV + PARAMETER (NFLAV=%(nflav)d) +CF2PY INTENT(OUT) :: NFLAV_OUT +CF2PY INTENT(OUT) :: NEXTERNAL_OUT +CF2PY INTENT(OUT) :: NCROSS_OUT + INTEGER NFLAV_OUT, NEXTERNAL_OUT, NCROSS_OUT + NFLAV_OUT = NFLAV + NEXTERNAL_OUT = NEXTERNAL + NCROSS_OUT = %(ncross)d + RETURN + END + + SUBROUTINE PY_%(proc_prefix)sGET_NHEL_IDX(FLAV_IDX, IDEN_STAR, + & NHEL_STAR) +C Crossing-aware twin of PY_GET_NHEL. +C +C GET_NHEL reports the static IDEN, which is the averaging denominator +C of the *uncrossed* representative flavor only. SMATRIX itself does +C not use it that way -- it divides by IDEN/BROKEN_SYM(FLAVOR) when +C uncrossed and by GET_SPINCOL_CROSS*GET_IDENT_CROSS when crossed -- +C so a caller reading GET_NHEL and reconstructing ANS*IDEN would get a +C wrong answer for any crossed (or merely non-representative) flavor. +C This entry reports the denominator SMATRIX actually applied for this +C FLAV_IDX. GET_NHEL keeps its signature and its meaning for existing +C uncrossed callers. + IMPLICIT NONE + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER NCOMB + PARAMETER ( NCOMB=%(ncomb)d) +CF2PY INTENT(IN) :: FLAV_IDX +CF2PY INTENT(OUT) :: IDEN_STAR +CF2PY INTENT(OUT) :: NHEL_STAR(NEXTERNAL,NCOMB) + INTEGER FLAV_IDX + INTEGER IDEN_STAR + INTEGER NHEL_STAR(NEXTERNAL,NCOMB) + CALL %(proc_prefix)sGET_NHEL_IDX(FLAV_IDX, IDEN_STAR, NHEL_STAR) + RETURN + END + + SUBROUTINE PY_%(proc_prefix)sGET_DENSITY_IDX(P, POS, N_CHANGING, + & ALLOW_HEL, N_COMB, FLAV_IDX, ALPHAS, SCALE2, INTER) +C Density matrix for an extended FLAV_IDX. PY_GET_DENSITY takes the +C FLAVOR array and so can only ever ask for an uncrossed density +C matrix; this is the only way to request a crossed one through f2py. +C Same CF2PY-before-declarations layout and same explicit INTER sizing +C as PY_GET_DENSITY -- see the comments there, both matter. + IMPLICIT NONE +CF2PY double precision, intent(in), dimension(0:3,%(nexternal)d) :: P +CF2PY integer, intent(in), dimension(*) :: POS +CF2PY integer, intent(in) :: N_CHANGING +CF2PY integer, intent(in), dimension(N_CHANGING*N_COMB) :: ALLOW_HEL +CF2PY integer, intent(in) :: N_COMB +CF2PY integer, intent(in) :: FLAV_IDX +CF2PY double precision, intent(in) :: ALPHAS +CF2PY double precision, intent(in) :: SCALE2 +CF2PY double complex, intent(out), dimension(N_COMB*(N_COMB+1)/2) :: INTER + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + REAL*8 P(0:3,NEXTERNAL) + INTEGER N_CHANGING, N_COMB + INTEGER POS(*) + INTEGER ALLOW_HEL(*) + INTEGER FLAV_IDX + DOUBLE PRECISION ALPHAS, SCALE2 + DOUBLE COMPLEX INTER(N_COMB*(N_COMB+1)/2) + CALL %(proc_prefix)sGET_DENSITY_IDX(P, POS, N_CHANGING, ALLOW_HEL, + & N_COMB, FLAV_IDX, ALPHAS, SCALE2, INTER) + RETURN + END + + SUBROUTINE PY_%(proc_prefix)sGET_ALL_INTER_IDX(P, NHEL, POS, + & N_CHANGING, ALLOW_HEL, N_COMB, FLAV_IDX, INTER) +C The un-normalised interference terms behind GET_DENSITY_IDX, for a +C caller supplying its own helicity configuration. Exposed for the +C same reason: its FLAVOR-array twin cannot carry a crossing. + IMPLICIT NONE +CF2PY double precision, intent(in), dimension(0:3,%(nexternal)d) :: P +CF2PY integer, intent(in), dimension(%(nexternal)d) :: NHEL +CF2PY integer, intent(in), dimension(*) :: POS +CF2PY integer, intent(in) :: N_CHANGING +CF2PY integer, intent(in), dimension(N_CHANGING*N_COMB) :: ALLOW_HEL +CF2PY integer, intent(in) :: N_COMB +CF2PY integer, intent(in) :: FLAV_IDX +CF2PY double complex, intent(out), dimension(N_COMB*(N_COMB+1)/2) :: INTER + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + REAL*8 P(0:3,NEXTERNAL) + INTEGER NHEL(NEXTERNAL) + INTEGER N_CHANGING, N_COMB + INTEGER POS(*) + INTEGER ALLOW_HEL(*) + INTEGER FLAV_IDX + DOUBLE COMPLEX INTER(N_COMB*(N_COMB+1)/2) + CALL %(proc_prefix)sGET_ALL_INTER_IDX(P, NHEL, POS, N_CHANGING, + & ALLOW_HEL, N_COMB, FLAV_IDX, INTER) + RETURN + END diff --git a/madgraph/iolibs/template_files/matrix_standalone_hel_orig_v4.inc b/madgraph/iolibs/template_files/matrix_standalone_hel_orig_v4.inc new file mode 100644 index 0000000000..77eeaebd53 --- /dev/null +++ b/madgraph/iolibs/template_files/matrix_standalone_hel_orig_v4.inc @@ -0,0 +1,156 @@ +C Standalone helicity-recycling ORIG matrix element. +C This file is NOT the final code: it is the input parsed by +C madgraph/madevent/hel_recycle.py, which rewrites the MATRIX function +C body (external/internal wavefunctions, amplitudes and the JAMP sum) into +C the recycled form and injects it into the standalone _hel driver template +C via the ${helas_calls} placeholder. The structure below therefore +C mirrors the madevent single-MATRIX layout the rewriter expects: +C AMP(:) = 0 -> helas calls -> JAMP(:) = 0 -> jamp lines -> +C 'if(init_mode)' terminator -> color sum, +C plus the helicity (NHEL) table the rewriter reads. +C +C The helicity table MUST come first: hel_recycle reads it to set the +C per-leg helicity ranges before it parses the wavefunction calls. + BLOCK DATA %(proc_prefix)sNHEL_DATA + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER NCOMB + PARAMETER ( NCOMB=%(ncomb)d) + INTEGER NHEL(NEXTERNAL,NCOMB) + COMMON/%(proc_prefix)sHEL_TABLE/NHEL +%(helicity_lines)s + END +C + DOUBLE PRECISION FUNCTION %(proc_prefix)sMATRIX(P, NHEL, IC, FLAV_IDX) + use model_object + use aloha_object +C +%(process_lines)s +C + IMPLICIT NONE +C +C CONSTANTS +C + INTEGER NGRAPHS + PARAMETER (NGRAPHS=%(ngraphs)d) + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER NWAVEFUNCS, NCOLOR + PARAMETER (NWAVEFUNCS=%(nwavefuncs)d, NCOLOR=%(ncolor)d) + INTEGER NCOMB + PARAMETER ( NCOMB=%(ncomb)d) + REAL*8 ZERO + PARAMETER (ZERO=0D0) + COMPLEX*16 IMAG1 + PARAMETER (IMAG1=(0D0,1D0)) +C +C ARGUMENTS +C + REAL*8 P(0:3,NEXTERNAL) +C P, NHEL and IC must ALREADY be crossed (the warm-up applies the crossing +C once per point, as SMATRIX does), and FLAV_IDX must ALREADY be reduced to +C 1..NFLAV. FLAVOR(NEXTERNAL) is rebuilt from it by the flavor block below, +C exactly as in the standard GET_AMP. + INTEGER NHEL(NEXTERNAL), IC(NEXTERNAL) + INTEGER FLAV_IDX + INTEGER FLAVOR(NEXTERNAL) +C +C LOCAL VARIABLES +C + INTEGER I,J + COMPLEX*16 ZTEMP + INTEGER %(proc_prefix)sCF(%(ncolortriang)d) + INTEGER %(proc_prefix)sDENOM, CF_INDEX +C AMP is dimensioned exactly as in the standard GET_JAMP: when the color +C flow definitions are emitted as operand tables rather than written out, +C their temporaries live past NGRAPHS in this same array (NGRAPHS+154530 +C against NGRAPHS 126630 for g g > 6g) and jamp_decl declares the tables +C that index them. Hard-coding AMP(NGRAPHS) and a TMP_JAMP here instead +C both overran the array and left ITMP/ILEV undeclared, which is what made +C the warm-up fail to compile. + COMPLEX*16 AMP(%(namp_dim)s), JAMP(NCOLOR) +%(jamp_tmp_decl)s +%(jamp_decl)s + TYPE(ALOHA) W(NWAVEFUNCS) + COMPLEX*16 DUM0,DUM1 + DATA DUM0, DUM1/(0d0, 0d0), (1d0, 0d0)/ + double precision bwcutoff +C Warm-up instrumentation (only active when init_mode is .true., set by the +C hel_warmup driver). ZEROAMP(ihel,i) stays .true. while amplitude i has been +C zero for helicity ihel over every sampled phase-space point; CUR_IHEL is the +C helicity index the driver is currently evaluating. + logical init_mode + common/%(proc_prefix)sto_determine_zero_hel/init_mode + logical %(proc_prefix)sZEROAMP(NCOMB, NGRAPHS) + common/%(proc_prefix)sto_zeroamp/%(proc_prefix)sZEROAMP + integer %(proc_prefix)sCUR_IHEL + common/%(proc_prefix)sto_cur_ihel/%(proc_prefix)sCUR_IHEL +C +C GLOBAL VARIABLES +C + common/%(proc_prefix)scolor_matrix/%(proc_prefix)sCF,%(proc_prefix)sDENOM + include 'coupl.inc' +%(global_variable)s +C +C COLOR DATA +C +%(color_data_lines)s +C Per-flavor amplitude/wavefunction mask plus the FLAV_IDX -> FLAVOR +C rebuild, identical to the standard GET_AMP: the recycled calls carry the +C same IAND(CURRENT_*_MASK,...) guards. +%(flavor_mask_decl)s +C ---------- +C BEGIN CODE +C ---------- + bwcutoff=15 +C See the same call in matrix_standalone_hel_v4.inc: past a size the color +C matrix is rebuilt at run time instead of written out as DATA, and this +C routine sums over CF itself rather than calling GET_MATRIX. Without the +C call CF is all zeros, every |M|^2 is 0, and the warm-up then measures no +C good helicities at all -- which it reports by writing nothing, so the +C output silently falls back to the compute-all matrix.f. + CALL %(proc_prefix)sINIT_CF() + AMP(:) = (0D0,0D0) +%(flavor_mask_setup)s +%(helas_calls)s + + JAMP(:) = (0D0,0D0) +%(jamp_lines)s + + if(init_mode)then + DO I=1, NGRAPHS + if (AMP(I).ne.0) then + %(proc_prefix)sZEROAMP(%(proc_prefix)sCUR_IHEL, I) = .false. + endif + ENDDO + endif + + %(proc_prefix)sMATRIX = 0.D0 + CF_INDEX = 0 + DO I = 1, NCOLOR + ZTEMP = (0.D0,0.D0) + DO J = I, NCOLOR + CF_INDEX = CF_INDEX + 1 + ZTEMP = ZTEMP + %(proc_prefix)sCF(CF_INDEX)*JAMP(J) + ENDDO + %(proc_prefix)sMATRIX = %(proc_prefix)sMATRIX + & + ZTEMP*DCONJG(JAMP(I)) + ENDDO + %(proc_prefix)sMATRIX = %(proc_prefix)sMATRIX/%(proc_prefix)sDENOM + END + + +C The color matrix rebuild, for the same reason: the warm-up probe links +C matrix_orig.f alone, so it cannot borrow the copy that the recycled +C matrix.f gets from the appended standard routines. Both are empty when +C the entries were written out as DATA instead. +%(color_init_routine)s + +%(jamp_init_routine)s + +C Crossing machinery, also emitted here so the warm-up probe (which links +C matrix_orig.f alone) can enumerate the crossings and measure the good +C helicities of each. Empty when the process was generated without crossing +C symmetry. matrix_orig.f and the recycled matrix.f are never linked +C together, so the duplicate definitions never clash. +%(crossing_routines)s diff --git a/madgraph/iolibs/template_files/matrix_standalone_hel_v4.inc b/madgraph/iolibs/template_files/matrix_standalone_hel_v4.inc new file mode 100644 index 0000000000..72afb05611 --- /dev/null +++ b/madgraph/iolibs/template_files/matrix_standalone_hel_v4.inc @@ -0,0 +1,310 @@ +C Standalone helicity-recycling driver template. +C The percent-paren placeholders are filled at generation time by the +C standalone exporter; the dollar-brace placeholders (helas_calls, +C helicity_lines, ncomb, nwavefuncs, csym_reuse, csym_dead) are filled by +C hel_recycle.py when it rewrites the MATRIX body into the recycled form +C (shared wavefunctions + P1N amplitude split). The rewriter's jamp_lines +C are deliberately NOT used: the color flows come from the shared GET_JAMP +C instead, see the color stage of MATRIX below. +C +C NCOMB below is the RECYCLED count (only the good helicity combinations +C survive); NHEL(0,K) carries the original helicity id of row K, which is +C what the polarization filter and SMATRIXHEL select on. + SUBROUTINE %(proc_prefix)sSMATRIXHEL(P, HEL, FLAV_IDX, ANS) +C Matrix element restricted to a single helicity configuration, rescaled by +C HELAVGFACTOR exactly like the standard standalone. HEL is the CANONICAL +C mixed-radix helicity code (what the standard SMATRIX compares against +C HELALLOW), not a row index: NHEL(0,K) gives the row K had in the full +C pre-recycling enumeration, and HELALLOW turns that row into its code. The +C two coincide for an unpolarized process (HELALLOW is then 1..NCOMBFULL) +C but not when a polarization restriction selects a subset of the codes. + IMPLICIT NONE + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER NCOMB + PARAMETER ( NCOMB=${ncomb}) +C Number of helicity rows BEFORE recycling (the allowed-code list length). + INTEGER NCOMBFULL + PARAMETER (NCOMBFULL=%(ncomb)d) + INTEGER HELAVGFACTOR + PARAMETER (HELAVGFACTOR=%(hel_avg_factor)d) + INTEGER NFLAV + PARAMETER (NFLAV=%(nflav)d) +CF2PY INTENT(OUT) :: ANS +CF2PY INTENT(IN) :: HEL +CF2PY INTENT(IN) :: P(0:3,NEXTERNAL) +CF2PY INTENT(IN) :: FLAV_IDX + REAL*8 P(0:3,NEXTERNAL), ANS + INTEGER HEL, FLAV_IDX + REAL*8 TS(NCOMB) +C I is the implied-DO variable of the recycled NHEL DATA statements below. + INTEGER I, K, IDEN + INTEGER JC(NEXTERNAL) + INTEGER FLAVOR(NEXTERNAL) + INTEGER %(proc_prefix)sBROKEN_SYM + INTEGER NHEL(0:NEXTERNAL,NCOMB) + INTEGER HELALLOW(NCOMBFULL) +${helicity_lines} +%(hel_allow_data)s +%(den_factor_line)s +C ---------- +C BEGIN CODE +C ---------- + ANS = 0D0 +%(hr_helcheck)s + CALL %(proc_prefix)sGET_FLAVOR(FLAV_IDX, FLAVOR) + DO K = 1, NEXTERNAL + JC(K) = +1 + ENDDO + CALL %(proc_prefix)sMATRIX(P, JC, FLAV_IDX, TS) + DO K = 1, NCOMB + IF (HELALLOW(NHEL(0,K)).EQ.HEL) ANS = ANS + TS(K) + ENDDO + ANS = ANS / DBLE(IDEN) * %(proc_prefix)sBROKEN_SYM(FLAVOR) + ANS = ANS * HELAVGFACTOR + END + + + SUBROUTINE %(proc_prefix)sSMATRIX(P, FLAV_IDX, ANS) +C +%(process_lines)s +C +C MadGraph5_aMC@NLO StandAlone Version - HELICITY RECYCLING +C +C Returns amplitude squared summed/avg over colors and helicities +c for the point in phase space P(0:3,NEXTERNAL). +C + IMPLICIT NONE + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER NINITIAL + PARAMETER (NINITIAL=%(nincoming)d) + INTEGER NPOLENTRIES + PARAMETER (NPOLENTRIES=(NEXTERNAL+1)*6) + INTEGER NCOMB + PARAMETER ( NCOMB=${ncomb}) + INTEGER NFLAV + PARAMETER (NFLAV=%(nflav)d) + REAL*8 P(0:3,NEXTERNAL), ANS + INTEGER FLAV_IDX +CF2PY INTENT(OUT) :: ANS +CF2PY INTENT(IN) :: P(0:3,NEXTERNAL) +CF2PY INTENT(IN) :: FLAV_IDX + REAL*8 TS(NCOMB) +C I is the implied-DO variable of the recycled NHEL DATA statements below. + INTEGER I, K, L, M, IDEN + LOGICAL SELECTED, FOUNDIT + INTEGER JC(NEXTERNAL) +C Reduced flavor index: FLAV_IDX with its crossing part stripped. + INTEGER FLAV_USE + INTEGER FLAVOR(NEXTERNAL) + INTEGER %(proc_prefix)sBROKEN_SYM +%(hr_cross_decl)s +C For a 1>N process BEAMTWO_HELAVGFACTOR would be set to 1. + INTEGER BEAMS_HELAVGFACTOR(2) + DATA (BEAMS_HELAVGFACTOR(K),K=1,2)/%(beamone_helavgfactor)d,%(beamtwo_helavgfactor)d/ + INTEGER NHEL(0:NEXTERNAL,NCOMB) +${helicity_lines} +%(den_factor_line)s + INTEGER POLARIZATIONS(0:NEXTERNAL,0:5) + COMMON/%(proc_prefix)sBORN_BEAM_POL/POLARIZATIONS + DATA ((POLARIZATIONS(K,L),K=0,NEXTERNAL),L=0,5)/NPOLENTRIES*-1/ +C ---------- +C BEGIN CODE +C ---------- +C FLAV_IDX out of range means the requested flavor is not an allowed +C combination: its matrix element is identically zero. + ANS = 0D0 +%(hr_cross_decode)s + CALL %(proc_prefix)sGET_FLAVOR(FLAV_USE, FLAVOR) + DO K = 1, NEXTERNAL + JC(K) = +1 + ENDDO +%(hr_cross_apply)s +C The recycled MATRIX returns the color-summed |M|^2 for every (good) +C helicity combination at once in TS; the dead combinations were dropped at +C generation time, so there is no runtime good-helicity filter here. Under a +C crossing the baked helicity rows play the role of the CROSSED +C configurations, which is why the warm-up measures the good rows of every +C crossing and the recycled table is their union. +%(hr_matrix_call)s + DO K = 1, NCOMB +C Beam polarization: keep only the rows whose per-leg helicities are in +C the requested set (same test as IS_BORN_HEL_SELECTED, evaluated on the +C recycled table). + IF (POLARIZATIONS(0,0).NE.-1) THEN + SELECTED = .TRUE. + DO L = 1, NEXTERNAL + IF (POLARIZATIONS(L,0).EQ.-1) CYCLE + FOUNDIT = .FALSE. + DO M = 1, POLARIZATIONS(L,0) + IF (NHEL(L,K).EQ.POLARIZATIONS(L,M)) THEN + FOUNDIT = .TRUE. + EXIT + ENDIF + ENDDO + IF (.NOT.FOUNDIT) THEN + SELECTED = .FALSE. + EXIT + ENDIF + ENDDO + IF (.NOT.SELECTED) CYCLE + ENDIF + ANS = ANS + TS(K) + ENDDO +%(hr_iden_line)s + DO L = 1, NINITIAL + IF (POLARIZATIONS(L,0).NE.-1) THEN + ANS = ANS * BEAMS_HELAVGFACTOR(L) + ANS = ANS / POLARIZATIONS(L,0) + ENDIF + ENDDO + END + + + SUBROUTINE %(proc_prefix)sMATRIX(P, IC, FLAV_IDX, TS) + use model_object + use aloha_object +C +%(process_lines)s +C + IMPLICIT NONE +C +C CONSTANTS +C + INTEGER NGRAPHS + PARAMETER (NGRAPHS=%(ngraphs)d) + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER NWAVEFUNCS, NCOLOR + PARAMETER (NWAVEFUNCS=${nwavefuncs}, NCOLOR=%(ncolor)d) + INTEGER NCOMB + PARAMETER ( NCOMB=${ncomb}) + REAL*8 ZERO + PARAMETER (ZERO=0D0) + COMPLEX*16 IMAG1 + PARAMETER (IMAG1=(0D0,1D0)) +C +C ARGUMENTS +C +C P and IC must ALREADY be crossed and FLAV_IDX ALREADY reduced to +C 1..NFLAV: SMATRIX applies the crossing once, above. + REAL*8 P(0:3,NEXTERNAL) + INTEGER IC(NEXTERNAL) + INTEGER FLAV_IDX + REAL*8 TS(NCOMB) +C +C LOCAL VARIABLES +C + INTEGER I,J,K,KK +C Raw storage handed to the P1N split-amplitude calls as a type(aloha) +C scratch wavefunction (see CombineAmp in the DHELAS library). + COMPLEX*16 TMP(%(wavefunctionsize)d) +C AMP is HELICITY MAJOR because that is the layout the recycled helas +C block writes: one CombineAmp call fills the same amplitude for a whole +C set of helicity rows at once, so a column of AMP is what it produces. +C It is exactly the wrong layout to READ from, which is why the color +C stage gathers rows out of it (into AMPK) rather than indexing it. +C Only NGRAPHS wide: the color flow temporaries used to be written past +C NGRAPHS in here, and now live in the gather buffer instead. +C +C On the heap, not in the executable. Helicity major makes it NCOMB times +C the standard AMP -- 519 MB for g g > 6g even at this width -- and a +C fixed-size local that size is linked into __DATA. Together with the +C 825 MB of __TEXT that 3.9M recycled call sites produce, the image runs +C up against the arm64 dyld shared region, and past it the binary does not +C START: dyld fails to map its cache and blames the first framework it +C then cannot find. Allocating keeps __DATA at tens of MB whatever the +C multiplicity, and costs nothing measurable (g g > 5g steady state +C 0.0219/0.0227 s allocated against 0.0217/0.0218 s linked in). + COMPLEX*16, ALLOCATABLE, SAVE :: AMP(:,:) + type(aloha) W(NWAVEFUNCS) + COMPLEX*16 DUM0,DUM1 + DATA DUM0, DUM1/(0d0, 0d0), (1d0, 0d0)/ + double precision bwcutoff + INTEGER FLAVOR(NEXTERNAL) +C The rows whose helas calls were actually generated. A C-parity partner +C dropped by the warm-up has none, so its whole AMP row is zero and the +C color stage would run a full sum over zeros; HRROW lists the rows worth +C visiting and csym_reuse copies the |M|^2 back into the rest. Built once, +C from the marks below (none = every row is live). + LOGICAL HRDEAD(NCOMB) + INTEGER HRROW(NCOMB), NHRROW + SAVE HRDEAD, HRROW, NHRROW + DATA NHRROW/0/ +C The color matrix itself is never read here -- the color sum is done by +C the shared GET_MATRIX / GET_MATRIX_BATCHV. What this routine still owns +C is where the common block is FILLED: below a size the entries (and DENOM, +C always) are written out as DATA, and the standard output puts that DATA in +C its MATRIX for the same reason. Drop it and DENOM is 0, which turns every +C |M|^2 into a NaN rather than into a zero. + INTEGER %(proc_prefix)sCF(%(ncolortriang)d) + INTEGER %(proc_prefix)sDENOM + common/%(proc_prefix)scolor_matrix/%(proc_prefix)sCF,%(proc_prefix)sDENOM +%(hr_color_decl)s +C Recycled helicity table (NHEL(0,k) is the original helicity id). + INTEGER NHEL(0:NEXTERNAL,NCOMB) +${helicity_lines} +C +C GLOBAL VARIABLES +C + include 'coupl.inc' +%(global_variable)s +C +C COLOR DATA +C +%(color_data_lines)s +C Per-flavor amplitude/wavefunction mask plus the FLAV_IDX -> FLAVOR +C rebuild. The mask is helicity independent, so it is resolved once here +C and the recycled calls carry the IAND(...) guards. +%(flavor_mask_decl)s +C ---------- +C BEGIN CODE +C ---------- + bwcutoff=15 + IF (.NOT.ALLOCATED(AMP)) ALLOCATE(AMP(NCOMB,NGRAPHS)) + IF (NHRROW.EQ.0) THEN + DO K = 1, NCOMB + HRDEAD(K) = .FALSE. + ENDDO +${csym_dead} + DO K = 1, NCOMB + IF (.NOT.HRDEAD(K)) THEN + NHRROW = NHRROW + 1 + HRROW(NHRROW) = K + ENDIF + ENDDO + ENDIF + AMP(:,:) = (0D0,0D0) +%(flavor_mask_setup)s +${helas_calls} +C END OF RECYCLED HELAS BLOCK +C That marker is where hel_recycle.split_helas_block cuts when it moves the +C block into chunk subroutines, so it has to stay right after the calls. +C The madevent templates need no marker: there the color flows follow the +C calls directly, and the rewriter cuts at those instead. +C +C Color stage. Everything below is the SHARED per-helicity code -- the +C same GET_JAMP the standard output calls, and the same color sum (folded, +C and batched through BLAS when it is worth it) -- so the recycled build +C inherits whatever the color side gains instead of carrying its own copy. +%(hr_color_sum)s +C C-parity de-duplication: a dropped partner's HELAS calls were never +C generated, so its TS() is 0 here; copy the representative's identical +C |M|^2 back into it (empty unless the warm-up validated the pairing). +${csym_reuse} + END + + +C Everything below this point -- the per-helicity GET_AMP/GET_JAMP, the +C density and interference stack, GET_value, the helicity encoder/decoder, +C the crossing routines and the flavor/BROKEN_SYM helpers -- is appended +C verbatim from matrix_standalone_v4.inc by _write_hel_recycling_matrix, so +C the recycled output offers exactly the same entry points as the standard +C one and the two cannot drift apart. +C +C Only SMATRIX/SMATRIXHEL take the recycled path. The density machinery +C evaluates arbitrary helicity configurations (GET_ALL_INTER_CROSSED +C substitutes helicities at the POS slots), which the recycled table cannot +C serve: its rows are baked at generation time and the dead ones dropped. +C It therefore keeps using the plain per-helicity GET_AMP. diff --git a/madgraph/iolibs/template_files/matrix_standalone_matchbox.inc b/madgraph/iolibs/template_files/matrix_standalone_matchbox.inc index 48aa4fac51..3916769b64 100644 --- a/madgraph/iolibs/template_files/matrix_standalone_matchbox.inc +++ b/madgraph/iolibs/template_files/matrix_standalone_matchbox.inc @@ -229,7 +229,7 @@ C are zero. INTEGER NCOLOR, NJAMP PARAMETER (NCOLOR=%(ncolor)d) - COMPLEX*16 JAMP(NCOLOR), ONEJAMP + COMPLEX*16 JAMP(NCOLOR), LNJAMP(NCOLOR), ONEJAMP COMMON/%(proc_prefix)sJAMP/JAMP,LNJAMP ONEJAMP = JAMP(njamp+1) ! +1 since njamp start at zero (c convention) diff --git a/madgraph/iolibs/template_files/matrix_standalone_matchbox_splitOrders_v4.inc b/madgraph/iolibs/template_files/matrix_standalone_matchbox_splitOrders_v4.inc index 02d6fd6a1c..a838edc8c6 100644 --- a/madgraph/iolibs/template_files/matrix_standalone_matchbox_splitOrders_v4.inc +++ b/madgraph/iolibs/template_files/matrix_standalone_matchbox_splitOrders_v4.inc @@ -1,19 +1,23 @@ - SUBROUTINE %(proc_prefix)sSMATRIX(P,ANS_SUMMED) + SUBROUTINE %(proc_prefix)sSMATRIX(P, FLAV_IDX, ANS_SUMMED) C C Simple routine wrapper to provide the same interface for C backward compatibility for usage without split orders. C -C +C C CONSTANTS -C +C INTEGER NEXTERNAL PARAMETER (NEXTERNAL=%(nexternal)d) INTEGER NSQAMPSO PARAMETER (NSQAMPSO=%(nSqAmpSplitOrders)d) -C -C ARGUMENTS -C +C +C ARGUMENTS +C REAL*8 P(0:3,NEXTERNAL), ANS_SUMMED +C FLAV_IDX selects one column of the flavor table, i.e. one of the +C flavor combinations this (possibly merged) matrix element covers. +C The canonical flavor is column 1. + INTEGER FLAV_IDX C C VARIABLES C @@ -22,25 +26,26 @@ C C C BEGIN CODE C - CALL %(proc_prefix)sSMATRIX_SPLITORDERS(P,ANS) + CALL %(proc_prefix)sSMATRIX_SPLITORDERS(P, FLAV_IDX, ANS) ANS_SUMMED=ANS(0) END - SUBROUTINE %(proc_prefix)sSMATRIXHEL(P,HEL,ANS) + SUBROUTINE %(proc_prefix)sSMATRIXHEL(P,HEL, FLAV_IDX, ANS) IMPLICIT NONE C C CONSTANT C INTEGER NEXTERNAL PARAMETER (NEXTERNAL=%(nexternal)d) - INTEGER NCOMB + INTEGER NCOMB PARAMETER ( NCOMB=%(ncomb)d) -C -C ARGUMENTS -C +C +C ARGUMENTS +C REAL*8 P(0:3,NEXTERNAL),ANS INTEGER HEL + INTEGER FLAV_IDX C C GLOBAL VARIABLES C @@ -50,12 +55,12 @@ C ---------- C BEGIN CODE C ---------- USERHEL=HEL - CALL %(proc_prefix)sSMATRIX(P,ANS) + CALL %(proc_prefix)sSMATRIX(P, FLAV_IDX, ANS) USERHEL=-1 END - SUBROUTINE %(proc_prefix)sSMATRIX_SPLITORDERS(P,ANS) + SUBROUTINE %(proc_prefix)sSMATRIX_SPLITORDERS(P, FLAV_IDX, ANS) C %(info_lines)s C @@ -86,53 +91,82 @@ C C ARGUMENTS C REAL*8 P(0:3,NEXTERNAL),ANS(0:NSQAMPSO) -C -C LOCAL VARIABLES -C - INTEGER NHEL(NEXTERNAL,NCOMB),NTRY +C FLAV_IDX (the dummy argument) is declared with NFLAV below. +C +C LOCAL VARIABLES +C + INTEGER NHEL(NEXTERNAL,NCOMB) REAL*8 T(NSQAMPSO), BUFF INTEGER IHEL,IDEN, I INTEGER JC(NEXTERNAL) - LOGICAL GOODHEL(NCOMB) - DATA NTRY/0/ - DATA GOODHEL/NCOMB*.FALSE./ +C One good-helicity table and one warm-up counter per flavor: the +C helicity configurations that vanish depend on the flavor (masses and +C the diagrams the flavor mask keeps), so they cannot be shared. + INTEGER FLAVOR(NEXTERNAL) + INTEGER NFLAV + PARAMETER (NFLAV=%(nflav)d) + INTEGER NNTRY_FLAV, NGOODHEL_FLAV + PARAMETER (NNTRY_FLAV=NFLAV) + PARAMETER (NGOODHEL_FLAV=NCOMB*NFLAV) + INTEGER FLAV_IDX + INTEGER NTRY(NFLAV) + LOGICAL GOODHEL(NCOMB,NFLAV) + DATA NTRY/NNTRY_FLAV*0/ + DATA GOODHEL/NGOODHEL_FLAV*.FALSE./ %(helicity_lines)s %(den_factor_line)s C +C FUNCTIONS +C + INTEGER %(proc_prefix)sBROKEN_SYM +C C GLOBAL VARIABLES C INTEGER USERHEL - DATA USERHEL/-1/ + DATA USERHEL/-1/ COMMON/%(proc_prefix)sHELUSERCHOICE/USERHEL C ---------- C BEGIN CODE C ---------- - NTRY=NTRY+1 +C FLAV_IDX=0 (or out of range): GET_FLAVOR_INDEX could not resolve the +C requested flavor -- it is not an allowed combination, so its matrix +C element is identically zero. Short-circuit before touching the +C 1..NFLAV GOODHEL/NTRY arrays. + IF (FLAV_IDX.LT.1 .OR. FLAV_IDX.GT.NFLAV) THEN + DO I=0,NSQAMPSO + ANS(I) = 0D0 + ENDDO + RETURN + ENDIF + CALL %(proc_prefix)sGET_FLAVOR(FLAV_IDX, FLAVOR) + NTRY(FLAV_IDX)=NTRY(FLAV_IDX)+1 DO IHEL=1,NEXTERNAL JC(IHEL) = +1 ENDDO DO I=1,NSQAMPSO - ANS(I) = 0D0 + ANS(I) = 0D0 ENDDO DO IHEL=1,NCOMB IF (USERHEL.EQ.-1.OR.USERHEL.EQ.IHEL) THEN - IF (GOODHEL(IHEL) .OR. NTRY .LT. 2) THEN - CALL %(proc_prefix)sMATRIX(P ,NHEL(1,IHEL),JC(1), T) + IF (GOODHEL(IHEL,FLAV_IDX) .OR. NTRY(FLAV_IDX) .LT. 2) THEN + CALL %(proc_prefix)sMATRIX(P ,NHEL(1,IHEL),JC(1), FLAV_IDX, T) BUFF=0D0 - DO I=1,NSQAMPSO + DO I=1,NSQAMPSO ANS(I)=ANS(I)+T(I) BUFF=BUFF+T(I) ENDDO - IF (BUFF .NE. 0D0 .AND. .NOT. GOODHEL(IHEL)) THEN - GOODHEL(IHEL)=.TRUE. + IF (BUFF .NE. 0D0 .AND. .NOT. GOODHEL(IHEL,FLAV_IDX)) THEN + GOODHEL(IHEL,FLAV_IDX)=.TRUE. ENDIF ENDIF ENDIF ENDDO ANS(0)=0.0d0 DO I=1,NSQAMPSO - ANS(I)=ANS(I)/DBLE(IDEN) +C IDEN averages the canonical flavor; BROKEN_SYM undoes the identical +C -particle part of it for the flavors that are not actually identical. + ANS(I)=ANS(I)/DBLE(IDEN)*%(proc_prefix)sBROKEN_SYM(FLAVOR) IF (CHOSEN_SO_CONFIGS(I)) THEN ANS(0)=ANS(0)+ANS(I) ENDIF @@ -145,22 +179,23 @@ C ---------- ENDIF END - SUBROUTINE %(proc_prefix)sSMATRIXHEL_SPLITORDERS(P,HEL,ANS) + SUBROUTINE %(proc_prefix)sSMATRIXHEL_SPLITORDERS(P,HEL, FLAV_IDX, ANS) IMPLICIT NONE C C CONSTANT C INTEGER NEXTERNAL PARAMETER (NEXTERNAL=%(nexternal)d) - INTEGER NCOMB + INTEGER NCOMB PARAMETER ( NCOMB=%(ncomb)d) INTEGER NSQAMPSO PARAMETER (NSQAMPSO=%(nSqAmpSplitOrders)d) -C -C ARGUMENTS -C +C +C ARGUMENTS +C REAL*8 P(0:3,NEXTERNAL),ANS(0:NSQAMPSO) INTEGER HEL + INTEGER FLAV_IDX C C GLOBAL VARIABLES C @@ -170,12 +205,13 @@ C ---------- C BEGIN CODE C ---------- USERHEL=HEL - CALL %(proc_prefix)sSMATRIX_SPLITORDERS(P,ANS) + CALL %(proc_prefix)sSMATRIX_SPLITORDERS(P, FLAV_IDX, ANS) USERHEL=-1 END - - SUBROUTINE %(proc_prefix)sMATRIX(P,NHEL,IC,RES) + + SUBROUTINE %(proc_prefix)sMATRIX(P,NHEL,IC,FLAV_IDX,RES) + use model_object C %(info_lines)s C @@ -184,6 +220,7 @@ c for the point with external lines W(0:6,NEXTERNAL) C %(process_lines)s C + use aloha_object IMPLICIT NONE C C CONSTANTS @@ -205,10 +242,14 @@ C ARGUMENTS C REAL*8 P(0:3,NEXTERNAL) INTEGER NHEL(NEXTERNAL), IC(NEXTERNAL) +C The HELAS calls below take FLAVOR, which the block that follows +C rebuilds from FLAV_IDX -- one column of the flavor table, i.e. one of +C the flavor combinations this (possibly merged) matrix element covers. + INTEGER FLAV_IDX REAL*8 RES(NSQAMPSO) -C -C LOCAL VARIABLES -C +C +C LOCAL VARIABLES +C INTEGER I,J,M,N COMPLEX*16 ZTEMP INTEGER CF(NCOLOR*(NCOLOR+1)) @@ -216,22 +257,23 @@ C COMPLEX*16 AMP(NGRAPHS) COMPLEX*16 JAMP(NCOLOR,NAMPSO), LNJAMP(NCOLOR,NAMPSO) COMPLEX*16 TMP_JAMP(%(nb_temp_jamp)i) - COMMON/%(proc_prefix)sJAMP/JAMP,LNJAMP - COMPLEX*16 W(18,NWAVEFUNCS) + COMMON/%(proc_prefix)sJAMP/JAMP,LNJAMP + type(aloha) W(NWAVEFUNCS) COMPLEX*16 DUM0,DUM1 DATA DUM0, DUM1/(0d0, 0d0), (1d0, 0d0)/ + INTEGER FLAVOR(NEXTERNAL) %(flavor_mask_decl)s C C FUNCTION C INTEGER %(proc_prefix)sSQSOINDEX -C +C C GLOBAL VARIABLES -C +C include 'coupl.inc' -C +C C COLOR DATA -C +C %(color_data_lines)s C ---------- C BEGIN CODE @@ -288,7 +330,8 @@ C ---------- - SUBROUTINE %(proc_prefix)sBORN(P,NHEL) + SUBROUTINE %(proc_prefix)sBORN(P,NHEL,FLAVOR) + use model_object C %(info_lines)s C @@ -297,6 +340,7 @@ c for the point with external lines W(0:6,NEXTERNAL) C %(process_lines)s C + use aloha_object IMPLICIT NONE C C CONSTANTS @@ -319,37 +363,58 @@ C C ARGUMENTS C REAL*8 P(0:3,NEXTERNAL) - INTEGER NHEL(NEXTERNAL), IC(NEXTERNAL) -C -C LOCAL VARIABLES -C + INTEGER NHEL(NEXTERNAL) +C FLAVOR carries the per-leg flavor of the requested combination, in the +C same convention as the non-split BORN of matrix_standalone_matchbox.inc. +C It is resolved to a flavor-table column below. + INTEGER FLAVOR(NEXTERNAL) +C +C LOCAL VARIABLES +C + INTEGER IC(NEXTERNAL) INTEGER I,J,M,N COMPLEX*16 ZTEMP -c REAL*8 CF(NCOLOR,NCOLOR) + INTEGER CF(NCOLOR*(NCOLOR+1)/2) + INTEGER CF_INDEX, DENOM COMPLEX*16 AMP(NGRAPHS) COMPLEX*16 JAMP(NCOLOR,NAMPSO), LNJAMP(NCOLOR,NAMPSO) + COMPLEX*16 TMP_JAMP(%(nb_temp_jamp)i) COMMON/%(proc_prefix)sJAMP/JAMP,LNJAMP - COMPLEX*16 W(18,NWAVEFUNCS) + type(aloha) W(NWAVEFUNCS) COMPLEX*16 DUM0,DUM1 DATA DUM0, DUM1/(0d0, 0d0), (1d0, 0d0)/ + INTEGER FLAV_IDX + INTEGER %(proc_prefix)sGET_FLAVOR_INDEX %(flavor_mask_decl)s -C +C C GLOBAL VARIABLES -C +C include 'coupl.inc' -C +C C COLOR DATA -C +C %(color_data_lines)s C ---------- C BEGIN CODE C ---------- - DO I=1,NEXTERNAL + DO I=1,NEXTERNAL IC(I) = 1 - ENDDO + ENDDO + FLAV_IDX = %(proc_prefix)sGET_FLAVOR_INDEX(FLAVOR) +C Unresolved flavor (not an allowed combination): all color amplitudes +C are zero. + IF (FLAV_IDX.EQ.0) THEN + DO M = 1, NAMPSO + DO I = 1, NCOLOR + JAMP(I,M) = (0D0, 0D0) + LNJAMP(I,M) = (0D0, 0D0) + ENDDO + ENDDO + RETURN + ENDIF %(flavor_mask_setup)s %(helas_calls)s %(jamp_lines)s @@ -412,4 +477,13 @@ C ---------- out = CHOSEN_SO_CONFIGS(%(proc_prefix)sSQSOINDEX(M,N)) return end + + +%(broken_sym_function)s + + +%(flavor_index_function)s + + +%(flavor_array_function)s diff --git a/madgraph/iolibs/template_files/matrix_standalone_splitOrders_v4.inc b/madgraph/iolibs/template_files/matrix_standalone_splitOrders_v4.inc index c2806387d6..1f176c9b98 100644 --- a/madgraph/iolibs/template_files/matrix_standalone_splitOrders_v4.inc +++ b/madgraph/iolibs/template_files/matrix_standalone_splitOrders_v4.inc @@ -668,6 +668,8 @@ c INTEGER I,J,SOL,N INTEGER FLAV_IDX INTEGER %(proc_prefix)sGET_FLAVOR_INDEX + INTEGER %(proc_prefix)sBROKEN_SYM + DOUBLE PRECISION RESCALE C ---------- C BEGIN CODE @@ -689,11 +691,17 @@ C ---------- call %(proc_prefix)sGET_JAMP(AMP,JAMP(1,1,I)) enddo +C GET_INTER only sees JAMPs, so it normalises with the bare static IDEN +C and cannot apply any flavor dependent factor. SMATRIX does +C ANS/IDEN*BROKEN_SYM(FLAVOR); the density matrix must use the same +C normalisation or the sum of its diagonal stops matching SMATRIX. + RESCALE = DBLE(%(proc_prefix)sBROKEN_SYM(FLAVOR)) SOL = 0 DO I = 1, N_COMB DO J= I, N_COMB SOL = SOL +1 call %(proc_prefix)sGET_INTER(JAMP(1,1,I), JAMP(1,1,J), INTER(1,SOL)) + INTER(:,SOL) = INTER(:,SOL) * RESCALE ENDDO ENDDO diff --git a/madgraph/iolibs/template_files/matrix_standalone_v4.inc b/madgraph/iolibs/template_files/matrix_standalone_v4.inc index ada2fd0eb1..9eb2bfd77f 100644 --- a/madgraph/iolibs/template_files/matrix_standalone_v4.inc +++ b/madgraph/iolibs/template_files/matrix_standalone_v4.inc @@ -86,10 +86,33 @@ C For a 1>N process, them BEAMTWO_HELAVGFACTOR would be set to 1. PARAMETER (NGOODHEL_FLAV=NCOMB*NFLAV) INTEGER FLAV_IDX INTEGER %(proc_prefix)sGET_FLAVOR_INDEX +C FLAV_USE is the flavor part of FLAV_IDX. + INTEGER FLAV_USE +%(smatrix_cross_decl)s INTEGER NTRY(NFLAV) LOGICAL GOODHEL(NCOMB,NFLAV) DATA NTRY/NNTRY_FLAV*0/ DATA GOODHEL/NGOODHEL_FLAV*.FALSE./ +C C-parity helicity de-duplication (see the SMATRIX loop): FLIP(IHEL) is the +C row with every helicity negated (built once, an involution); TSTORE caches +C the per-row |M|^2 within a scan point; DEDUP turns the reuse on in the fast +C phase only. The reuse is ALL-OR-NOTHING per flavor: CSYM(J) stays true only +C while EVERY row is in a genuine matched pair -- a self-paired row +C (FLIP(IHEL)=IHEL) or a single pair with |M(IHEL)|^2 != |M(FLIP)|^2 at any +C scan point clears it for good. Halving the whole loop (rather than an +C arbitrary subset of rows) is what keeps any per-row accumulation done +C inside MATRIX uniformly scaled, so the same rule is used by every backend. +C NTRY_CSYM counts only the uncrossed (cross 0) calls: CSYM is built and +C applied for the base process only, because a crossing permutes/sign-flips +C the helicities so FLIP (a base-row negation) is no longer the crossed +C C-parity partner. Crossed flavours therefore keep the full helicity sum. + INTEGER FLIP(NCOMB), JHEL, KHEL, NTRY_CSYM(NFLAV) + LOGICAL CSYM(NFLAV), HELSAME, DEDUP + REAL*8 TSTORE(NCOMB) + REAL*8 TSMAX + DATA FLIP/NCOMB*0/ + DATA CSYM/NNTRY_FLAV*.TRUE./ + DATA NTRY_CSYM/NNTRY_FLAV*0/ C C GLOBAL VARIABLES @@ -101,7 +124,10 @@ C common/%(proc_prefix)shelreset/HELRESET data HELRESET/.true./ -%(helicity_lines)s +C Allowed canonical helicity codes (mixed-radix over the per-leg states). +C The explicit NHEL config table is materialized at runtime by FILL_NHEL. + INTEGER HELALLOW(NCOMB) +%(hel_allow_data)s %(den_factor_line)s INTEGER POLARIZATIONS(0:NEXTERNAL,0:5) @@ -113,12 +139,17 @@ C FUNCTIONS C LOGICAL %(proc_prefix)sIS_BORN_HEL_SELECTED INTEGER %(proc_prefix)sBROKEN_SYM +%(blas_decl)s c---------- c Check if helreset mode is on c--------- if (HELRESET) then do i=1,NFLAV NTRY(i) = 0 + NTRY_CSYM(i) = 0 + enddo + do j=1,NFLAV + CSYM(j) = .true. enddo do i=1,NCOMB do j=1,NFLAV @@ -131,19 +162,45 @@ endif C ---------- C BEGIN CODE C ---------- -C FLAV_IDX=0 (or out of range) means GET_FLAVOR_INDEX could not resolve -C the requested flavor: it is not an allowed combination, so its matrix -C element is identically zero. Short-circuit before touching the -C 1..NFLAV GOODHEL/NTRY arrays. - IF (FLAV_IDX.LT.1 .OR. FLAV_IDX.GT.NFLAV) THEN +C FLAV_USE = mod(FLAV_IDX-1, NFLAV) + 1 is the flavor used for masking. +C FLAV_IDX<1 means GET_FLAVOR_INDEX could not resolve the requested flavor: +C it is not an allowed combination, so its matrix element is identically +C zero. Short-circuit before touching the 1..NFLAV GOODHEL/NTRY arrays. + IF (FLAV_IDX.LT.1) THEN ANS = 0D0 RETURN ENDIF - CALL %(proc_prefix)sGET_FLAVOR(FLAV_IDX, FLAVOR) - IF(USERHEL.EQ.-1) NTRY(FLAV_IDX)=NTRY(FLAV_IDX)+1 + FLAV_USE = MOD(FLAV_IDX-1, NFLAV) + 1 +%(smatrix_cross_decode)s +C The helicity filter is deliberately shared by every crossing of a given +C flavor, so it is indexed by FLAV_USE rather than by the full FLAV_IDX. + CALL %(proc_prefix)sGET_FLAVOR(FLAV_USE, FLAVOR) + CALL %(proc_prefix)sFILL_NHEL() +C C-parity partner of each helicity row (all helicities negated): an +C involution, self-paired when every helicity is 0. Fixed per process, so +C built once (FLIP starts at 0). + IF (FLIP(1).EQ.0) THEN + DO IHEL=1,NCOMB + FLIP(IHEL)=IHEL + DO JHEL=1,NCOMB + HELSAME=.TRUE. + DO KHEL=1,NEXTERNAL + IF (NHEL(KHEL,JHEL).NE.-NHEL(KHEL,IHEL)) HELSAME=.FALSE. + ENDDO + IF (HELSAME) THEN + FLIP(IHEL)=JHEL + EXIT + ENDIF + ENDDO + ENDDO + ENDIF + IF(USERHEL.EQ.-1) NTRY(FLAV_USE)=NTRY(FLAV_USE)+1 + IF(USERHEL.EQ.-1.AND.FLAV_IDX.LE.NFLAV) + & NTRY_CSYM(FLAV_USE)=NTRY_CSYM(FLAV_USE)+1 DO IHEL=1,NEXTERNAL JC(IHEL) = +1 ENDDO +%(smatrix_cross_apply)s C When spin-2 particles are involved, the Helicity filtering is dangerous for the 2->1 topology. C This is because depending on the MC setup the initial PS points have back-to-back initial states C for which some of the spin-2 helicity configurations are zero. But they are no longer zero @@ -157,24 +214,74 @@ C For this reason, we simply remove the filterin when there is only three ex ENDDO ENDDO ENDIF +C C-parity de-duplication is only safe for the plain unpolarized helicity +C sum of the uncrossed process (FLAV_IDX in [1,NFLAV]) and only once its own +C scan has settled (NTRY_CSYM>=20). + DEDUP = NTRY_CSYM(FLAV_USE).GE.20 .AND. CSYM(FLAV_USE) + & .AND. USERHEL.EQ.-1 + & .AND. POLARIZATIONS(0,0).EQ.-1 .AND. FLAV_IDX.LE.NFLAV ANS = 0D0 +%(blas_branch)s DO IHEL=1,NCOMB - IF (USERHEL.EQ.-1.OR.USERHEL.EQ.IHEL) THEN - IF (GOODHEL(IHEL,FLAV_IDX) .OR. NTRY(FLAV_IDX) .LT. 20.OR.USERHEL.NE.-1) THEN - IF(NTRY(FLAV_IDX).GE.2.AND.POLARIZATIONS(0,0).ne.-1.and.(.not.%(proc_prefix)sIS_BORN_HEL_SELECTED(IHEL))) THEN + IF (%(blas_guard_open)sUSERHEL.EQ.-1.OR.USERHEL.EQ.HELALLOW(IHEL)%(blas_guard)s) THEN +%(smatrix_goodhel_gate)s + IF(NTRY(FLAV_USE).GE.2.AND.POLARIZATIONS(0,0).ne.-1.and.(.not.%(proc_prefix)sIS_BORN_HEL_SELECTED(IHEL))) THEN CYCLE ENDIF - T=%(proc_prefix)sMATRIX(P ,NHEL(1,IHEL),JC(1),FLAV_IDX) +C Fast phase: a row whose fully flipped C-parity partner has an +C identical |M|^2 (CSYM) is computed once, at the lower index, +C and counted twice -- skip the higher-index partner here. + IF (DEDUP.AND.IHEL.GT.FLIP(IHEL)) CYCLE +C MATRIX/GET_AMP get already crossed arrays and the reduced +C flavor index: the crossing was applied once, above. +%(smatrix_matrix_call)s +C Scan phase (uncrossed only): cache |M|^2 to test the +C C-parity partner below. + IF (FLAV_IDX.LE.NFLAV.AND.NTRY_CSYM(FLAV_USE).LT.20) + & TSTORE(IHEL)=T +C Fast phase: the representative carries its skipped partner's +C identical contribution. + IF (DEDUP.AND.IHEL.LT.FLIP(IHEL)) T=T+T IF(POLARIZATIONS(0,0).eq.-1.or.%(proc_prefix)sIS_BORN_HEL_SELECTED(IHEL)) THEN ANS=ANS+T ENDIF - IF (T .NE. 0D0 .AND. .NOT. GOODHEL(IHEL,FLAV_IDX)) THEN - GOODHEL(IHEL,FLAV_IDX)=.TRUE. - ENDIF +%(smatrix_goodhel_train)s ENDIF ENDIF ENDDO - ANS=ANS/DBLE(IDEN)*%(proc_prefix)sBROKEN_SYM(FLAVOR) +C Scan phase: drop the C-parity pairing of any row whose fully flipped +C partner gave a different |M|^2 (parity/C violation). One mismatch at any +C scan point permanently invalidates the pair (robust, like the zero-filter). + IF (USERHEL.EQ.-1.AND.FLAV_IDX.LE.NFLAV + & .AND.NTRY_CSYM(FLAV_USE).LT.20 + & .AND.POLARIZATIONS(0,0).EQ.-1) THEN +C A pair of rows that are BOTH numerically zero differ only by roundoff, +C and a purely RELATIVE test on that noise fails at random -- one such pair +C then vetoes every real pair, because the verdict is all-or-nothing. +C Measured on g g > t t~: rows 1/16 and 4/13 sit at |M|^2 ~ 1e-30 while the +C scan maximum is ~3e+02, and the de-duplication never engaged. So also +C require the difference to be significant against TSMAX, the largest +C |M|^2 of this scan point. + TSMAX=0D0 + DO IHEL=1,NCOMB + IF (ABS(TSTORE(IHEL)).GT.TSMAX) TSMAX=ABS(TSTORE(IHEL)) + ENDDO + DO IHEL=1,NCOMB + IF (FLIP(IHEL).EQ.IHEL) THEN +C Self-paired row: no distinct partner, so the loop cannot be +C halved uniformly -- refuse the reuse for this flavor. + CSYM(FLAV_USE)=.FALSE. + ELSE IF (FLIP(IHEL).GT.IHEL) THEN + IF (ABS(TSTORE(IHEL)-TSTORE(FLIP(IHEL))).GT. + & 1D-6*(ABS(TSTORE(IHEL))+ABS(TSTORE(FLIP(IHEL)))) + & .AND.ABS(TSTORE(IHEL)-TSTORE(FLIP(IHEL))).GT. + & 1D-12*TSMAX) THEN + CSYM(FLAV_USE)=.FALSE. + ENDIF + ENDIF + ENDDO + ENDIF +%(smatrix_iden_line)s IF(USERHEL.NE.-1) THEN ANS=ANS*HELAVGFACTOR ELSE @@ -196,6 +303,10 @@ C C Returns amplitude squared -- no average over initial state/symmetry factor c for the point with external lines W(0:6,NEXTERNAL) C +C CONTRACT: P, NHEL and IC must ALREADY be crossed and FLAV_IDX must ALREADY +C be reduced to [1,NFLAV] (see GET_AMP). SMATRIX applies the crossing once, +C before its helicity loop; this routine never decodes anything. +C %(process_lines)s C use aloha_object @@ -228,7 +339,9 @@ C INTEGER %(proc_prefix)sCF(%(ncolortriang)d) INTEGER %(proc_prefix)sDENOM common /%(proc_prefix)scolor_matrix/ %(proc_prefix)sCF,%(proc_prefix)sDENOM - COMPLEX*16 AMP(NGRAPHS), JAMP(NCOLOR), TMP_JAMP(%(nb_temp_jamp)i) + COMPLEX*16 AMP(%(namp_dim)s), JAMP(NCOLOR) +%(jamp_tmp_decl)s +%(jampflow_decl)s type(aloha) W(NWAVEFUNCS) COMPLEX*16 DUM0,DUM1 DATA DUM0, DUM1/(0d0, 0d0), (1d0, 0d0)/ @@ -248,6 +361,7 @@ C ---------- c WRITE (*,*) ' -> AMP = ', AMP call %(proc_prefix)sGET_JAMP(AMP,JAMP) c WRITE (*,*) ' -> JAMP = ', JAMP +%(jampflow_call)s call %(proc_prefix)sGET_MATRIX(JAMP,%(proc_prefix)sMATRIX) c write (*,*) " -> col.ave. |M|^2 for HEL=[", NHEL ,"] = ", %(proc_prefix)sMATRIX @@ -265,20 +379,90 @@ CF2PY INTENT(OUT) :: IDEN_STAR INTEGER NCOMB PARAMETER ( NCOMB=%(ncomb)d) - INTEGER NHEL(NEXTERNAL,NCOMB),NHEL_STAR(NEXTERNAL,NCOMB) + INTEGER NHEL(NEXTERNAL,NCOMB) + COMMON/%(proc_prefix)sPROCESS_NHEL/NHEL + INTEGER NHEL_STAR(NEXTERNAL,NCOMB) INTEGER IDEN,IDEN_STAR - -%(helicity_lines)s %(den_factor_line)s + CALL %(proc_prefix)sFILL_NHEL() IDEN_STAR = IDEN NHEL_STAR = NHEL END + SUBROUTINE %(proc_prefix)sGET_NHEL_IDX(FLAV_IDX_IN,IDEN_STAR, + & NHEL_STAR) +C Same as GET_NHEL, but reporting the denominator SMATRIX ACTUALLY +C divides by for FLAV_IDX_IN, rather than the static IDEN. +C +C The static IDEN is the averaging/symmetry factor of the uncrossed +C *representative* flavor. SMATRIX never uses it bare: it applies +C IDEN/BROKEN_SYM(FLAVOR) uncrossed (BROKEN_SYM correcting the +C identical-particle count of the representative to that of the actual +C flavor) and GET_SPINCOL_CROSS*GET_IDENT_CROSS when crossed. A caller +C that reads GET_NHEL and multiplies ANS by it to recover the raw +C helicity/color sum -- the natural thing to do, and what makes two +C crossings comparable -- is therefore wrong for every crossed flavor +C and for every non-representative one. +C +C GET_NHEL is deliberately left alone: its signature and its value are +C what existing uncrossed callers expect. +%(nhel_idx_decl)s + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER NCOMB + PARAMETER ( NCOMB=%(ncomb)d) + INTEGER NFLAV + PARAMETER (NFLAV=%(nflav)d) +CF2PY INTENT(IN) :: FLAV_IDX_IN +CF2PY INTENT(OUT) :: NHEL_STAR +CF2PY INTENT(OUT) :: IDEN_STAR + INTEGER FLAV_IDX_IN + INTEGER NHEL_STAR(NEXTERNAL,NCOMB) + INTEGER IDEN_STAR + INTEGER NHI_FLAV, NHI_CROSS + INTEGER FLAVOR(NEXTERNAL) + INTEGER %(proc_prefix)sBROKEN_SYM + + CALL %(proc_prefix)sGET_NHEL(IDEN_STAR, NHEL_STAR) +C An index naming no valid flavor gives a zero matrix element; report a +C 0 denominator rather than a plausible-looking one. + IF (FLAV_IDX_IN .LT. 1) THEN + IDEN_STAR = 0 + RETURN + ENDIF + NHI_FLAV = MOD(FLAV_IDX_IN-1, NFLAV) + 1 + NHI_CROSS = (FLAV_IDX_IN-1) / NFLAV + CALL %(proc_prefix)sGET_FLAVOR(NHI_FLAV, FLAVOR) +%(nhel_idx_body)s + RETURN + END + SUBROUTINE %(proc_prefix)sGET_AMP(P,NHEL,IC,FLAV_IDX,AMP) use model_object C %(process_lines)s C +C CONTRACT (this routine is pure: it decodes nothing). +C +C P(0:3,NEXTERNAL) : momenta, ALREADY crossed. +C NHEL(NEXTERNAL) : helicities, ALREADY crossed (permuted, NOT negated: +C helas uses nh=nhel*nsf, so the sign follows IC). +C IC(NEXTERNAL) : NSF/NSV flag per leg, ALREADY crossed (-1 on a leg +C the crossing moved across). +C FLAV_IDX : flavor index ALREADY reduced to [1,NFLAV]; it must +C NOT be an extended index carrying a crossing. +C +C When the crossing machinery is written out (see %(proc_prefix)sGET_CROSS_PERM below; +C it is left out when the process was generated with --use_crossing=False, +C in which case FLAV_IDX is never extended), the callers inside this file +C (SMATRIX via MATRIX, GET_ALL_INTER_CROSSED) apply the crossing ONCE per +C entry point with %(proc_prefix)sAPPLY_CROSSING / %(proc_prefix)sAPPLY_CROSSING_TABLE and then call +C this routine in their inner loop. An external (f2py) caller holding an +C extended FLAV_IDX must call the public %(proc_prefix)sAPPLY_CROSSING itself first; +C passing the extended index here would otherwise silently return the +C UNCROSSED amplitude, so the range is checked below and violations are +C reported and return AMP=0. +C CF2PY INTENT(OUT) :: AMP CF2PY INTENT(IN) :: NHEL CF2PY INTENT(IN) :: P(0:3,NEXTERNAL) @@ -296,6 +480,8 @@ C PARAMETER (NEXTERNAL=%(nexternal)d) INTEGER NWAVEFUNCS, NCOLOR PARAMETER (NWAVEFUNCS=%(nwavefuncs)d, NCOLOR=%(ncolor)d) + INTEGER NFLAV + PARAMETER (NFLAV=%(nflav)d) REAL*8 ZERO PARAMETER (ZERO=0D0) C @@ -304,11 +490,15 @@ C REAL*8 P(0:3,NEXTERNAL) INTEGER NHEL(NEXTERNAL), IC(NEXTERNAL) INTEGER FLAV_IDX - INTEGER FLAVOR(NEXTERNAL) + COMPLEX*16 AMP(NGRAPHS) C C LOCAL VARIABLES C - COMPLEX*16 AMP(NGRAPHS) +C FLAVOR is rebuilt from FLAV_IDX below and is NOT permuted by any +C crossing: each slot keeps its own flavor-group position, which is what +C the mask indexes. + INTEGER FLAVOR(NEXTERNAL) + INTEGER AMP_I type(aloha) W(NWAVEFUNCS) COMPLEX*16 DUM0,DUM1 DATA DUM0, DUM1/(0d0, 0d0), (1d0, 0d0)/ @@ -322,6 +512,18 @@ C C C bwcutoff=15 ! use if $ syntax is defined in the process +C Contract guard: an extended FLAV_IDX (one carrying a crossing) reaching +C this routine would be silently truncated to its flavor part and give the +C uncrossed amplitude. Fail loudly and return zero instead. + IF (FLAV_IDX.LT.1 .OR. FLAV_IDX.GT.NFLAV) THEN + WRITE(*,*) 'ERROR: GET_AMP got FLAV_IDX', FLAV_IDX, 'NFLAV', NFLAV + WRITE(*,*) 'GET_AMP needs a reduced index and crossed P/NHEL/IC.' + WRITE(*,*) 'Returning AMP=0.' + DO AMP_I = 1, NGRAPHS + AMP(AMP_I) = (0D0, 0D0) + ENDDO + RETURN + ENDIF %(flavor_mask_setup)s %(helas_calls)s %(amp2_lines)s @@ -344,11 +546,15 @@ C PARAMETER ( NCOLOR=%(ncolor)d) COMPLEX*16 IMAG1 PARAMETER (IMAG1=(0D0,1D0)) - COMPLEX*16 AMP(NGRAPHS), JAMP(NCOLOR), TMP_JAMP(%(nb_temp_jamp)i) + COMPLEX*16 AMP(%(namp_dim)s), JAMP(NCOLOR) +%(jamp_tmp_decl)s +%(jamp_decl)s %(jamp_lines)s END +%(jampflow_routine)s + SUBROUTINE %(proc_prefix)sGET_MATRIX(JAMP,MATRIX) C %(process_lines)s @@ -361,43 +567,75 @@ CF2PY INTENT(OUT) :: MATRIX CF2PY INTENT(IN) :: JAMP - INTEGER NCOLOR + INTEGER NCOLOR, NCOLORFOLD PARAMETER (NCOLOR=%(ncolor)d) + PARAMETER (NCOLORFOLD=%(ncolorfold)d) REAL*8 ZERO,MATRIX PARAMETER (ZERO=0D0) C C LOCAL VARIABLES C - INTEGER I,J - COMPLEX*16 ZTEMP + INTEGER I,J,NJ,NB + COMPLEX*16 ZTEMP,Z1,Z2,Z3,Z4 INTEGER CF_INDEX - INTEGER %(proc_prefix)sCF(NCOLOR*(NCOLOR+1)/2) + INTEGER %(proc_prefix)sCF(NCOLORFOLD*(NCOLORFOLD+1)/2) INTEGER %(proc_prefix)sDENOM common /%(proc_prefix)scolor_matrix/ %(proc_prefix)sCF,%(proc_prefix)sDENOM - COMPLEX*16 JAMP(NCOLOR), TMP_JAMP(%(nb_temp_jamp)i) + COMPLEX*16 JAMP(NCOLOR) + COMPLEX*16 JFOLD(NCOLORFOLD) +%(color_fold_decl)s +%(color_fold_index)s +%(jamp_tmp_decl)s COMPLEX*16 DUM0,DUM1 DATA DUM0, DUM1/(0d0, 0d0), (1d0, 0d0)/ C C COLOR DATA C + CALL %(proc_prefix)sINIT_CF() +C Reversing a color flow gives the same one back up to an overall +C sign, so only one of each pair carries anything: the sum below runs +C over those, against a color matrix folded onto them. +%(color_fold_gather)s MATRIX = 0.D0 CF_INDEX = 0 - DO I = 1, NCOLOR - ZTEMP = (0.D0,0.D0) - DO J = I, NCOLOR - CF_INDEX = CF_INDEX + 1 - ZTEMP = ZTEMP + %(proc_prefix)sCF(CF_INDEX)*JAMP(J) +C Four accumulators, not one: with a single one every +C term waits for the one before it to come out of the +C adder, and that latency is what the loop spends its +C time on. No compiler does this by itself, since it +C changes the order the terms are summed in. + DO I = 1, NCOLORFOLD + Z1 = (0.D0,0.D0) + Z2 = (0.D0,0.D0) + Z3 = (0.D0,0.D0) + Z4 = (0.D0,0.D0) + NJ = NCOLORFOLD - I + 1 + NB = (NJ/4)*4 + DO J = 0, NB-4, 4 + Z1 = Z1 + %(proc_prefix)sCF(CF_INDEX+J+1)*JFOLD(I+J) + Z2 = Z2 + %(proc_prefix)sCF(CF_INDEX+J+2)*JFOLD(I+J+1) + Z3 = Z3 + %(proc_prefix)sCF(CF_INDEX+J+3)*JFOLD(I+J+2) + Z4 = Z4 + %(proc_prefix)sCF(CF_INDEX+J+4)*JFOLD(I+J+3) ENDDO - MATRIX = MATRIX+ZTEMP*DCONJG(JAMP(I))/%(proc_prefix)sDENOM + ZTEMP = (Z1+Z2)+(Z3+Z4) + DO J = NB, NJ-1 + ZTEMP = ZTEMP + %(proc_prefix)sCF(CF_INDEX+J+1)*JFOLD(I+J) + ENDDO + CF_INDEX = CF_INDEX + NJ + MATRIX = MATRIX+ZTEMP*DCONJG(JFOLD(I))/%(proc_prefix)sDENOM ENDDO END +%(color_init_routine)s + +%(jamp_init_routine)s + +%(blas_routine)s SUBROUTINE %(proc_prefix)sGET_INTER(JAMP_1,JAMP_2, INTER) CF2PY INTENT(OUT) :: INTER @@ -416,6 +654,7 @@ CF2PY INTENT(IN) :: JAMP_2 C COLOR DATA C + CALL %(proc_prefix)sINIT_CF() INTER = (0.D0,0.D0) CF_INDEX = 0 @@ -433,12 +672,53 @@ C ZTEMP = DCONJG(JAMP_2(I)) SUBROUTINE %(proc_prefix)sGET_DENSITY(P, POS, N_CHANGING, ALLOW_HEL, N_COMB, FLAVOR, ALPHAS, SCALE2, INTER) +C Entry point taking the full FLAVOR(NEXTERNAL) array (back-compat): +C resolve it to FLAV_IDX and forward to GET_DENSITY_IDX. A crossing can +C only be requested through GET_DENSITY_IDX: an extended FLAV_IDX carries +C a crossing code, which no FLAVOR array can express (GET_FLAVOR_INDEX +C only ever returns 1..NFLAV). + implicit none + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) +CF2PY INTENT(IN) :: P(0:3,%(nexternal)d) +CF2PY INTENT(IN) :: POS(N_CHANGING) +CF2PY INTENT(IN) :: N_CHANGING +CF2PY INTENT(IN) :: ALLOW_HEL(N_CHANGING*N_COMB) +CF2PY INTENT(IN) :: N_COMB +CF2PY INTENT(IN) :: FLAVOR(%(nexternal)d) +CF2PY INTENT(IN) :: ALPHAS +CF2PY INTENT(IN) :: SCALE2 +CF2PY INTENT(OUT) :: INTER(N_COMB*(N_COMB+1)/2) + REAL*8 P(0:3,NEXTERNAL) + INTEGER N_CHANGING, N_COMB + INTEGER POS(*) + INTEGER ALLOW_HEL(*) + INTEGER FLAVOR(NEXTERNAL) + DOUBLE PRECISION ALPHAS, SCALE2 + DOUBLE COMPLEX INTER(*) + INTEGER %(proc_prefix)sGET_FLAVOR_INDEX + + CALL %(proc_prefix)sGET_DENSITY_IDX(P, POS, N_CHANGING, ALLOW_HEL, + & N_COMB, %(proc_prefix)sGET_FLAVOR_INDEX(FLAVOR), ALPHAS, SCALE2, + & INTER) + RETURN + END + + + SUBROUTINE %(proc_prefix)sGET_DENSITY_IDX(P, POS, N_CHANGING, ALLOW_HEL, N_COMB, FLAV_IDX, ALPHAS, SCALE2, INTER) c P momenta c NHEL base of helicity that are not changing c POS(N_CHNGING): position of the changing helicity c n_changing: number of changing helicity c ALLOW_HEL(NCOMB, N_CHANGING): combination of helicity to consider (all jamp computed) c INTER(NCOMB*(NCOMB+1)/2): all interference term (not the symmetric one) +c FLAV_IDX may carry a crossing. It is decoded and applied ONCE here (the +c whole NHEL table, the momenta and the NSF flags in one go) and only +c crossed arrays plus the reduced flavor index travel further down. POS and +c the helicity labels refer to the UNCROSSED (source process) leg ordering +c and are mapped through the crossing permutation here. No helicity flip is +c needed on top: helas folds it into nh=nhel*nsf when the NSF flag of a +c crossed leg is flipped. use model_object implicit none CF2PY INTENT(IN) :: P(0:3,%(nexternal)d) @@ -446,7 +726,7 @@ CF2PY INTENT(IN) :: POS(N_CHANGING) CF2PY INTENT(IN) :: N_CHANGING CF2PY INTENT(IN) :: ALLOW_HEL(N_CHANGING*N_COMB) CF2PY INTENT(IN) :: N_COMB -CF2PY INTENT(IN) :: FLAVOR(%(nexternal)d) +CF2PY INTENT(IN) :: FLAV_IDX CF2PY INTENT(IN) :: ALPHAS CF2PY INTENT(IN) :: SCALE2 CF2PY INTENT(OUT) :: INTER(N_COMB*(N_COMB+1)/2) @@ -462,7 +742,7 @@ C INTEGER N_CHANGING, N_COMB INTEGER POS(*) INTEGER ALLOW_HEL(*) - INTEGER FLAVOR(NEXTERNAL) + INTEGER FLAV_IDX DOUBLE PRECISION ALPHAS, SCALE2 DOUBLE COMPLEX INTER(*) INTEGER NINTER @@ -472,6 +752,13 @@ C c LOCAL INTEGER I,IHEL,IPART DOUBLE PRECISION PI +C Crossed copies, built once (see the crossing block below). + REAL*8 PUSE(0:3,NEXTERNAL) + INTEGER NHELUSE(NEXTERNAL,NB_NHEL) + INTEGER IC(NEXTERNAL), ICUSE(NEXTERNAL) + INTEGER PERM(NEXTERNAL), SGN(NEXTERNAL), CPOS(NEXTERNAL) + INTEGER FLAV_USE, DUMFLAV + DOUBLE PRECISION RESCALE C INTEGER NHEL(NEXTERNAL,NB_NHEL) C put in common block to expose this variable to python interface @@ -494,13 +781,30 @@ C G = 2* DSQRT(ALPHAS*pi) call UPDATE_AS_PARAM() ENDIF +C Unresolved flavor (GET_FLAVOR_INDEX miss): the matrix element and hence +C every interference term is identically zero. Guarded after the alphas +C update so that side effect is unchanged. + IF (FLAV_IDX.LT.1) THEN + return + ENDIF +C Decode and apply the crossing ONCE for the whole density matrix: the +C permutation is the same for every helicity row, so the NHEL table is +C permuted in one sweep. RESCALE carries the flavor / crossing dependent +C part of the normalisation (RESCALE=0 = impossible crossing). + IC(:) = 1 + CALL %(proc_prefix)sGET_INTER_RESCALE(FLAV_IDX, FLAV_USE, RESCALE) + IF (RESCALE.EQ.0D0) THEN + return + ENDIF + CALL %(proc_prefix)sFILL_NHEL() +%(density_cross_apply)s DO IHEL =1, NB_NHEL - THISNHEL(:) = NHEL(:, IHEL) + THISNHEL(:) = NHELUSE(:, IHEL) DO IPART=1,N_CHANGING - if(THISNHEL(POS(IPART)).NE.ALLOW_HEL(IPART)) GOTO 10 !BYPASS COMPUTATION FOR HELICITY + if(THISNHEL(CPOS(IPART)).NE.ALLOW_HEL(IPART)) GOTO 10 !BYPASS COMPUTATION FOR HELICITY ENDDO TMP_INTER(:) = 0 - call %(proc_prefix)sGET_ALL_INTER(P, THISNHEL, POS, N_CHANGING, ALLOW_HEL, N_COMB, FLAVOR, TMP_INTER) + call %(proc_prefix)sGET_ALL_INTER_CROSSED(PUSE, THISNHEL, ICUSE, CPOS, N_CHANGING, ALLOW_HEL, N_COMB, FLAV_USE, RESCALE, TMP_INTER) do I = 1, N_COMB*(N_COMB+1)/2 INTER(I) = INTER(I) + TMP_INTER(I) enddo @@ -510,12 +814,45 @@ C end SUBROUTINE %(proc_prefix)sGET_ALL_INTER(P, NHEL, POS, N_CHANGING, ALLOW_HEL, N_COMB, FLAVOR, INTER) +C Entry point taking the full FLAVOR(NEXTERNAL) array (back-compat); see +C GET_DENSITY. Use GET_ALL_INTER_IDX to request a crossing. + implicit none + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) +CF2PY INTENT(IN) :: P(0:3,%(nexternal)d) +CF2PY INTENT(IN) :: NHEL(%(nexternal)d) +CF2PY INTENT(IN) :: POS(N_CHANGING) +CF2PY INTENT(IN) :: N_CHANGING +CF2PY INTENT(IN) :: ALLOW_HEL(N_CHANGING*N_COMB) +CF2PY INTENT(IN) :: N_COMB +CF2PY INTENT(IN) :: FLAVOR(%(nexternal)d) +CF2PY INTENT(OUT) :: INTER(NCOMB*(NCOMB+1)/2) + REAL*8 P(0:3,NEXTERNAL) + INTEGER NHEL(NEXTERNAL) + INTEGER N_CHANGING, N_COMB + INTEGER POS(*) + INTEGER ALLOW_HEL(*) + INTEGER FLAVOR(NEXTERNAL) + DOUBLE COMPLEX INTER(*) + INTEGER %(proc_prefix)sGET_FLAVOR_INDEX + + CALL %(proc_prefix)sGET_ALL_INTER_IDX(P, NHEL, POS, N_CHANGING, + & ALLOW_HEL, N_COMB, %(proc_prefix)sGET_FLAVOR_INDEX(FLAVOR), INTER) + RETURN + END + + + SUBROUTINE %(proc_prefix)sGET_ALL_INTER_IDX(P, NHEL, POS, N_CHANGING, ALLOW_HEL, N_COMB, FLAV_IDX, INTER) c P momenta c NHEL base of helicity that are not changing c POS(N_CHNGING): position of the changing helicity c n_changing: number of changing helicity c ALLOW_HEL(NCOMB, N_CHANGING): combination of helicity to consider (all jamp computed) c INTER((NCOMB*NCOMB+1)/2: all interference term (not the symmetric one) +c FLAV_IDX may carry a crossing: it is decoded and applied ONCE here, and +c GET_ALL_INTER_CROSSED below then only sees crossed arrays. POS is given +c in the UNCROSSED (source process) slot numbering and is mapped through +c the crossing permutation here. implicit none CF2PY INTENT(IN) :: P(0:3,%(nexternal)d) CF2PY INTENT(IN) :: NHEL(%(nexternal)d) @@ -523,7 +860,7 @@ CF2PY INTENT(IN) :: POS(N_CHANGING) CF2PY INTENT(IN) :: N_CHANGING CF2PY INTENT(IN) :: ALLOW_HEL(N_CHANGING*N_COMB) CF2PY INTENT(IN) :: N_COMB -CF2PY INTENT(IN) :: FLAVOR(%(nexternal)d) +CF2PY INTENT(IN) :: FLAV_IDX CF2PY INTENT(OUT) :: INTER(NCOMB*(NCOMB+1)/2) c C @@ -536,7 +873,97 @@ C INTEGER N_CHANGING, N_COMB INTEGER POS(*) INTEGER ALLOW_HEL(*) + INTEGER FLAV_IDX + DOUBLE COMPLEX INTER(*) +c +c LOCAL +c + INTEGER I, IPART + INTEGER IC(NEXTERNAL), ICUSE(NEXTERNAL) + REAL*8 PUSE(0:3,NEXTERNAL) + INTEGER NHELUSE(NEXTERNAL) + INTEGER PERM(NEXTERNAL), SGN(NEXTERNAL), CPOS(NEXTERNAL) + INTEGER FLAV_USE, DUMFLAV + DOUBLE PRECISION RESCALE +C ---------- +C BEGIN CODE +C ---------- +C Unresolved flavor (not an allowed combination): the matrix element and +C therefore all interference terms are zero. + IF (FLAV_IDX.LT.1) THEN + DO I = 1, N_COMB*(N_COMB+1)/2 + INTER(I) = (0d0, 0d0) + ENDDO + RETURN + ENDIF +C RESCALE carries everything flavor / crossing dependent in the +C normalisation; RESCALE=0 marks a crossing that cannot be applied. + CALL %(proc_prefix)sGET_INTER_RESCALE(FLAV_IDX, FLAV_USE, RESCALE) + IF (RESCALE.EQ.0D0) THEN + DO I = 1, N_COMB*(N_COMB+1)/2 + INTER(I) = (0d0, 0d0) + ENDDO + RETURN + ENDIF +%(allinter_cross_apply)s + CALL %(proc_prefix)sGET_ALL_INTER_CROSSED(PUSE, NHELUSE, ICUSE, CPOS, + & N_CHANGING, ALLOW_HEL, N_COMB, FLAV_USE, RESCALE, INTER) + RETURN + END + + + SUBROUTINE %(proc_prefix)sGET_INTER_RESCALE(FLAV_IDX, FLAV_USE, + & RESCALE) +C Split an extended FLAV_IDX and return the factor by which GET_INTER's +C output must be multiplied. +C +C GET_INTER only ever sees JAMPs, so it cannot know the flavor: it +C normalises with the bare static IDEN and everything flavor dependent has +C to be applied by its caller. That is also what keeps the density matrix +C consistent with SMATRIX. RESCALE=0 marks a crossing that cannot be +C applied (zero matrix element). + IMPLICIT NONE + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER NFLAV + PARAMETER (NFLAV=%(nflav)d) + INTEGER FLAV_IDX, FLAV_USE + DOUBLE PRECISION RESCALE INTEGER FLAVOR(NEXTERNAL) + INTEGER %(proc_prefix)sBROKEN_SYM +%(inter_rescale_decl)s + + FLAV_USE = MOD(FLAV_IDX-1, NFLAV) + 1 + CALL %(proc_prefix)sGET_FLAVOR(FLAV_USE, FLAVOR) +%(inter_rescale_body)s + + RETURN + END + + + SUBROUTINE %(proc_prefix)sGET_ALL_INTER_CROSSED(P, NHEL, IC, POS, N_CHANGING, ALLOW_HEL, N_COMB, FLAV_IDX, RESCALE, INTER) +c Inner worker of the density machinery. +c +c CONTRACT: P, NHEL and IC are ALREADY crossed, POS is expressed in the +c CROSSED slot numbering, FLAV_IDX is ALREADY reduced to [1,NFLAV] and +c RESCALE already accounts for BROKEN_SYM / the crossed denominator. The +c callers (GET_ALL_INTER_IDX, GET_DENSITY_IDX) decode and apply the +c crossing once, so nothing is decoded per GET_AMP call here. +c NHEL is overwritten at the POS slots. + implicit none +C +C ARGUMENTS +C + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + REAL*8 P(0:3,NEXTERNAL) + INTEGER NHEL(NEXTERNAL) + INTEGER IC(NEXTERNAL) + INTEGER N_CHANGING, N_COMB + INTEGER POS(*) + INTEGER ALLOW_HEL(*) + INTEGER FLAV_IDX + DOUBLE PRECISION RESCALE DOUBLE COMPLEX INTER(*) c c Intermediate array @@ -545,18 +972,14 @@ c PARAMETER (NGRAPHS=%(ngraphs)d) INTEGER NCOLOR PARAMETER (NCOLOR=%(ncolor)d) - INTEGER IC(NEXTERNAL) - DOUBLE COMPLEX AMP(NGRAPHS) + DOUBLE COMPLEX AMP(%(namp_dim)s) DOUBLE COMPLEX, ALLOCATABLE, SAVE :: JAMP(:,:) INTEGER, SAVE :: S_NCOMB = 0 - c c LOCAL c INTEGER I,J,SOL,N - INTEGER FLAV_IDX - INTEGER %(proc_prefix)sGET_FLAVOR_INDEX if (allocated(jamp) .and. S_NCOMB.ne.N_COMB) then deallocate(jamp) @@ -569,16 +992,6 @@ c C ---------- C BEGIN CODE C ---------- - IC(:)=1 - FLAV_IDX = %(proc_prefix)sGET_FLAVOR_INDEX(FLAVOR) -C Unresolved flavor (not an allowed combination): the matrix element and -C therefore all interference terms are zero. - IF (FLAV_IDX.EQ.0) THEN - DO I = 1, N_COMB*(N_COMB+1)/2 - INTER(I) = (0d0, 0d0) - ENDDO - RETURN - ENDIF do I = 1, N_COMB do N = 1, N_CHANGING NHEL(POS(N)) = ALLOW_HEL((I-1)*N_CHANGING+N) @@ -592,6 +1005,7 @@ C therefore all interference terms are zero. DO J= I, N_COMB SOL = SOL +1 call %(proc_prefix)sGET_INTER(JAMP(1,I), JAMP(1,J), INTER(SOL)) + INTER(SOL) = INTER(SOL)*RESCALE ENDDO ENDDO @@ -761,6 +1175,89 @@ C ---------- END + SUBROUTINE %(proc_prefix)sDECODE_HEL(CODE, THISNHEL) +C Decode a canonical mixed-radix helicity CODE (1..NCOMBFULL) into the +C per-leg helicity values THISNHEL(NEXTERNAL). The last external leg is the +C least-significant digit, matching the itertools.product ordering used to +C build the allowed-code list HELALLOW. + IMPLICIT NONE + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER MAXHEL + PARAMETER (MAXHEL=%(maxhel)d) + INTEGER CODE, THISNHEL(NEXTERNAL) + INTEGER I, K, R, D + INTEGER NHSTATE(NEXTERNAL), STATES(MAXHEL,NEXTERNAL) +%(nhstate_data)s +%(states_data)s + R = CODE - 1 + DO K=NEXTERNAL,1,-1 + D = MOD(R, NHSTATE(K)) + THISNHEL(K) = STATES(D+1, K) + R = R / NHSTATE(K) + ENDDO + RETURN + END + + SUBROUTINE %(proc_prefix)sENCODE_HEL(THISNHEL, CODE) +C Inverse of DECODE_HEL: encode per-leg helicity values THISNHEL into the +C canonical mixed-radix code (used by the crossing-aware routines). + IMPLICIT NONE + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER MAXHEL + PARAMETER (MAXHEL=%(maxhel)d) + INTEGER THISNHEL(NEXTERNAL), CODE + INTEGER I, K, D + INTEGER NHSTATE(NEXTERNAL), STATES(MAXHEL,NEXTERNAL) +%(nhstate_data)s +%(states_data)s + CODE = 0 + DO K=1,NEXTERNAL + DO D=1,NHSTATE(K) + IF (STATES(D,K).EQ.THISNHEL(K)) GOTO 5 + ENDDO + D = 1 + 5 CONTINUE + CODE = CODE*NHSTATE(K) + (D-1) + ENDDO + CODE = CODE + 1 + RETURN + END + + SUBROUTINE %(proc_prefix)sFILL_NHEL() +C Materialize the PROCESS_NHEL config table by decoding the list of allowed +C canonical helicity codes (HELALLOW). Runs once; the table is a runtime +C cache of the encoder/decoder representation, kept for the density-matrix +C and python (f2py) interfaces. + IMPLICIT NONE + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER NCOMB + PARAMETER ( NCOMB=%(ncomb)d) + INTEGER NHEL(NEXTERNAL,NCOMB) + COMMON/%(proc_prefix)sPROCESS_NHEL/NHEL + INTEGER HELALLOW(NCOMB) + INTEGER I, K, THIS(NEXTERNAL) + LOGICAL DONE + SAVE DONE +%(hel_allow_data)s + DATA DONE /.FALSE./ + IF (DONE) RETURN + DO I=1,NCOMB + CALL %(proc_prefix)sDECODE_HEL(HELALLOW(I), THIS) + DO K=1,NEXTERNAL + NHEL(K,I) = THIS(K) + ENDDO + ENDDO + DONE = .TRUE. + RETURN + END + + +%(crossing_routines)s + + %(broken_sym_function)s @@ -768,3 +1265,6 @@ C ---------- %(flavor_array_function)s + + +%(flavor_pdg_function)s diff --git a/madgraph/madevent/gen_ximprove.py b/madgraph/madevent/gen_ximprove.py index 455cec0773..9ea5e0960e 100755 --- a/madgraph/madevent/gen_ximprove.py +++ b/madgraph/madevent/gen_ximprove.py @@ -202,7 +202,8 @@ def get_helicity(self, to_submit=True, clean=True): zero_gc = list() all_zampperhel = set() all_bad_amps_perhel = set() - + all_csym_pairs = set() + for line in stdout.splitlines(): if "=" not in line and ":" not in line: continue @@ -212,6 +213,9 @@ def get_helicity(self, to_submit=True, clean=True): zero_gc.append(lsplit[0]) if 'Matrix Element/Good Helicity:' in line: all_hel.add(tuple(line.split()[3:5])) + if 'CSYM PAIR:' in line: + # (me_index, representative_hel, dropped_partner_hel) + all_csym_pairs.add(tuple(line.split()[2:5])) if 'Amplitude/ZEROAMP:' in line: all_zamp.add(tuple(line.split()[1:3])) if 'HEL/ZEROAMP:' in line: @@ -232,8 +236,18 @@ def get_helicity(self, to_submit=True, clean=True): all_good_hels = collections.defaultdict(list) for me_index, hel in all_hel: - all_good_hels[me_index].append(int(hel)) - + all_good_hels[me_index].append(int(hel)) + + # C-parity de-duplication: (representative -> dropped partner) pairs + # per matrix element, reported by matrix_orig.f. rep < flip, and + # both are good helicities with |M(rep)|^2 == |M(flip)|^2 at every + # scan point. The partner keeps its row (helicity table / |M|^2 sum) + # but its amplitudes are dropped from the recycled optim and its + # |M|^2 reused from the representative. + all_csym = collections.defaultdict(list) + for me_index, rep, flip in all_csym_pairs: + all_csym[me_index].append((int(rep), int(flip))) + #print(all_hel) if self.run_card['hel_zeroamp']: all_bad_amps = collections.defaultdict(list) @@ -271,8 +285,37 @@ def get_helicity(self, to_submit=True, clean=True): fsock.write(data) + # Crossing bases: bake the optim over the UNION good-hel of the + # crossing class so one compiled optim serves every crossing that + # enters it -- a cross-group dependent in another P directory (Track + # B) or a within-group matrix_router.f in this one (Track A). + # crossgroup_helunion.dat gives, per base matrix index, base->base + # helicity permutations: the dependent for that crossing is good at + # helicity h iff perm[h] is good for the base. + helunion = collections.defaultdict(list) + hu_file = pjoin(Pdir, 'crossgroup_helunion.dat') + if os.path.exists(hu_file): + for line in open(hu_file): + vals = line.split() + if vals: + helunion[vals[0]].append([int(x) for x in vals[1:]]) + for matrix_file in misc.glob('matrix*orig.f', Pdir): - + + # Track B cross-group crossing: a dependent P directory reuses a + # base group's compiled matrix element, so its matrix_orig.f is + # a SYMLINK and crossgroup.mk symlinks the base's already-recycled + # matrix_optim.o over it. Running the (expensive) recycler here + # is redundant -- the resulting matrix_optim.f is never compiled + # (its .o comes from the base). But the P makefile discovers its + # matrix objects by the presence of matrix_optim.f, so a + # placeholder must still exist: copy the source (cheap) instead of + # recycling. The base directory, whose source is a real file, bakes + # the shared optim over the UNION good-hel of the whole class. + if os.path.islink(matrix_file): + files.cp(matrix_file, matrix_file.replace('orig', 'optim')) + continue + split_file = matrix_file.split('/') me_index = split_file[-1][len('matrix'):-len('_orig.f')] @@ -286,17 +329,76 @@ def get_helicity(self, to_submit=True, clean=True): # Convert to sorted list for reproducibility #good_hels = sorted(list(good_hels)) - good_hels = [str(x) for x in sorted(all_good_hels[me_index])] + base_good = set(all_good_hels[me_index]) + good_set = set(base_good) + # Crossing base: the shared optim is also evaluated with each + # dependent's CROSSED momenta and IC, but the recycled MATRIX + # bakes the base's helicity configs (it takes no runtime NHEL), so + # the base's own good-hel SUBSET is not the dependent's and + # filtering on it alone would bias a crossed dependent. Keep the + # UNION over the class: h survives if it is good for the base, or + # if some dependent's crossing makes h non-zero, which is exactly + # tau[h] good for the base -- tau being the crossing's helicity + # SIGN map, the part of the transform IC can carry. The lines of + # crossgroup_helunion.dat are those tau (an all-zero row is the + # sentinel for "not a clean permutation": keep everything). + # Note it must be tau and not the GHREMAP sigma, which also + # permutes the slots: matrix_orig.f applies sigma because it + # reads NHEL at run time, the recycled optim cannot. + # Keeping EVERY config instead is NOT a safe over-approximation. + # The recycled K loop also accumulates AMP2 (the single-diagram + # multi-channel weights) and JAMP2 (the colour-flow weights) from + # every config it keeps, and those are not the gauge-invariant + # |M|^2: a config whose |M|^2 vanishes still has non-zero + # individual diagrams and JAMPs, so keeping it silently reweights + # channel and colour selection. For g g > q q~ that resurrected + # the s-channel config, whose AMP2 is exactly zero over the good + # helicities, and diluted the colour flow toward 50/50. + perms = helunion.get(me_index, []) + for perm in perms: + if not all(perm): + good_set = set(range(1, len(perm) + 1)) + break + good_set |= set(h for h, p in enumerate(perm, 1) + if p in base_good) + good_hels = [str(x) for x in sorted(good_set)] + + mtext = open(matrix_file).read() + nb_amp = int(re.findall(r'PARAMETER \(NGRAPHS=(\d+)\)', mtext)[0]) + if self.run_card['hel_zeroamp']: - bad_amps = [str(x) for x in sorted(all_bad_amps[me_index])] bad_amps_perhel = [x for x in sorted(all_bad_amps_perhel[me_index])] else: - bad_amps = [] + bad_amps = [] bad_amps_perhel = [] + + # C-parity de-duplication: for each surviving pair KEEP both rows + # in the helicity table (so the |M|^2 sum and the event-helicity + # CDF stay complete) but drop the partner's amplitudes -- add every + # (partner, graph) to bad_amps_perhel so its HELAS calls are never + # generated -- and reuse the representative's |M|^2 for it. The + # reuse indices are the OPTIM's re-indexed positions in good_hels + # (helicity indices are renumbered 1..len(good_hels) in the optim). + # Still disabled for a crossing-class base (perms): the pairing + # is baked at the BASE's re-indexed positions, and a dependent + # reads those rows through its own crossing permutation, so the + # reuse is not obviously its mirror pairing. That costs only + # speed -- both rows of a pair get computed -- and not + # correctness, since AMP2/JAMP2 ratios do not depend on WHICH + # subset of the good configs is summed (they are the same for a + # row and its mirror). + csym_reuse_pairs = [] + if not perms and all_csym[me_index]: + opt_index = {h: i + 1 for i, h in enumerate(sorted(good_set))} + bad_set = set(bad_amps_perhel) + for rep, flip in all_csym[me_index]: + if rep in good_set and flip in good_set: + for a in range(1, nb_amp + 1): + bad_set.add((flip, a)) + csym_reuse_pairs.append((opt_index[rep], opt_index[flip])) + bad_amps_perhel = sorted(bad_set) if __debug__: - mtext = open(matrix_file).read() - nb_amp = int(re.findall(r'PARAMETER \(NGRAPHS=(\d+)\)', mtext)[0]) logger.debug('(%s) nb_hel: %s zero amp: %s bad_amps_hel: %s/%s', split_file[-1], len(good_hels),len(bad_amps),len(bad_amps_perhel), len(good_hels)*nb_amp ) if len(good_hels) == 1: files.cp(matrix_file, matrix_file.replace('orig','optim')) @@ -305,10 +407,41 @@ def get_helicity(self, to_submit=True, clean=True): gauge = self.cmd.proc_characteristics['gauge'] recycler = hel_recycle.HelicityRecycler(good_hels, bad_amps, bad_amps_perhel, gauge=gauge) + # C-parity de-duplication: copy each dropped partner's |M|^2 from + # its representative (both are real fortran helicity indices). + if csym_reuse_pairs: + recycler.template_dict['csym_reuse'] = '\n'.join( + ' TS(%d) = TS(%d)' % (flip, rep) + for rep, flip in sorted(csym_reuse_pairs)) + '\n' + # A crossing base's optim holds configs that are dead for + # whichever member is calling it: dead for the crossing when the + # base evaluates its own flavors, dead for the base when a + # dependent's crossing enters. Their |M|^2 is zero and costs the + # sum nothing, but their individual diagrams and JAMPs are not + # zero, so letting them into AMP2 (multi-channel) and JAMP2 + # (colour flow) reweights channel and colour selection -- the + # g g > q q~ defect. Gate both on |M|^2 being non-zero, which is + # the same test the good-hel filter itself is trained on, so each + # caller accumulates over exactly its own good set as the + # unrecycled path does. + # Keyed on perms rather than on the union having grown, because + # base_good is not the base's own good set either: the good-hel + # scan prints the RAW loop index of matrix_orig.f, which for a + # crossed flavor is a row of sigma-space, so a crossing base's + # reported set already carries rows that are dead uncrossed. + if perms: + recycler.template_dict['dead_row_if'] = \ + 'IF (TS(%s).NE.0D0) THEN' % recycler.loop_var + recycler.template_dict['dead_row_endif'] = 'ENDIF' # In case of bugs you can play around with these: recycler.hel_filt = self.run_card['hel_filtering'] recycler.amp_splt = self.run_card['hel_splitamp'] recycler.amp_filt = self.run_card['hel_zeroamp'] + # The unrolled call sequence is the whole file at high + # multiplicity; write it out in slices of this many statements + # (0 keeps it inline) so that gfortran is not handed one + # multi-million-line basic block. + recycler.amp_chunk_size = self.run_card['amp_chunk_size'] recycler.set_input(matrix_file) recycler.set_output(out_file) diff --git a/madgraph/madevent/hel_recycle.py b/madgraph/madevent/hel_recycle.py index af0a1e7daa..daf811d9cc 100755 --- a/madgraph/madevent/hel_recycle.py +++ b/madgraph/madevent/hel_recycle.py @@ -2,6 +2,7 @@ import argparse import atexit +import glob import os import re import collections @@ -31,66 +32,272 @@ def get_num_lines(file_path): lines += 1 return lines + +# Default number of fortran statements per amplitude-chunk file; kept in step +# with export_v4.AMP_CHUNK_SIZE_DEFAULT, which this module cannot import (it is +# shipped stand-alone as bin/internal/hel_recycle.py). See the comment there. +AMP_CHUNK_SIZE_DEFAULT = 2000 + +# How many helicity rows the color stage gathers out of AMP at a time when it +# gathers at all (see HelicityRecycler.set_gather_lines). AMP is helicity major, +# so the rows of one amplitude are the adjacent entries and eight complex*16 are +# one 128 byte cache line: gathering a row on its own fetches one line per +# amplitude and uses 16 bytes of it, gathering eight fetches the same line once +# and uses all of it. Kept in step with the exporter's +# hel_recycling_gather_block, which the standalone color stage uses. +GATHER_BLOCK = 8 + +# ... and the size of AMP, in bytes, above which gathering pays at all. Below +# it the rows sharing a cache line are visited close enough together that the +# hardware already gets the reuse the gather is after, so the copy is a second +# pass over AMP for nothing. Measured on the color stage of the recycled matrix +# element, gathered against read in place: g g > g g g (AMP 14 kB) +35%, +# g g > g g g g (408 kB) +56%, g g > t t~ g g g (3.9 MB) -21%. A micro-benchmark +# of the same access pattern puts the crossover near 1.5 MB. Most madevent +# processes are far below that, so this leaves the plain loop alone for all but +# the largest matrix elements. +GATHER_MIN_BYTES = 2 * 1024 ** 2 + +# The markers the exporter puts around the HELAS block of an amplitude-chunk +# file, so that the unrolling below can read the calls back out of it. +AMP_CHUNK_BEGIN = 'HELAS CALLS BEGIN' +AMP_CHUNK_END = 'HELAS CALLS END' +AMP_CHUNK_CALL_RE = re.compile(r'^\s*CALL\s+ORIGAMP\d+_(\d+)\s*\(', re.IGNORECASE) + + +_CHUNK_COMMENT_RE = re.compile(r"^(\s*#|c\$|c$|(c\s+([^=]|$))|cf2py|c\-\-|c\*\*|\s*!|!\$)", + re.IGNORECASE) +_CHUNK_CONTINUATION_RE = re.compile(r"^(?: )[$&]") + + +def chunk_statements(lines, chunk_size): + """Group column-formatted fortran *lines* into slices of about *chunk_size* + statements each. A slice boundary may only fall where a new statement + starts at nesting depth zero: continuation lines stay with their statement, + comments attach to the statement below them, and an IF(...)THEN block -- + which split_amps puts around a flavor-masked amplitude -- is never cut in + half. Mirrors export_v4.chunk_fortran_statements. + """ + + def depth_change(line): + code = line.upper().split('!')[0].strip() + if code.startswith('IF') and code.endswith('THEN'): + return 1 + if code.startswith('DO ') or code == 'DO': + return 1 + if code.startswith(('ENDIF', 'END IF', 'ENDDO', 'END DO')): + return -1 + return 0 + + chunks = [] + current = [] + pending = [] + nb_statements = 0 + depth = 0 + for line in lines: + if not line.strip() or _CHUNK_COMMENT_RE.search(line): + pending.append(line) + continue + if _CHUNK_CONTINUATION_RE.match(line): + (current if current else pending).append(line) + continue + if depth == 0 and nb_statements >= chunk_size and current: + chunks.append(current) + current = [] + nb_statements = 0 + current.extend(pending) + pending = [] + current.append(line) + nb_statements += 1 + depth = max(0, depth + depth_change(line)) + if pending: + current.extend(pending) + if current: + chunks.append(current) + return chunks + + +def get_subroutine_signature(text): + """(name, argument list) of the first SUBROUTINE statement of *text*, with + its continuation lines folded back in. The chunk template is written by the + exporter, which is where the argument list of a chunk is decided (it + depends on the crossing and flavor-mask holes), so it is read back from + there rather than repeated here.""" + + statement = '' + for line in text.split('\n'): + if not statement: + if 'SUBROUTINE' not in line.upper(): + continue + statement = line.strip() + elif line[5:6] in ('$', '&'): + statement += line[6:].strip() + else: + break + if statement.endswith(')'): + break + head, _, args = statement.partition('(') + return head.split()[-1], args.rsplit(')', 1)[0] + + +def read_amp_chunk_body(path): + """Return the HELAS call lines of an amplitude-chunk file, i.e. what used + to sit inline in the matrix element before the split.""" + + body = [] + inside = False + with open(path) as chunk_file: + for line in chunk_file: + if AMP_CHUNK_BEGIN in line.upper(): + inside = True + elif AMP_CHUNK_END in line.upper(): + break + elif inside: + body.append(line) + return body + + +def splice_amp_chunks(path): + """Iterate over the lines of *path*, substituting the body of + matrix_origamp.f wherever the matrix element calls it. + + The exporter can move the HELAS call sequence of matrix_orig.f into + files of its own; the unrolling below has to see that sequence, and it sees + exactly the lines that used to be there. + """ + + directory = os.path.dirname(path) or '.' + base = os.path.basename(path)[:-len('_orig.f')] + skipping = False + with open(path) as input_file: + for line in input_file: + if skipping: + # the call is long enough to be wrapped as soon as the flavor + # masks are threaded through it; its continuations must go with + # it, or they would be folded onto the last spliced statement + if _CHUNK_CONTINUATION_RE.match(line): + continue + skipping = False + match = AMP_CHUNK_CALL_RE.match(line) + if not match: + yield line + continue + skipping = True + chunk = os.path.join( + directory, '%s_origamp%s.f' % (base, match.group(1))) + for chunk_line in read_amp_chunk_body(chunk): + yield chunk_line + class DAG: def __init__(self): - self.graph = {} self.all_wavs = [] self.external_wavs = [] self.internal_wavs = [] + # all_wavs holds every wavefunction ever built, dead ones included, and + # grows to hundreds of thousands of entries at high multiplicity. Every + # question ever asked of it is keyed on old_name, so bucket it: a linear + # scan per HELAS line is quadratic in the file, and it was the second + # cost centre of the whole recycling step after find_path. + self.by_old_name = {} + # The externals each wavefunction depends on, which is the ONLY thing + # anyone ever wanted the graph for -- good_helicity asked it as + # find_path(dep, ext) over every (dep, external) pair, i.e. a fresh DFS + # per pair, 114 million of them on g g > 5g. It needs no search at all: + # the edges recorded by store_wav go straight from a wavefunction to the + # externals under it (that is what its caller passes as ext_deps, itself + # already a transitive closure), and externals have no outgoing edges, + # so the graph is two levels deep by construction and reachability is a + # set membership. Verified against find_path over every top-level pair + # of g g > g g g and g g > g g g g g: same answer everywhere, and no + # path longer than two nodes exists. + self.ext_closure = {} + # Bit i of comb_masks[wav] is set when good_wav_combs[i] contains wav; + # compat_masks[node] is the AND over its ext_closure, so "does some good + # helicity combination cover this subtree" is one big-int test instead + # of a rescan of the whole comb list. Rebuilt by set_good_wav_combs. + self.comb_masks = {} + self.compat_masks = {} + self.full_mask = 0 def store_wav(self, wav, ext_deps=[]): self.all_wavs.append(wav) nature = wav.nature if nature == 'external': self.external_wavs.append(wav) - if nature == 'internal': - self.internal_wavs.append(wav) - for ext in ext_deps: - self.add_branch(wav, ext) - - def add_branch(self, node_i, node_f): + # An external is its own only external dependency: find_path(w, w) + # returned the one-element path [w], which is truthy. + self.ext_closure[wav] = frozenset((wav,)) + else: + if nature == 'internal': + self.internal_wavs.append(wav) + self.ext_closure[wav] = frozenset(ext_deps) try: - self.graph[node_i].append(node_f) + self.by_old_name[wav.old_name].append(wav) except KeyError: - self.graph[node_i] = [node_f] + self.by_old_name[wav.old_name] = [wav] def dependencies(self, old_name): - deps = [wav for wav in self.all_wavs - if wav.old_name == old_name and not wav.dead] - return deps + return list(self.by_old_name.get(old_name, ())) def kill_old(self, old_name): - for wav in self.all_wavs: - if wav.old_name == old_name: + # Every wavefunction under this name dies at once, so the bucket can be + # emptied rather than filtered later: dead wavefunctions are never + # resurrected, and dependencies() would drop them anyway. The name stays + # a key so old_names() keeps reporting it, exactly as the scan over + # all_wavs (which also kept the dead entries) used to. + bucket = self.by_old_name.get(old_name) + if bucket: + for wav in bucket: wav.dead = True + del bucket[:] def old_names(self): - return {wav.old_name for wav in self.all_wavs} - - def find_path(self, start, end, path=[]): - '''Taken from https://www.python.org/doc/essays/graphs/''' - - path = path + [start] - if start == end: - return path - if start not in self.graph: - return None - for node in self.graph[start]: - if node not in path: - newpath = self.find_path(node, end, path) - if newpath: - return newpath - return None + '''The old names ever stored, live or dead. Callers only intersect it + with a set, which leaves it untouched -- do not mutate the result.''' + return self.by_old_name.keys() + + def set_good_wav_combs(self, good_wav_combs): + '''Index the good external-wavefunction combinations as bitmasks. Called + whenever External.get_gwc rebuilds them, which is what invalidates the + cached per-node masks.''' + self.comb_masks = comb_masks = {} + self.compat_masks = {} + for i, comb in enumerate(good_wav_combs): + bit = 1 << i + for wav in comb: + comb_masks[wav] = comb_masks.get(wav, 0) | bit + self.full_mask = (1 << len(good_wav_combs)) - 1 + + def compat_mask(self, node): + '''The combinations that cover every external under `node`. Zero when + none does -- with no combinations at all that is every node, which is + how the old "no comb was a superset" answer came out for an empty + good_wav_combs.''' + try: + return self.compat_masks[node] + except KeyError: + pass + mask = self.full_mask + comb_masks = self.comb_masks + for ext in self.ext_closure[node]: + mask &= comb_masks.get(ext, 0) + if not mask: + break + self.compat_masks[node] = mask + return mask def __str__(self): return self.__repr__() def __repr__(self): + branches = [(key, sorted(item, key=lambda w: w.name)) + for key, item in self.ext_closure.items() + if item and key.nature != 'external'] print_str = 'With new names:\n\t' - print_str += '\n\t'.join([f'{key} : {item}' for key, item in self.graph.items() ]) + print_str += '\n\t'.join([f'{key} : {item}' for key, item in branches]) print_str += '\n\nWith old names:\n\t' - print_str += '\n\t'.join([f'{key.old_name} : {[i.old_name for i in item]}' for key, item in self.graph.items() ]) + print_str += '\n\t'.join([f'{key.old_name} : {[i.old_name for i in item]}' for key, item in branches]) return print_str @@ -98,8 +305,8 @@ def __repr__(self): class MathsObject: '''Abstract class for wavefunctions and Amplitudes''' - # Store here which externals the last wav/amp depends on. - # This saves us having to call find_path multiple times. + # Store here which externals the last wav/amp depends on, so that get_obj + # and get_number do not have to recompute what good_helicity just worked out. ext_deps = None def __init__(self, arguments, old_name, nature): @@ -135,19 +342,31 @@ def get_deps(line, graph): @classmethod def good_helicity(cls, wavs, graph, diag_number=None, all_hel=[], bad_hel_amp=[]): - exts = graph.external_wavs - cls.ext_deps = { i for dep in wavs for i in exts if graph.find_path(dep, i) } - this_comb_good = False - for comb in External.good_wav_combs: - if cls.ext_deps.issubset(set(comb)): - this_comb_good = True + # The externals under this combination of dependencies: the union of the + # closures the DAG already recorded, not a search per (dep, external) + # pair. See DAG.ext_closure. + closure = graph.ext_closure + ext_deps = set() + for dep in wavs: + ext_deps |= closure[dep] + cls.ext_deps = ext_deps + # "Is ext_deps covered by some good combination" -- an AND of the + # per-dependency masks, which is the same answer as testing every + # combination for a superset (a combination covers the union exactly when + # it covers each closure) but does not rescan the comb list, and reuses + # the mask each dependency was given the first time it was seen. + mask = graph.full_mask + for dep in wavs: + mask &= graph.compat_mask(dep) + if not mask: break - + this_comb_good = bool(mask) + if diag_number and this_comb_good and cls.ext_deps: helicity = dict([(a.get_id(), a.hel) for a in cls.ext_deps]) - this_hel = [helicity[i] for i in range(1, len(helicity)+1)] - hel_number = 1 + all_hel.index(tuple(this_hel)) + this_hel = [helicity[i] for i in range(1, len(helicity)+1)] + hel_number = 1 + External.all_hel_index[tuple(this_hel)] if (hel_number,diag_number) in bad_hel_amp: this_comb_good = False @@ -200,7 +419,10 @@ class External(MathsObject): # Could get this from dag but I'm worried about preserving order wavs_same_leg = {} good_wav_combs = [] - max_wav_num = 0 + max_wav_num = 0 + # helicity tuple -> its row in the original NHEL table, filled by + # HelicityRecycler.get_good_hel once that table is complete + all_hel_index = {} def __init__(self, arguments, old_name): super().__init__(arguments, old_name, 'external') @@ -390,6 +612,7 @@ def __init__(self, good_elements, bad_amps=[], bad_amps_perhel=[], gauge='U'): External.num_externals = 0 External.wavs_same_leg = {} External.good_wav_combs = [] + External.all_hel_index = {} Internal.max_wav_num = 0 Internal.num_internals = 0 @@ -397,8 +620,16 @@ def __init__(self, good_elements, bad_amps=[], bad_amps_perhel=[], gauge='U'): Amplitude.max_amp_num = 0 self.last_category = None self.good_elements = good_elements - self.bad_amps = bad_amps - self.bad_amps_perhel = bad_amps_perhel + # Both are only ever asked "is this one in you?" -- bad_amps once per + # amplitude line, bad_amps_perhel once per (amplitude, helicity + # combination). As lists that is a linear scan every time, which was + # affordable while they held the handful of identically-zero + # amplitudes. The C-parity de-duplication now adds EVERY amplitude of + # every dropped mirror row: 128 x 28215 entries on g g > t t~ 4g and + # 128 x 126630 on g g > 6g, turning the scan into the dominant cost of + # the whole recycling step. Sets make the same question O(1). + self.bad_amps = set(bad_amps) + self.bad_amps_perhel = set(bad_amps_perhel) # Default file names self.input_file = 'matrix_orig.f' @@ -411,8 +642,31 @@ def __init__(self, good_elements, bad_amps=[], bad_amps_perhel=[], gauge='U'): self.template_dict['helas_calls'] = [] self.template_dict['jamp_lines'] = '\n' self.template_dict['amp2_lines'] = '\n' - self.template_dict['ncomb'] = '0' - self.template_dict['nwavefuncs'] = '0' + self.template_dict['ncomb'] = '0' + self.template_dict['nwavefuncs'] = '0' + # C-parity de-duplication: fortran that copies a dropped C-partner's + # |M|^2 back from its representative (TS(flip)=TS(rep)). Empty unless + # gen_ximprove supplies C-symmetric pairs: it keeps the partner's + # helicity row but adds all its amplitudes to bad_amps_perhel, so their + # HELAS calls are never generated and only the representatives are + # computed. The indices here are the optim's re-numbered helicities. + self.template_dict['csym_reuse'] = '\n' + # The other half of that de-duplication, used by the standalone driver: + # marks each dropped partner's helicity row dead so the color stage + # skips it instead of summing colors over a row of zeros. Empty (every + # row live) unless the same pairs are supplied. + self.template_dict['csym_dead'] = '\n' + # Optional IF/ENDIF around the AMP2 (multi-channel) and JAMP2 + # (colour-flow) accumulation of the helicity loop, so a config can + # contribute to the |M|^2 sum without contributing to either weight. + # Empty -- every kept config feeds both, as it always did -- unless + # gen_ximprove is recycling a matrix element SHARED by a crossing, whose + # config set has to cover every member of the class: a config that is + # dead for the caller at hand still has non-zero individual diagrams and + # JAMPs, and those are not the gauge-invariant |M|^2. See + # gen_ximprove.gensym.get_helicity. + self.template_dict['dead_row_if'] = '\n' + self.template_dict['dead_row_endif'] = '\n' self.dag = DAG() @@ -424,10 +678,30 @@ def __init__(self, good_elements, bad_amps=[], bad_amps_perhel=[], gauge='U'): self.old_out_name = '' self.loop_var = 'K' + # The color stage reads AMP in place, over a plain loop on the helicity + # rows -- what it always did. set_gather_lines replaces that with the + # gathered form where it pays; anything that does not go through it + # (another caller, the zero matrix element) keeps what is set here. + # + # The two loop holes sit inside a literal DO/ENDDO pair in the template + # so that the exporter's fortran writer still sees the helicity loop and + # indents its body: hr_gather_open is what follows the DO, and + # hr_gather_close what follows the ENDDO's comment marker. + self.amp_gather = False + self.template_dict['hr_gather_decl'] = '' + self.template_dict['hr_gather_open'] = '%s = 1, NCOMB' % self.loop_var + self.template_dict['hr_gather_close'] = self.loop_var self.all_hel = [] self.hel_filt = True self.gauge = gauge + # statements per matrix_optimamp.f; 0 keeps the unrolled sequence + # inline in matrix_optim.f as it always was + self.amp_chunk_size = AMP_CHUNK_SIZE_DEFAULT + # rows gathered at a time, and the size of AMP the gather starts paying + # at; see set_gather_lines + self.gather_block = GATHER_BLOCK + self.gather_min_bytes = GATHER_MIN_BYTES def set_input(self, file): if 'born_matrix' in file: @@ -487,18 +761,33 @@ def function_call(self, line): # string manipulation + # Contiguous per-row copy of AMP the color flows read from when the stage + # gathers, and the index of the row inside the gathered block. Both are + # declared by set_gather_lines below and only ever appear together. + AMP_GATHER = 'AMPK' + GATHER_LANE = 'HRL' + def add_amp_index(self, matchobj): - old_pat = matchobj.group() - new_pat = old_pat.replace('AMP(', 'AMP( %s,' % self.loop_var) - - #new_pat = f'{self.loop_var},{old_pat[:-1]}{old_pat[-1]}' - return new_pat + # The recycled AMP is helicity major -- that is the layout the rewritten + # helas block WRITES, one CombineAmp filling one amplitude for a whole + # set of rows at once -- so a read of row K is AMP(K,i). Where the stage + # gathers, the row has already been copied out contiguously and the read + # becomes AMPK(i,HRL) instead. + args = matchobj.group()[len('AMP('):-1] + if self.amp_gather: + return '%s(%s,%s)' % (self.AMP_GATHER, args, self.GATHER_LANE) + return 'AMP( %s,%s)' % (self.loop_var, args) def add_indices(self, line): - '''Add loop_var index to amp and output variable. - Also update name of output variable.''' - # Doesnt work if the AMP arguments contain brackets - new_line = re.sub(r'\WAMP\(.*?\)', self.add_amp_index, line) + '''Point the amplitude reads at the gathered row and update the name of + the output variable.''' + # The character in front is looked at rather than eaten, so that an + # AMP( opening the statement is indexed too -- which is what a line + # like "AMP(31) = AMP(31) + AMP(1)" needs. One level of brackets is + # allowed inside the argument: with the color flow definitions emitted + # as operand tables the index is itself a table lookup. + new_line = re.sub(r'(? g g g g g. The table is complete by now -- every + # DATA (NHEL line precedes the first HELAS call. + External.all_hel_index = dict([(hel,i) for i,hel in enumerate(self.all_hel)]) External.hel_ranges = [set() for hel in next(iter(External.good_hel))] for comb in External.good_hel: for i, hel in enumerate(comb): @@ -666,39 +964,41 @@ def nhel_string(self, hel_comb): def read_orig(self): - with open(self.input_file, 'r') as input_file: + # The HELAS call sequence may live in matrix_origamp.f rather than + # inline; splice_amp_chunks puts those lines back where they were. + input_file = splice_amp_chunks(self.input_file) - self.prepare_bools() + self.prepare_bools() - for line_num, line in tqdm(enumerate(input_file), total=get_num_lines(self.input_file)): - if line_num == 0: - line_cache = line - continue - - if '!SKIP' in line: - continue - - char_5 = '' - try: - char_5 = line[5] - except IndexError: - pass - if char_5 == '$': - line_cache = undo_multiline(line_cache, line) - continue + for line_num, line in tqdm(enumerate(input_file), total=get_num_lines(self.input_file)): + if line_num == 0: + line_cache = line + continue - line, line_cache = line_cache, line + if '!SKIP' in line: + continue - self.get_old_name(line) - self.get_good_hel(line) - self.get_amp_stuff(line_num, line) - call_type = self.function_call(line) - self.get_gwc(line, call_type) + char_5 = '' + try: + char_5 = line[5] + except IndexError: + pass + if char_5 == '$': + line_cache = undo_multiline(line_cache, line) + continue - - if call_type in ['external', 'internal', 'amplitude']: - self.template_dict['helas_calls'] += self.unfold_helicities( - line, call_type) + line, line_cache = line_cache, line + + self.get_old_name(line) + self.get_good_hel(line) + self.get_amp_stuff(line_num, line) + call_type = self.function_call(line) + self.get_gwc(line, call_type) + + + if call_type in ['external', 'internal', 'amplitude']: + self.template_dict['helas_calls'] += self.unfold_helicities( + line, call_type) self.template_dict['nwavefuncs'] = max(External.num_externals, Internal.max_wav_num, External.max_wav_num) # filter out uselless call @@ -725,7 +1025,77 @@ def read_template(self): out_file.write(line) out_file.close() + def amp_chunk_paths(self): + """(chunk template, chunk file stem) for this matrix element, or None + when the exporter did not write a chunk template for it.""" + + if not self.output_file.endswith('_optim.f'): + return None + template_file = '%s_ampchunk.f' % self.template_file[:-len('.f')] + if not os.path.exists(template_file): + return None + return template_file, self.output_file[:-len('_optim.f')] + + def write_amp_chunks(self): + """Move the unrolled HELAS call sequence out of matrix_optim.f and + into matrix_optimamp.f, one subroutine per amp_chunk_size + statements, leaving the calls to them behind. + + That sequence is essentially the whole recycled matrix element at high + multiplicity, and as one basic block inside one routine it is what + makes the file uncompilable; split up it also gets to be compiled + apart from -- and at a lower optimisation level than -- the JAMP and + colour blocks, which are the only part -O has anything to do on. + + Returns the number of chunk files written; 0 leaves the sequence inline + and matrix_optim.f exactly as it was before. + """ + + paths = self.amp_chunk_paths() + if not paths: + return 0 + template_file, stem = paths + # a shorter sequence than last time must not leave live orphans behind + for stale in glob.glob('%s_optimamp*.f' % stem): + os.remove(stale) + + lines = self.template_dict['helas_calls'].split('\n') + if self.amp_chunk_size <= 0 or len(lines) <= self.amp_chunk_size: + return 0 + chunks = chunk_statements(lines, self.amp_chunk_size) + if len(chunks) < 2: + return 0 + + template = open(template_file).read() + name, args = get_subroutine_signature(template) + # the leading blank puts the comments below in column 1: the template + # hole itself is indented, and a comment marker has to start the line + driver = ['', + 'C The unrolled HELAS call sequence lives in ' + '%s_optimamp.f,' % os.path.basename(stem), + 'C one subroutine per %d statements.' % self.amp_chunk_size] + for i, chunk in enumerate(chunks): + chunk_dict = dict(self.template_dict) + chunk_dict['chunk_id'] = str(i + 1) + # the template hole is indented; the leading newline keeps the + # first call of the slice in the same columns as all the others, + # which a long one would otherwise be split out of + chunk_dict['helas_calls'] = '\n' + '\n'.join(chunk) + text = Template(template).safe_substitute(chunk_dict) + text = '\n'.join([do_multiline(sub) for sub in text.split('\n')]) + with open('%s_optimamp%d.f' % (stem, i + 1), 'w') as chunk_file: + chunk_file.write(text) + driver.append(' CALL %s(%s)' + % (Template(name).safe_substitute(chunk_id=i + 1), + args)) + self.template_dict['helas_calls'] = '\n'.join(driver) + return len(chunks) + def write_zero_matrix_element(self): + paths = self.amp_chunk_paths() + if paths: + for stale in glob.glob('%s_optimamp*.f' % paths[1]): + os.remove(stale) try: os.remove(self.output_file) except Exception: @@ -734,25 +1104,352 @@ def write_zero_matrix_element(self): os.symlink(input_file, self.output_file) + _NGRAPHS_RE = re.compile(r'^\s*PARAMETER\s*\(\s*NGRAPHS\s*=\s*(\d+)\s*\)', + re.IGNORECASE) + + def template_ngraphs(self): + """NGRAPHS of the driver template, or 0 when it does not say. + + The exporter fills it in, so it is there before anything is parsed -- + which is what the gather decision below needs, since the color flow + lines are rewritten as they are read.""" + + try: + with open(self.template_file) as fsock: + for line in fsock: + found = self._NGRAPHS_RE.match(line) + if found: + return int(found.group(1)) + except IOError: + pass + return 0 + + def set_gather_lines(self): + """Decide how the color stage reads the amplitudes, and fill the holes + of the driver template that say so. + + AMP is helicity major -- that is the layout the rewritten helas block + WRITES, one CombineAmp filling one amplitude for a whole set of rows at + once, so a column of AMP is what it produces. Reading a row back out of + it walks NGRAPHS entries that are NCOMB apart, i.e. one cache line per + amplitude, where the unrecycled matrix element reads AMP contiguously. + + The cure is to copy the row into a contiguous buffer first, NHRBLK rows + at a time so that the copy itself reads whole lines. But the rows that + share a line are also visited within NHRBLK iterations of each other, so + below a certain size the hardware already gets that reuse for free and + the copy is a second pass over AMP for nothing. Hence the size gate, see + GATHER_MIN_BYTES: most madevent processes are below it and keep the very + loop the template has always carried, unchanged.""" + + ngraphs = self.template_ngraphs() + ncomb = len(self.good_elements) + self.amp_gather = bool(ngraphs) and \ + ngraphs * ncomb * 16 >= self.gather_min_bytes + if not self.amp_gather: + return + + buf, lane = self.AMP_GATHER, self.GATHER_LANE + # Each block continues a line the template already indented, so its + # first entry carries no indentation of its own. + self.template_dict['hr_gather_decl'] = '\n'.join([ + 'INTEGER NHRBLK', + ' PARAMETER (NHRBLK=%d)' % self.gather_block, + ' INTEGER KB, HRNL, %s' % lane, + # One lane per gathered row, and a lane is contiguous (fortran is + # column major). Deliberately NOT saved: it has to follow the same + # storage class as AMP, which is a plain local. SMATRIX1_MULTI + # carries an !$OMP PARALLEL over the matrix element and the link + # line already passes -fopenmp; the day FFLAGS does too, gfortran + # makes the locals automatic and thread private -- and an explicit + # SAVE would be the one thing left shared between the threads. + ' COMPLEX*16 %s(NGRAPHS,NHRBLK)' % buf]) + # The template's own DO opens the block loop; the row loop is nested + # inside it and K, which every rewritten line still uses, becomes the + # row of the block being read rather than a loop variable. + self.template_dict['hr_gather_open'] = '\n'.join([ + 'KB = 1, NCOMB, NHRBLK', + ' HRNL = MIN(NHRBLK, NCOMB-KB+1)', + ' DO I = 1, NGRAPHS', + ' DO %s = 1, HRNL' % lane, + ' %s(I,%s) = AMP(KB+%s-1,I)' % (buf, lane, lane), + ' ENDDO', + ' ENDDO', + ' DO %s = 1, HRNL' % lane, + ' %s = KB + %s - 1' % (self.loop_var, lane)]) + # ... and the template's own ENDDO closes the row loop, so only the + # block loop is left to close here. + self.template_dict['hr_gather_close'] = '\n'.join([ + lane, + ' ENDDO ! KB']) + def generate_output_file(self): if not self.good_elements: misc.sprint("No helicity", self.input_file) self.write_zero_matrix_element() return - + atexit.register(self.clean_up) + # before read_orig: it rewrites the color flow lines as it reads them, + # and how they spell an amplitude is what this decides + self.set_gather_lines() self.read_orig() + # Two chunkers live here, one per backend, and exactly one of them + # fires for any given matrix element. write_amp_chunks is madevent's: + # it cuts template_dict['helas_calls'] up BEFORE the output is written, + # and only for a matrix_optim.f whose exporter left a matching + # matrix_ampchunk.f template next to it. split_helas_block is + # standalone's: it re-reads the file just written and lifts the block + # out of it, and only when the exporter set chunk_spec/chunk_stmts/ + # chunk_file. Neither backend configures the other's, so they never + # both cut the same file -- do not "unify" them without checking that. + self.write_amp_chunks() self.read_template() + self.split_helas_block() atexit.unregister(self.clean_up) + #=========================================================================== + # Splitting the recycled helas block out of MATRIX + #=========================================================================== + # The recycled MATRIX holds the whole amplitude construction (externals, + # shared wavefunctions, P1N currents, CombineAmp) as one straight-line + # block. For a dense process that block is enormous -- g g > t t~ g g g + # gives ~54k statements -- and a single function that size defeats the + # optimizer: gfortran spends ~105 s at -O2 on it AND produces slower code + # than if it were split (register allocation degrades on one huge body). + # + # Moving the block into its own file, cut into chunk subroutines that share + # W/AMP by reference, and compiling that file at -O0 fixes both ends: the + # block is CALL-bound (its work happens inside libdhelas, already compiled + # at -O2), so its own optimization level barely matters for runtime. + # Measured on g g > t t~ g g g: compile 105 s -> ~18 s, runtime 0.78 s -> + # ~0.52 s, same matrix element. + # + # Small processes must stay monolithic: there the -O2 monolith optimizes + # perfectly and splitting costs runtime (g g > t t~ g g: 0.19 -> 0.28 s). + # Hence a threshold on the number of statements, not a chunk count. + + # a statement that opens a block we must not cut through + _IF_THEN = re.compile(r'^\s*IF\s*\(.*\)\s*THEN\s*$', re.IGNORECASE) + _END_IF = re.compile(r'^\s*END\s*IF\b', re.IGNORECASE) + _AMP_INIT = re.compile(r'^\s*AMP\s*\(\s*:\s*,\s*:\s*\)\s*=', re.IGNORECASE) + _BLOCK_START = re.compile(r'^\s*(CALL\s|IF\s*\(\s*IAND)', re.IGNORECASE) + # What follows the helas calls: the color flows in the madevent templates + # (which write them out, or open a helicity loop over them), or -- for a + # driver that does something else entirely with AMP, as the standalone one + # does -- an explicit marker comment right after the last call. + _BLOCK_END = re.compile(r'^\s*(JAMP\s*\(|DO\s+K\s*=\s*1\s*,\s*NCOMB' + r'|C\s+END OF RECYCLED HELAS BLOCK)', + re.IGNORECASE) + + def _group_statements(self, block): + """Group the block's physical lines into atomic units. + + A unit is a full fortran statement (continuation lines carry '&' or '$' + in column 6, comments and blank lines attach to the statement they + precede), and an IF(...)THEN ... ENDIF guard -- which is how a merged + process' per-flavor mask wraps a P1N/CombineAmp pair -- is kept whole. + """ + stmts, cur, depth = [], [], 0 + for line in block: + stripped = line.strip() + is_comment = bool(line) and line[0] in 'Cc*!' + cont = len(line) > 5 and line[5] in '&$' + if cur and (cont or is_comment or not stripped or depth > 0): + cur.append(line) + elif not cur: + cur.append(line) + else: + stmts.append(cur) + cur = [line] + if not is_comment: + if self._IF_THEN.match(line): + depth += 1 + elif self._END_IF.match(line) and depth > 0: + depth -= 1 + # the ENDIF closes the guard: the unit is complete + if depth == 0: + stmts.append(cur) + cur = [] + if cur: + stmts.append(cur) + return stmts + + @staticmethod + def _starts_with_combine(stmt): + """A CombineAmp consumes the TMP its immediately preceding P1N call + wrote, so a chunk must never begin with one.""" + for line in stmt: + if line.strip() and not line[0] in 'Cc*!': + return 'combineamp' in line.lower() + return False + + def split_helas_block(self): + """Move the recycled helas block of MATRIX into chunk subroutines in a + separate file. No-op unless the caller configured chunk_stmts and a + chunk_spec, or when the block is below the threshold. + + Returns the number of chunks written (0 when nothing was split).""" + spec = getattr(self, 'chunk_spec', None) + limit = getattr(self, 'chunk_stmts', 0) + chunk_file = getattr(self, 'chunk_file', None) + if not spec or not limit or not chunk_file: + return 0 + + with open(self.output_file) as fsock: + lines = fsock.read().splitlines() + + def find(regex, start=0): + for i in range(start, len(lines)): + if regex.match(lines[i]): + return i + return -1 + + amp_init = find(self._AMP_INIT) + if amp_init == -1: + return 0 + begin = find(self._BLOCK_START, amp_init) + if begin == -1: + return 0 + end = find(self._BLOCK_END, begin) + if end == -1: + return 0 + + stmts = self._group_statements(lines[begin:end]) + if len(stmts) <= limit: + # small enough to stay in MATRIX: make sure no chunk file survives + # from an earlier, larger pass. + self.clean_chunk_files(chunk_file) + return 0 + + chunks, cur = [], [] + for stmt in stmts: + if cur and len(cur) >= limit and not self._starts_with_combine(stmt): + chunks.append(cur) + cur = [] + cur.append(stmt) + if cur: + chunks.append(cur) + if len(chunks) <= 1: + self.clean_chunk_files(chunk_file) + return 0 + + prefix = spec['name'] + # Which shared objects the chunks take by reference: whichever of the + # candidates the block actually mentions. Deriving it from the block + # rather than from the caller keeps the signature right whatever the + # enclosing MATRIX happens to declare -- IC only exists when the + # crossing machinery threads it, the CURRENT_*_MASK arrays only for a + # merged process. + block_text = '\n'.join(lines[begin:end]) + used = [(name, decl) for name, decl in spec['candidates'] + if re.search(r'\b%s\b' % re.escape(name), block_text)] + args = ', '.join(name for name, _ in used) + preamble = '\n'.join( + [spec['prologue']] + [decl for _, decl in used] + + [spec['locals'], spec['epilogue']]) + preamble = Template(preamble).safe_substitute(self.template_dict) + + calls, bodies = [], [] + for i, chunk in enumerate(chunks, 1): + name = '%s%d' % (prefix, i) + calls.append(' CALL %s(%s)' % (name, args)) + body = [' SUBROUTINE %s(%s)' % (name, args), preamble] + for stmt in chunk: + body.extend(stmt) + body.append(' END') + bodies.append('\n'.join(body)) + + self.write_chunk_files(chunk_file, bodies) + with open(self.output_file, 'w') as fsock: + fsock.write('\n'.join(lines[:begin] + calls + lines[end:]) + '\n') + return len(chunks) + + @staticmethod + def clean_chunk_files(chunk_file): + """Drop every chunk file of an earlier, larger pass. The makefile takes + these with a wildcard, so an orphan left behind is still compiled and + still linked.""" + + stem = chunk_file[:-2] if chunk_file.endswith('.f') else chunk_file + for stale in glob.glob('%s*.f' % stem): + os.remove(stale) + + def write_chunk_files(self, chunk_file, bodies): + """Spread the chunk subroutines over several source files. + + Cutting MATRIX into chunk subroutines makes the file compilable, but as + long as they all land in ONE file the compiler still reads the whole + thing as a single translation unit: one process, one core, and a heap + that grows with the file. g g > 6g gives 419 MB and 9.8M lines, on + which gfortran sat at 15 GB and climbing, single threaded, while the + other 17 cores of the machine did nothing. + + The subroutines are independent -- they share W/AMP by reference and + nothing else -- so which file each one lives in is free. Spread them + over chunk_nfiles files and `make -j` compiles them at once, each + process holding only its own share. Same trick, and the same reason, as + the madevent side writing matrix_optimamp.f per chunk. + + Returns the list of files written.""" + + stem = chunk_file[:-2] if chunk_file.endswith('.f') else chunk_file + # a shorter sequence than last time must not leave live orphans behind: + # the makefile picks these up with a wildcard. + for stale in glob.glob('%s*.f' % stem): + os.remove(stale) + + nfiles = getattr(self, 'chunk_nfiles', 0) + if not nfiles or nfiles < 1: + nfiles = 1 + nfiles = min(nfiles, len(bodies)) + + written = [] + # contiguous, balanced: the first (len % nfiles) files take one extra, + # which keeps the subroutine numbering monotonic across the set. + per, extra = divmod(len(bodies), nfiles) + at = 0 + for i in range(nfiles): + take = per + (1 if i < extra else 0) + path = '%s%d.f' % (stem, i + 1) + with open(path, 'w') as fsock: + fsock.write('\n\n\n'.join(bodies[at:at + take]) + '\n') + written.append(path) + at += take + return written + def clean_up(self): pass +# get_arguments walks its line character by character, and unfold_helicities +# asks it again for every object it unfolds out of that line: 905 351 calls over +# the 8 143 HELAS lines of g g > g g g g g, all but ~50 000 of them a repeat of +# the line just parsed, and 3.7 s of a 16.8 s (profiled) recycling step. Keep the +# last few answers -- the callers walk the file one line at a time, so a handful +# of slots is all it takes -- and hand out a copy, since a shared mutable list is +# not what a caller that goes on to substitute arguments into it expects. +_ARGUMENT_CACHE = {} +_ARGUMENT_CACHE_SIZE = 32 + + def get_arguments(line): '''Find the substrings separated by commas between the first - closed set of parentheses in 'line'. + closed set of parentheses in 'line'. ''' + try: + return list(_ARGUMENT_CACHE[line]) + except KeyError: + pass + arguments = parse_arguments(line) + if len(_ARGUMENT_CACHE) >= _ARGUMENT_CACHE_SIZE: + _ARGUMENT_CACHE.clear() + _ARGUMENT_CACHE[line] = arguments + return list(arguments) + + +def parse_arguments(line): + '''The uncached get_arguments.''' start_idx = None call_idx = line.upper().find('CALL ') if call_idx != -1: @@ -865,15 +1562,37 @@ def split_amps(line, new_amps, gauge): # Remove the one that occurs the most occur.pop(to_remove) - lines = [] + lines = [] + # Which amplitudes carry a given wavefunction, one bit per amplitude. The + # selection below was a rescan of every amplitude for every combination of + # columns -- 12.5 million `w in amp.args` evaluations on g g > g g g g g, + # the largest single cost left in the recycling step -- and it is an AND of + # these masks instead. A HELAS call never uses the same wavefunction twice, + # so a name identifies the column it came from and asking "is w anywhere in + # this amplitude" is the same question as asking its column. + amp_masks = {} + for i, amp in enumerate(new_amps): + bit = 1 << i + for a in amp.args: + amp_masks[a] = amp_masks.get(a, 0) | bit + all_amps_mask = (1 << len(new_amps)) - 1 # Get the wavs per column - wav_name = [o.keys() for o in occur] + wav_name = [o.keys() for o in occur] for wfcts in product(*wav_name): # Select the amplitudes produced by wfcts - sub_amps = [amp for amp in new_amps - if all(w in amp.args for w in wfcts)] - if not sub_amps: + mask = all_amps_mask + for w in wfcts: + mask &= amp_masks.get(w, 0) + if not mask: + break + if not mask: continue + # lowest bit first, so sub_amps keeps the order of new_amps + sub_amps = [] + while mask: + low = mask & -mask + sub_amps.append(new_amps[low.bit_length() - 1]) + mask ^= low if len(sub_amps) ==1: lines.append(apply_args(line, [i.args for i in sub_amps]).replace('\n','')) @@ -956,23 +1675,36 @@ def do_multiline(line): comment = None char_limit = 72 if len(line) > char_limit: + indent = '' + for char in line[6:]: + if char == ' ': + indent += char + else: + break + + # The split must leave at least one character of the statement on the + # first line. Searching from column 0 lets a statement with no internal + # blank before the limit -- JAMPF(2,1)=+2D0*(-IMAG1*JAMP(3,1)-...) is + # one, and so is any long JAMP -- split inside its own indent: the + # first line comes out blank and the continuation after it then + # attaches to the PREVIOUS statement, which fortran rejects. + first_split = 6 + len(indent) + split_line = [] remaining = line + floor = first_split while len(remaining) > char_limit: - split_at = remaining.rfind(' ', 0, char_limit + 1) - if split_at <= 0: + split_at = remaining.rfind(' ', floor + 1, char_limit + 1) + if split_at <= floor: split_line.append(remaining[:char_limit]) remaining = remaining[char_limit:] else: split_line.append(remaining[:split_at+1]) remaining = remaining[split_at+1:] + # the continuations carry no indent of their own, it is prepended + # by the join below + floor = 0 split_line.append(remaining) - indent = '' - for char in line[6:]: - if char == ' ': - indent += char - else: - break line = f'\n ${indent}'.join(split_line) if not comment: diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index 5d5b0c63dd..fdfceab133 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -4599,13 +4599,17 @@ def default_setup(self): self.add_param('hel_filtering', True, hidden=True, include=False, comment='filter in advance the zero helicities when doing helicity per helicity optimization.') self.add_param('hel_splitamp', True, hidden=True, include=False, comment='decide if amplitude aloha call can be splitted in two or not when doing helicity per helicity optimization.') self.add_param('hel_zeroamp', True, hidden=True, include=False, comment='decide if zero amplitude can be removed from the computation when doing helicity per helicity optimization.') + self.add_param('blas_color_sum', True, hidden=True, comment='sum the color at every helicity in one batched BLAS-3 call --only where the code was generated with such a call, i.e. a large enough color basis and a BLAS to link against--') self.add_param('SDE_strategy', 1, allowed=[1,2], fortran_name="sde_strat", comment="decide how Multi-channel should behaves \"1\" means full single diagram enhanced (hep-ph/0208156), \"2\" use the product of the denominator") self.add_param('global_flag', '-O', include=False, hidden=True, comment='global fortran compilation flag, suggestion -fbound-check', fct_mod=(self.make_clean, ('Source'),{})) self.add_param('aloha_flag', '', include=False, hidden=True, comment='global fortran compilation flag, suggestion: -ffast-math', fct_mod=(self.make_clean, ('Source/DHELAS'),{})) self.add_param('matrix_flag', '', include=False, hidden=True, comment='fortran compilation flag for the matrix-element files, suggestion -O3', - fct_mod=(self.make_Ptouch, ('matrix'),{})) + fct_mod=(self.make_Ptouch, ('matrix'),{})) + self.add_param('amp_flag', '', include=False, hidden=True, comment='fortran compilation flag for the amplitude (HELAS call) files split out of the matrix elements; it lands after matrix_flag. -O0 buys about 1.5x on their compile but costs 19%% of the run time at g g > t t~ 3g and 61%% at g g > 5g -- the helicity-recycled sequence is not the flat run of external calls the un-recycled one is -- so it is only worth setting when the compile itself is the problem', + fct_mod=(self.make_Ptouch, ('matrix'),{})) + self.add_param('amp_chunk_size', 2000, include=False, hidden=True, comment='number of fortran statements per amplitude file when the helicity-recycled matrix element is split up; 0 keeps the unrolled call sequence inline in matrix_optim.f') self.add_param('vector_size', 1, include='vector.inc', hidden=True, comment='lockstep size for parralelism run', fortran_name='WARP_SIZE', fct_mod=(self.reset_simd,(),{})) self.add_param('nb_warp', 1, include='vector.inc', hidden=True, comment='number of warp for parralelism run', diff --git a/madgraph/various/misc.py b/madgraph/various/misc.py index 15e28d903b..af7b0b5a0e 100755 --- a/madgraph/various/misc.py +++ b/madgraph/various/misc.py @@ -2141,9 +2141,19 @@ def post_banner(self, date): from madgraph import MG5DIR import madgraph.interface.madgraph_interface as madgraph_interface to_add = [] - ff = open(pjoin(MG5DIR,'input','authors.md'), 'r') + # input/authors.md is written by bin/create_release.py and is therefore + # absent from a git checkout. This banner is purely cosmetic and runs on + # the crash path (EasterEgg('error')), so a missing or malformed file + # must never raise here. + author_path = pjoin(MG5DIR,'input','authors.md') + if not os.path.exists(author_path): + return "" + ff = open(author_path, 'r') for line in ff: - author, fdate = line.split() + data = line.split() + if len(data) != 2: + continue + author, fdate = data year, month, day = [int(i) for i in fdate.split('-')] if (day, month) == date: to_add.append((author, year)) diff --git a/madgraph/various/process_checks.py b/madgraph/various/process_checks.py index 423ec5d603..eb5879a19c 100755 --- a/madgraph/various/process_checks.py +++ b/madgraph/various/process_checks.py @@ -3906,6 +3906,844 @@ def output_flavor(comparison_results, output='text'): return fail_proc +#=============================================================================== +# check_crossing +#=============================================================================== +# Driver script run in a *fresh* interpreter for every compiled matrix2py +# module. Importing an f2py .so pollutes the importing interpreter (the module +# name 'matrix2py' can only be bound once and its Fortran COMMON blocks leak +# globally), so each module has to be probed in its own subprocess; the request +# and the answer are exchanged as JSON through files/stdout. +_CROSSING_DRIVER = r''' +import sys, json +import numpy as np +req = json.load(open(sys.argv[1])) +sys.path.insert(0, req["pdir"]) +import matrix2py +from flavor_dispatch import FlavorDispatch +me = FlavorDispatch(matrix2py) +me.initialisemodel(req["card"]) +out = {} +if req["mode"] == "enumerate": + nflav, nexternal, ncross = me.flavor_layout() + out["layout"] = [nflav, nexternal, ncross] + entries = [] + for cross in range(ncross): + for flav in range(1, nflav + 1): + idx = cross * nflav + flav + pdg = me.pdg_for_index(idx) + if pdg is not None: + entries.append([idx, cross, flav, list(pdg)]) + out["entries"] = entries +elif req["mode"] == "evaluate": + values = [] + for item in req["items"]: + P = np.asfortranarray(np.array(item["momenta"], dtype=float).T) + values.append(float(me.smatrix(P, int(item["index"])))) + out["values"] = values +sys.stdout.write("CROSSJSON:" + json.dumps(out) + "\n") +''' + + +def _crossing_build_env(): + """Environment for building/running the f2py module. + + numpy>=1.26 drives f2py through the meson backend, whose ``meson`` and + ``ninja`` executables normally sit next to the running interpreter. Prepend + that directory to PATH so ``make matrix2py.so`` finds them even when they are + not on the ambient PATH. + """ + env = dict(os.environ) + bindir = os.path.dirname(os.path.abspath(sys.executable)) + env['PATH'] = bindir + os.pathsep + env.get('PATH', '') + return env + + +def _crossing_build_f2py(pdir, env): + """Compile ``matrix2py.so`` in *pdir*; return True on success. + + The system ``f2py`` is unusable on some setups (dangling interpreter, or the + distutils backend removed on numpy>=1.26), so the makefile is driven with + ``F2PY=" -m numpy.f2py"`` which always resolves to the running + interpreter's f2py. A plain ``make matrix2py.so`` is tried first so a + working system f2py is still honoured. + + Success is that the module IMPORTS, not that a file appeared. f2py can fail + to build anything and still exit 0 (it does exactly that when handed a link + flag its meson backend does not parse), and the makefile touches the bare + .so unconditionally to give make a timestamp -- so "the target exists" is + not evidence of anything. Taking it as evidence turned a hard build failure + into a check_crossing that silently returned no comparison at all, instead + of the build_failed that makes the caller skip. + """ + for f2py in (None, '%s -m numpy.f2py' % sys.executable): + for stale in glob.glob(pjoin(pdir, 'matrix2py*.so')): + try: + os.remove(stale) + except OSError: + pass + cmd = ['make', 'matrix2py.so'] + if f2py is not None: + cmd.append('F2PY=%s' % f2py) + with open(os.devnull, 'w') as devnull: + ret = subprocess.call(cmd, cwd=pdir, stdout=devnull, + stderr=devnull, env=env) + if ret == 0 and glob.glob(pjoin(pdir, 'matrix2py*.so')) \ + and _crossing_f2py_importable(pdir, env): + return True + return False + + +def _crossing_f2py_importable(pdir, env): + """True when ``import matrix2py`` actually succeeds inside *pdir*. + + Run out of process: the module is a compiled extension, it is rebuilt per + directory under the same name, and a failed dlopen must not be able to hurt + the interpreter driving the check. + """ + probe = ('import sys\n' + 'sys.path.insert(0, %r)\n' + 'import matrix2py\n' + 'print("CROSSIMPORT_OK")\n' % pdir) + try: + proc = subprocess.Popen([sys.executable, '-c', probe], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, cwd=pdir, env=env) + output = proc.communicate()[0].decode() + except OSError: + return False + if 'CROSSIMPORT_OK' in output: + return True + logger.debug("matrix2py built in %s but does not import:\n%s" + % (pdir, output)) + return False + + +def _crossing_run_driver(pdir, request, env): + """Run the JSON driver against the module in *pdir*; return the answer dict + (or None on failure).""" + import json + import tempfile + request = dict(request) + request['pdir'] = pdir + script = pjoin(pdir, '_crossing_driver.py') + with open(script, 'w') as fsock: + fsock.write(_CROSSING_DRIVER) + fd, req_path = tempfile.mkstemp(suffix='.json', dir=pdir) + with os.fdopen(fd, 'w') as fsock: + json.dump(request, fsock) + try: + proc = subprocess.Popen([sys.executable, script, req_path], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, cwd=pdir, env=env) + output = proc.communicate()[0].decode() + finally: + try: + os.remove(req_path) + except OSError: + pass + for line in output.split('\n'): + if line.startswith('CROSSJSON:'): + return json.loads(line[len('CROSSJSON:'):]) + logger.debug("Crossing driver produced no answer in %s:\n%s" + % (pdir, output)) + return None + + +# The three standalone backends that decode an extended (crossing-carrying) +# flavor index. 'standalone' is the fortran default (f2py); the other two are +# the C++ / cudacpp-CPU-SIMD standalones. +CROSSING_EXPORTERS = ('standalone', 'standalone_cpp', 'standalone_mg7') + +# Vectorisation (SIMD) choices for the standalone_mg7 (cudacpp) backend; each +# maps to the madmatrix.mk 'BACKEND=cpp' build variant. 'auto' lets +# madmatrix pick the widest instruction set the host CPU supports. Only used by +# the standalone_mg7 crossing backend; ignored by the others. +MG7_SIMD_CHOICES = ('auto', 'none', 'sse4', 'avx2', '512y', '512z') + +# Floating-point precision choices for the standalone_mg7 (cudacpp) backend, each +# mapping to the madmatrix.mk 'FPTYPE=' build variant: 'd' double, 'f' float, +# 'm' mixed (double elsewhere, float in the colour algebra -- the madmatrix +# default). Only used by the standalone_mg7 crossing backend. +MG7_PRECISION_CHOICES = ('f', 'm', 'd') + + +def _crossing_pdg_entries(matrix_element, identity_only=False): + """Python enumeration of a matrix element's reachable extended flavor ids. + + Returns ``[(index, cross, flav0, pdg_tuple), ...]`` with a 0-based index + (``cross*NFLAV+flav0``) -- the encoding the C++/mg7 sigmaKin decodes. This + is the crossing twin of the fortran runtime GET_PDG_FOR_FLAVOR, used for the + backends that have no runtime PDG accessor. See + ProcessExporterFortran.compute_crossing_pdg_entries. + """ + if matrix_element is None: + # Correlation to a P* directory failed; the caller skips this module. + return None + import madgraph.iolibs.export_v4 as export_v4 + entries = export_v4.ProcessExporterFortran.compute_crossing_pdg_entries( + None, matrix_element, zero_based=True) + if identity_only: + entries = [e for e in entries if e[1] == 0] + return entries + + +# ── C++ standalone (standalone_cpp) ───────────────────────────────────────── +# A tiny driver that evaluates sigmaKin at the requested flavor_id and momenta. +# Each item gets a FRESH CPPProcess so the good-helicity cache (indexed by the +# reduced flavor) cannot carry a warmed-up crossing's helicity pattern into a +# different crossing of the same flavor -- exactly the recipe the acceptance +# test TestStandaloneCppCrossSymmetry uses. The request file holds, on the first +# line the number of items, then per item a flavor_id followed by 4*nexternal +# momentum components (E, px, py, pz per leg, in the leg order the crossed index +# expects them). +_CROSSING_CPP_DRIVER = r''' +#include +#include +#include +#include +#include "CPPProcess.h" +int main(int argc, char** argv){ + std::ifstream in(argv[1]); + int nitems; in >> nitems; + std::cout << std::setprecision(17); + for(int it = 0; it < nitems; it++){ + int fid; in >> fid; + CPPProcess process("../../Cards/param_card.dat"); + int npar = process.nexternal; + std::vector p; + for(int i = 0; i < npar; i++){ + double* m = new double[4]; + for(int j = 0; j < 4; j++) in >> m[j]; + p.push_back(m); + } + process.setMomenta(p); + double me = process.sigmaKin(fid); + std::cout << "ITEM " << it << " " << me << std::endl; + for(int i = 0; i < npar; i++) delete[] p[i]; + } + return 0; +} +''' + + +class _FortranCrossingBackend(object): + """The fortran standalone (f2py) crossing backend -- the historical path. + + Enumeration and evaluation both go through the compiled matrix2py module in + a fresh subprocess (see _CROSSING_DRIVER); the matrix element python object + is not needed because GET_PDG_FOR_FLAVOR resolves the crossed PDG at + runtime. + """ + output_format = 'standalone' + needs_matrix_element = False + + def __init__(self, options=None): + # options accepted for a uniform backend signature; --simd only applies + # to standalone_mg7. + pass + + def build(self, pdir, env): + return _crossing_build_f2py(pdir, env) + + def enumerate(self, pdir, matrix_element, card, env, identity_only): + answer = _crossing_run_driver( + pdir, {'mode': 'enumerate', 'card': card}, env) + if not answer: + return None + entries = [] + for idx, cross, flav, pdg in answer['entries']: + if identity_only and cross != 0: + continue + entries.append((idx, cross, flav, tuple(pdg))) + return entries + + def evaluate(self, pdir, items, card, env): + answer = _crossing_run_driver( + pdir, {'mode': 'evaluate', 'card': card, 'items': items}, env) + return answer['values'] if answer else [None] * len(items) + + +class _CppCrossingBackend(object): + """The C++ standalone (standalone_cpp) crossing backend. + + (`options` is accepted for a uniform backend constructor signature; the + SIMD/vectorisation choice only applies to standalone_mg7.) + + The crossed PDG of an extended flavor_id is computed in python (there is no + runtime accessor); evaluation compiles a small driver that news a fresh + CPPProcess per item and calls sigmaKin(flavor_id). + """ + output_format = 'standalone_cpp' + needs_matrix_element = True + + def __init__(self, options=None): + self.compiler = os.environ.get('CXX', 'g++') + + def build(self, pdir, env): + if not shutil.which(self.compiler): + return False + with open(os.devnull, 'w') as devnull: + rc = subprocess.call(['make'], cwd=pdir, stdout=devnull, + stderr=subprocess.STDOUT, env=env) + return rc == 0 and os.path.isfile(pjoin(pdir, 'CPPProcess.o')) + + def enumerate(self, pdir, matrix_element, card, env, identity_only): + return _crossing_pdg_entries(matrix_element, identity_only=identity_only) + + def evaluate(self, pdir, items, card, env): + with open(pjoin(pdir, 'driver_cross.cpp'), 'w') as fsock: + fsock.write(_CROSSING_CPP_DRIVER) + cxxflags = ['-O3', '-ffast-math', '-I../../src', '-I.', '-fPIC'] + libflags = ['-L../../lib', '-lmodel_sm'] + with open(os.devnull, 'w') as devnull: + rc = subprocess.call( + [self.compiler] + cxxflags + ['-c', '-o', 'driver_cross.o', + 'driver_cross.cpp'], + cwd=pdir, stdout=devnull, stderr=subprocess.STDOUT, env=env) + if rc != 0: + return [None] * len(items) + rc = subprocess.call( + [self.compiler, '-o', 'driver_cross', 'CPPProcess.o', + 'driver_cross.o'] + libflags, + cwd=pdir, stdout=devnull, stderr=subprocess.STDOUT, env=env) + if rc != 0: + return [None] * len(items) + req = pjoin(pdir, 'driver_cross.in') + with open(req, 'w') as fsock: + fsock.write('%d\n' % len(items)) + for item in items: + comps = ['%d' % int(item['index'])] + for leg in item['momenta']: + comps.extend('%.17e' % float(c) for c in leg) + fsock.write(' '.join(comps) + '\n') + try: + out = subprocess.check_output(['./driver_cross', 'driver_cross.in'], + cwd=pdir, env=env).decode() + except subprocess.CalledProcessError: + return [None] * len(items) + values = [None] * len(items) + for match in re.finditer(r'ITEM\s+(\d+)\s+([-\d.eE+]+)', out): + values[int(match.group(1))] = float(match.group(2)) + return values + + +# ── cudacpp CPU-SIMD standalone (standalone_mg7) ───────────────────────────── +# check_sa.exe generates its own RAMBO momenta, so to evaluate at a prescribed +# phase-space point the shipped check_sa.cc is patched (as the acceptance test +# TestStandaloneMg7CrossSymmetry does): its flavorID cap is lifted so the +# extended crossing ids pass validation, and, when MG_MOMFILE is set, the +# momenta read from that file are written into every event of the SIMD page +# before the matrix element is computed. +_MG7_CAP_FROM = 'if( flavorID >= CPPProcess::nmaxflavor )' +_MG7_CAP_TO = ('if( flavorID >= CPPProcess::nmaxflavor * ' + '(unsigned)((CPPProcess::npar+1)*(CPPProcess::npar+1)) )') +_MG7_MOM_FROM = ' prsk->getMomentaFinal();' +_MG7_MOM_TO = ( + ' prsk->getMomentaFinal();\n' + ' if( const char* mgmf = getenv("MG_MOMFILE") ) {\n' + ' std::ifstream mgin( mgmf );\n' + ' std::vector mgbuf( (std::size_t)CPPProcess::npar*4 );\n' + ' for( std::size_t mgk = 0; mgk < mgbuf.size(); mgk++ ) mgin >> mgbuf[mgk];\n' + ' for( unsigned int mgie = 0; mgie < nevt; mgie++ )\n' + ' for( int mgip = 0; mgip < CPPProcess::npar; mgip++ )\n' + ' for( int mgi4 = 0; mgi4 < 4; mgi4++ )\n' + ' MemoryAccessMomenta::ieventAccessIp4Ipar( hstMomenta.data(), mgie, mgi4, mgip ) = mgbuf[mgip*4+mgi4];\n' + ' }') + + +class _Mg7CrossingBackend(object): + """The cudacpp CPU-SIMD standalone (standalone_mg7) crossing backend. + + The vectorisation width is selectable via options['simd'] (see + MG7_SIMD_CHOICES): it is passed to the madmatrix build as + 'BACKEND=cpp', so the same crossing self-check can run on scalar + (none), SSE4, AVX2 or AVX-512 code, or let madmatrix auto-detect ('auto'). + The floating-point precision is selectable via options['precision'] (see + MG7_PRECISION_CHOICES): it is passed as 'FPTYPE=' (f/m/d). + """ + output_format = 'standalone_mg7' + needs_matrix_element = True + + def __init__(self, options=None): + self.compiler = os.environ.get('CXX', 'g++') + simd = (options or {}).get('simd', 'auto') + if simd not in MG7_SIMD_CHOICES: + raise InvalidCmd( + "Unknown --simd '%s' for standalone_mg7; choose one of %s." + % (simd, ', '.join(MG7_SIMD_CHOICES))) + self.simd = simd + precision = (options or {}).get('precision', 'm') + if precision not in MG7_PRECISION_CHOICES: + raise InvalidCmd( + "Unknown --precision '%s' for standalone_mg7; choose one of %s." + % (precision, ', '.join(MG7_PRECISION_CHOICES))) + self.precision = precision + + def build(self, pdir, env): + if not shutil.which(self.compiler): + return False + check = pjoin(pdir, 'check_sa.cc') + try: + with open(check) as fsock: + src = fsock.read() + except IOError: + return False + src = src.replace(_MG7_CAP_FROM, _MG7_CAP_TO) + src = src.replace(_MG7_MOM_FROM, _MG7_MOM_TO, 1) + with open(check, 'w') as fsock: + fsock.write(src) + make_cmd = ['make', '-j2', 'BACKEND=cpp%s' % self.simd, + 'FPTYPE=%s' % self.precision, 'check_sa.exe'] + with open(os.devnull, 'w') as devnull: + rc = subprocess.call(make_cmd, cwd=pdir, + stdout=devnull, stderr=subprocess.STDOUT, + env=env) + return rc == 0 and os.path.isfile(pjoin(pdir, 'check_sa.exe')) + + def enumerate(self, pdir, matrix_element, card, env, identity_only): + return _crossing_pdg_entries(matrix_element, identity_only=identity_only) + + def evaluate(self, pdir, items, card, env): + values = [] + for item in items: + momfile = pjoin(pdir, 'mom_cross.dat') + with open(momfile, 'w') as fsock: + for leg in item['momenta']: + fsock.write(' '.join('%.17e' % float(c) for c in leg) + '\n') + run_env = dict(env) + run_env['MG_MOMFILE'] = momfile + try: + out = subprocess.check_output( + ['./check_sa.exe', 'perf', '-v', '-f', + str(int(item['index'])), '1', '8', '1'], + cwd=pdir, env=run_env, stderr=subprocess.STDOUT).decode() + except subprocess.CalledProcessError: + values.append(None) + continue + mes = re.findall(r'Matrix element =\s*([-\d.eE+]+)', out) + values.append(float(mes[0]) if mes else None) + return values + + +_CROSSING_BACKENDS = { + 'standalone': _FortranCrossingBackend, + 'standalone_cpp': _CppCrossingBackend, + 'standalone_mg7': _Mg7CrossingBackend, +} + + +def _crossing_dir_name(matrix_element): + """The SubProcesses/P* directory name generated for this matrix element. + + Both the C++ and the mg7 exporters name the directory ``P`` + the process + shell string (export_cpp uses P_ and export_mg7 uses + P, and shell_string already is ``_``), so this + single reconstruction correlates a matrix element to its output directory + for either backend without instantiating a throwaway exporter. + """ + return 'P' + matrix_element.get('processes')[0].shell_string() + + +def check_crossing(process_definition, param_card=None, options=None, + cmd=FakeInterface()): + """Compare the crossing-enabled and crossing-disabled standalone output. + + The process is generated twice and output to the standalone backend picked + by ``options['exporter']`` (one of :data:`CROSSING_EXPORTERS`; default + ``'standalone'``, the fortran/f2py path): + + * ``--use_crossing=False`` — the crossing machinery is *off*; each generated + matrix element is self-contained and reachable only as its own identity. + This is the independent, per-diagram reference (``value_direct``). + * ``--use_crossing=True`` — the crossing machinery is *on*; a single matrix + element reaches many physical processes through the extended flavor index + (leg permutation + NSF flip + per-crossing denominator). + + For every physical subprocess of the reference, the same signed-PDG process + is located in the crossing output and evaluated *through a genuine crossing* + (a non-identity extended index reproducing that PDG signature, when one + exists) at the very same phase-space point, giving ``value_crossed``. The + two must agree: this exercises the crossing (leg permutation / dynamic NSF / + crossed averaging denominator) against a value computed with none of them. + + The backend abstraction (:data:`_CROSSING_BACKENDS`) parametrises the three + steps that differ per exporter -- the ``output`` format, the build, and how + an extended index is evaluated -- while the generate/match/momenta logic is + shared. ``'standalone'`` enumerates the crossed PDG at runtime via f2py + (GET_PDG_FOR_FLAVOR); ``'standalone_cpp'`` / ``'standalone_mg7'`` have no + runtime accessor and compute it in python from the same crossing tables + (:func:`_crossing_pdg_entries`), then evaluate through a compiled driver. + + Processes whose crossing is auto-disabled by an s-channel constraint (e.g. + ``u u~ > z > e+ e-``: what is s-channel in one arrangement is not in its + crossings) reach nothing but their own identity, so ``value_crossed`` falls + back to the identity and the result is flagged 'crossing not applicable'. + + Returns a list of result dicts consumed by :func:`output_crossing`. + """ + import tempfile + import madgraph.interface.master_interface as master_interface + + if options is None: + options = {} + energy = float(options.get('energy', 1000.0)) + + exporter = options.get('exporter', 'standalone') + if exporter not in _CROSSING_BACKENDS: + raise InvalidCmd( + "Unknown crossing exporter '%s'; choose one of %s." + % (exporter, ', '.join(CROSSING_EXPORTERS))) + backend = _CROSSING_BACKENDS[exporter](options) + + model = process_definition.get('model') + proc_line = options.get('proc_line') + if proc_line is None: + # Fall back to a regenerable string; the caller normally supplies the + # verbatim line via options so s-channel/forbidden constraints survive. + proc_line = process_definition.nice_string().split(':', 1)[-1].strip() + modelname = model.get('modelpath') or model.get('name') + + ninitial = len([leg for leg in process_definition.get('legs') + if not leg.get('state')]) + + tmproot = tempfile.mkdtemp(prefix='mg5_crosscheck_') + + def _generate(use_crossing, name): + """Generate + output the backend format; return + ``(outdir, pdirs, me_by_pdir)``. + + ``me_by_pdir`` maps each P* directory to its matrix element (only built + when the backend needs it -- the C++/mg7 backends compute the crossed + PDG in python and so need the matrix element object; the fortran backend + resolves it at runtime and leaves the map empty).""" + mgcmd = master_interface.MasterCmd() + mgcmd.no_notification() + mgcmd.exec_cmd('set automatic_html_opening False', printcmd=False) + mgcmd.exec_cmd('set group_subprocesses False', printcmd=False) + mgcmd.exec_cmd('set apply_flavor_grouping True', printcmd=False) + mgcmd.exec_cmd('import model %s' % modelname, printcmd=False) + # Carry over any user-defined multiparticle labels (e.g. a custom + # 'define x = g u u~'); the built-in ones (p, j, ...) are recreated by + # 'import model', but user labels only live in the caller's session. + user_mp = getattr(cmd, '_multiparticles', None) + if user_mp and hasattr(mgcmd, '_multiparticles'): + mgcmd._multiparticles.update(user_mp) + mgcmd.exec_cmd('generate %s --use_crossing=%s' + % (proc_line, use_crossing), printcmd=False) + outdir = pjoin(tmproot, name) + mgcmd.exec_cmd('output %s %s -f' % (backend.output_format, outdir), + printcmd=False) + subroot = pjoin(outdir, 'SubProcesses') + pdirs = [pjoin(subroot, d) for d in sorted(os.listdir(subroot)) + if d.startswith('P') and os.path.isdir(pjoin(subroot, d))] + me_by_pdir = {} + if backend.needs_matrix_element: + by_name = {} + try: + for me in mgcmd._curr_matrix_elements.get_matrix_elements(): + by_name[_crossing_dir_name(me)] = me + except Exception as err: + logger.debug("Could not read matrix elements for the crossing " + "check (%s): %s" % (backend.output_format, err)) + for pdir in pdirs: + me_by_pdir[pdir] = by_name.get(os.path.basename(pdir)) + # If the user supplied a param_card, use it in place of the model + # default for both evaluation and momenta generation. + if param_card: + shutil.copy(param_card, pjoin(outdir, 'Cards', 'param_card.dat')) + return outdir, pdirs, me_by_pdir + + def _pdg_label(pdg): + try: + names = [] + for code in pdg: + part = model.get_particle(code) + names.append(part.get_name() if part else str(code)) + return (' '.join(names[:ninitial]) + ' > ' + + ' '.join(names[ninitial:])) + except Exception: + return str(tuple(pdg)) + + results = [] + env = _crossing_build_env() + try: + ref_out, ref_pdirs, ref_me = _generate('False', 'reference') + cross_out, cross_pdirs, cross_me = _generate('True', 'crossing') + ref_card = pjoin(ref_out, 'Cards', 'param_card.dat') + cross_card = pjoin(cross_out, 'Cards', 'param_card.dat') + + # ── build every module ────────────────────────────────────────────── + built = {} + for pdir in ref_pdirs + cross_pdirs: + built[pdir] = backend.build(pdir, env) + if not any(built.get(pdir) for pdir in ref_pdirs) or \ + not any(built.get(pdir) for pdir in cross_pdirs): + # Nothing usable on either side: signal a skip rather than a fail. + return [{'status': 'build_failed', 'exporter': exporter}] + + # ── enumerate the crossing output: pdg-tuple -> (pdir, index, cross) ─ + # Two-stage matching so the crossing code path is exercised *safely*: + # * within a module keep the lowest-cross index per PDG (this is what + # find_pdg does). A module owning the process as its identity gives + # cross==0; a module reaching it only by crossing gives cross>0. The + # dedup is essential -- a *shadowed* higher-cross index can report the + # same PDG yet evaluate to a different (wrong) value, so it must never + # be picked over the identity of the module that owns the process. + # * across modules prefer a genuine crossing (cross>0) from a module + # that does not own the process, so the comparison exercises the + # crossing rather than a plain identity when the process line spans + # crossable subprocesses. + cross_map = {} + for pdir in cross_pdirs: + if not built.get(pdir): + continue + entries = backend.enumerate(pdir, cross_me.get(pdir), cross_card, + env, identity_only=False) + if not entries: + continue + module_map = {} # find_pdg semantics: lowest cross per PDG + for idx, cross, _flav, pdg in entries: + key = tuple(pdg) + if key not in module_map: + module_map[key] = (idx, cross) + for key, (idx, cross) in module_map.items(): + existing = cross_map.get(key) + # Prefer a genuine crossing (cross>0) over an identity match. + if existing is None or (existing[2] == 0 and cross > 0): + cross_map[key] = (pdir, idx, cross) + + # ── enumerate the reference identities and generate momenta ───────── + # (ref_pdir, ref_idx, pdg) for every reference subprocess (cross==0). + ref_subprocs = [] + momenta_by_pdg = {} + for pdir in ref_pdirs: + if not built.get(pdir): + continue + entries = backend.enumerate(pdir, ref_me.get(pdir), ref_card, env, + identity_only=True) + if not entries: + continue + for idx, cross, _flav, pdg in entries: + if cross != 0: + continue # reference has no genuine crossing anyway + key = tuple(pdg) + ref_subprocs.append((pdir, idx, key)) + if key not in momenta_by_pdg: + momenta_by_pdg[key] = _crossing_momenta( + key, ninitial, model, param_card, energy, cmd) + + # ── batch the evaluations per module ──────────────────────────────── + # value_direct: reference module at its own identity index. + direct_jobs = {} + for pdir, idx, key in ref_subprocs: + direct_jobs.setdefault(pdir, []).append((idx, key)) + direct_val = {} + for pdir, jobs in direct_jobs.items(): + items = [{'index': idx, 'momenta': momenta_by_pdg[key]} + for idx, key in jobs if momenta_by_pdg[key] is not None] + values = backend.evaluate(pdir, items, ref_card, env) + vi = 0 + for idx, key in jobs: + if momenta_by_pdg[key] is None: + continue + direct_val[(pdir, idx, key)] = values[vi] + vi += 1 + + # value_crossed: crossing module at the (preferably crossed) index. + crossed_jobs = {} + for _pdir, _idx, key in ref_subprocs: + match = cross_map.get(key) + if match is None or momenta_by_pdg[key] is None: + continue + cpdir, cidx, _ccross = match + crossed_jobs.setdefault(cpdir, []).append((cidx, key)) + crossed_val = {} + for cpdir, jobs in crossed_jobs.items(): + items = [{'index': cidx, 'momenta': momenta_by_pdg[key]} + for cidx, key in jobs] + values = backend.evaluate(cpdir, items, cross_card, env) + for (cidx, key), value in zip(jobs, values): + crossed_val[(cpdir, cidx, key)] = value + + # ── assemble the per-subprocess results ───────────────────────────── + for pdir, idx, key in ref_subprocs: + value_direct = direct_val.get((pdir, idx, key)) + match = cross_map.get(key) + value_crossed = None + cross_code = None + # crossing_matched records whether a crossing reproducing this + # subprocess was *located* in the crossing build, so the report can + # tell "no crossing reaches this here" apart from "a crossing was + # found but its matrix element could not be evaluated". + crossing_matched = match is not None + if match is not None and momenta_by_pdg[key] is not None: + cpdir, cidx, ccross = match + value_crossed = crossed_val.get((cpdir, cidx, key)) + cross_code = ccross + results.append({ + 'process': _pdg_label(key), + 'pdg': key, + 'value_direct': value_direct, + 'value_crossed': value_crossed, + 'cross_code': cross_code, + 'crossing_matched': crossing_matched, + 'exporter': exporter, + 'status': 'ok', + }) + finally: + shutil.rmtree(tmproot, ignore_errors=True) + + return results + + +def _crossing_momenta(pdg, ninitial, model, param_card, energy, cmd): + """A seeded phase-space point for the leg ordering *pdg* (signed codes). + + Uses the same RAMBO seed as the check_sa templates so the point is + reproducible. Returns a list of ``[E, px, py, pz]`` per leg, or None. + """ + try: + legs = base_objects.LegList() + for i, code in enumerate(pdg): + legs.append(base_objects.Leg({'id': int(code), + 'state': (i >= ninitial), + 'number': i + 1})) + proc = base_objects.Process({'legs': legs, 'model': model}) + evaluator = MatrixElementEvaluator(model, param_card, cmd=cmd, + auth_skipping=False, reuse=False) + momenta = _get_seeded_python_momenta(proc, evaluator, energy) + if momenta is None: + return None + return [list(map(float, p)) for p in momenta] + except Exception as err: + logger.debug("Could not build momenta for %s: %s" % (tuple(pdg), err)) + return None + + +def output_crossing(comparison_results, output='text'): + """Present the results of a crossing check in a table. + + Compares ``value_direct`` (the crossing-disabled build, evaluating the + subprocess with its own diagrams) against ``value_crossed`` (the + crossing-enabled build, evaluating the same signed-PDG process through the + extended flavor index). ``output='fail'`` returns the number of failures + instead of the formatted string. + """ + exporter = None + for data in comparison_results: + if data.get('exporter'): + exporter = data['exporter'] + break + + if len(comparison_results) == 1 and \ + comparison_results[0].get('status') == 'build_failed': + if exporter in ('standalone', None): + reason = ("f2py matrix2py module (f2py / numpy build backend " + "unavailable)") + else: + reason = "%s output (C++ compiler / build toolchain unavailable)" \ + % exporter + msg = ("Could not build the %s; the crossing check cannot run here." + % reason) + return 0 if output == 'fail' else msg + + proc_col_size = 17 + process_header = "Process" + for data in comparison_results: + # Leave room for the ' (identity)' tag that may be appended below. + proc = data['process'] + ' (identity)' + if len(proc) + 1 > proc_col_size: + proc_col_size = len(proc) + 1 + col_size = 20 + + pass_proc = 0 + fail_proc = 0 + no_check_proc = 0 + failed_proc_list = [] + no_check_proc_list = [] + any_crossed = False + + res_str = '' + if exporter: + res_str += "Exporter: %s\n" % exporter + res_str += fixed_string_length(process_header, proc_col_size) + \ + fixed_string_length("Direct", col_size) + \ + fixed_string_length("Crossed", col_size) + \ + fixed_string_length("Relative diff.", col_size) + \ + "Result" + + for one_comp in comparison_results: + proc = one_comp['process'] + val_d = one_comp['value_direct'] + val_c = one_comp['value_crossed'] + + if val_d is None or val_c is None: + no_check_proc += 1 + no_check_proc_list.append(proc) + if val_d is None: + reason = "reference matrix element could not be evaluated" + elif one_comp.get('crossing_matched'): + # A crossing reproducing this process WAS found, but evaluating + # its matrix element failed -- a build/run problem of this + # backend, not a missing crossing. + reason = ("crossing found but its matrix element could not be " + "evaluated with this exporter") + else: + # No crossing in the *generated* output reaches this exact + # subprocess. A crossing may still exist from a process line + # not spanned here (e.g. d d~ > g d d~ for g d > d d d~), or + # the backend groups flavors so this ordering is not produced. + reason = ("no crossing in the generated output reproduces this " + "subprocess") + res_str += '\n' + fixed_string_length(proc, proc_col_size) + \ + " * Not checked: %s *" % reason + continue + + cross_code = one_comp.get('cross_code') + crossed = bool(cross_code) + any_crossed = any_crossed or crossed + + ref = abs(val_d) if val_d != 0 else abs(val_c) + if ref == 0: + diff = 0.0 + else: + diff = abs(val_d - val_c) / ref + + tag = '' if crossed else ' (identity)' + res_str += '\n' + fixed_string_length(proc + tag, proc_col_size) + \ + fixed_string_length("%1.10e" % val_d, col_size) + \ + fixed_string_length("%1.10e" % val_c, col_size) + \ + fixed_string_length("%1.10e" % diff, col_size) + + if diff < 1e-6: + pass_proc += 1 + res_str += "Passed" + else: + fail_proc += 1 + failed_proc_list.append(proc) + res_str += "Failed" + + res_str += "\nSummary: %i/%i passed, %i/%i failed" % ( + pass_proc, pass_proc + fail_proc, + fail_proc, pass_proc + fail_proc) + if fail_proc: + res_str += "\nFailed processes: %s" % ', '.join(failed_proc_list) + if no_check_proc: + res_str += "\nNot checked processes: %s" % ', '.join(no_check_proc_list) + if not any_crossed and (pass_proc or fail_proc): + res_str += ("\nNote: every subprocess was matched at the identity, so " + "this compares the crossing-enabled build against the " + "crossing-disabled one at cross=0. No non-identity crossing " + "was reached -- either the process line spans no crossable " + "subprocesses or a constrained s-channel forbids crossing.") + + if output == 'text': + return res_str + else: + return fail_proc + + #=============================================================================== # Marsaglia-Zaman RNG matching the check_sa Fortran/C++ template seed #=============================================================================== diff --git a/madmatrix/model_handling.py b/madmatrix/model_handling.py index b4d1bac787..8d3bf4badb 100644 --- a/madmatrix/model_handling.py +++ b/madmatrix/model_handling.py @@ -18,6 +18,7 @@ from madgraph.iolibs import export_cpp, export_mg7 from madgraph.iolibs import file_writers as writers +from madgraph.iolibs import jamp_optimiser import aloha from aloha import aloha_writers @@ -1462,7 +1463,8 @@ def get_mg5_info_lines(self): # (NB: enable this via ProcessExporterMadMatrix.oneprocessclass in output.py) # (NB: use this directly also in MadMatrixUFOModelConverter.read_template_file) # (NB: use this directly also in MadMatrixGPUFOHelasCallWriter.super_get_matrix_element_calls) -class OneProcessExporterMadMatrix(export_mg7.OneProcessExporterMG7): +class OneProcessExporterMadMatrix(export_v4.ColorReflectionFolding, + export_mg7.OneProcessExporterMG7): # Class structure information # - object # - OneProcessExporterCPP(object) [in madgraph/iolibs/export_cpp.py] @@ -1470,6 +1472,9 @@ class OneProcessExporterMadMatrix(export_mg7.OneProcessExporterMG7): # - OneProcessExporterMadMatrix(OneProcessExporterCPP) # This class + # Sum |M|^2 over one color flow per reversal pair (color_sum.cc) + jamp_fold = True + # AV - change defaults from export_cpp.OneProcessExporterCPP cc_ext = 'cc' # create CPPProcess.cc process_dir = '.' @@ -1483,6 +1488,8 @@ class OneProcessExporterMadMatrix(export_mg7.OneProcessExporterMG7): process_wavefunction_template = pjoin('madmatrix', 'cpp_process_wavefunctions.inc') process_sigmaKin_function_template = pjoin('madmatrix', 'process_sigmaKin_function.inc') single_process_template = pjoin('madmatrix', 'process_matrix.inc') + blas_color_sum_template = pjoin('madmatrix', 'color_sum_blas.inc') + blas_helicity_loop_template = pjoin('madmatrix', 'color_sum_blas_loop.inc') support_multichannel = False multichannel_var = ',fptype& multi_chanel_num, fptype& multi_chanel_denom' imaginary_unit = "cxtype(0,1)" @@ -1503,6 +1510,9 @@ def __init__(self, *args, **kwargs): def get_process_class_definitions(self, write=True): replace_dict = super().get_process_class_definitions(write=False) replace_dict['process_lines'] = replace_dict['process_lines'].replace('\n','\n ') + # ncolor_flow sits next to ncolor in the class, so it has to be known + # here as well as in get_process_function_definitions + self.set_color_flow_lines_cpp(self.matrix_elements[0], replace_dict) ###misc.sprint( replace_dict['nwavefuncs'] ) # NB: this (from export_cpp) is the WRONG value of nwf, e.g. 6 for gg_tt (#644) ###misc.sprint( self.matrix_elements[0].get_number_of_wavefunctions() ) # NB: this is a different WRONG value of nwf, e.g. 7 for gg_tt (#644) ###replace_dict['nwavefunc'] = self.matrix_elements[0].get_number_of_wavefunctions() # how do I get HERE the right value of nwf, e.g. 5 for gg_tt? @@ -1512,6 +1522,7 @@ def get_process_class_definitions(self, write=True): replace_dict['nbhel'] = self.matrix_elements[0].get_helicity_combinations() # number of helicity combinations replace_dict['ndiagrams'] = len(self.matrix_elements[0].get('diagrams')) # AV FIXME #910: elsewhere matrix_element.get('diagrams') and max(config[0]... replace_dict['nmaxflavor'] = len(self.matrix_elements[0].get_external_flavors_with_iden()) # number of flavor combinations + replace_dict['ncolorfold'] = self.get_ncolorfold(self.matrix_elements[0], replace_dict['ncolor']) replace_dict['nwave'] = 4 if (fd_gauge): replace_dict['nwave'] += 1 @@ -1727,8 +1738,12 @@ def get_process_function_definitions(self, write=True): replace_dict['all_helicities'] = replace_dict['all_helicities'] .replace('helicities', 'tHel') replace_dict['all_flavors'] = self.get_flavor_matrix(self.matrix_elements[0]) replace_dict['all_flavors'] = replace_dict['all_flavors'].replace('flavors', 'tFlavors') - color_amplitudes = [me.get_color_amplitudes() for me in self.matrix_elements] # as in OneProcessExporterCPP.get_process_function_definitions + color_amplitudes = [me.get_color_amplitudes(merge_quartic_amplitudes=False) + for me in self.matrix_elements] # as in OneProcessExporterCPP.get_process_function_definitions replace_dict['ncolor'] = len(color_amplitudes[0]) + # The color sum can run on the (n-2)! DDM basis while the color flow + # probabilities keep using the (n-1)! trace one + self.set_color_flow_lines_cpp(self.matrix_elements[0], replace_dict) # broken_symmetry_factor function: use the shared decay-aware symmetry # data (same as the Fortran / standalone_cpp exporters) instead of the # old simple PID-count version, so identical-particle and decay-chain @@ -1741,6 +1756,10 @@ def get_process_function_definitions(self, write=True): export_v4.ProcessExporterFortran._fill_broken_sym_replace_dict( replace_dict, sym_data) + # Crossing-symmetry holes (identity fills when use_crossing is off -> + # byte-identical output). See get_madmatrix_crossing_dict. + replace_dict.update(self.get_madmatrix_crossing_dict(self.matrix_elements[0])) + file = self.read_template_file(self.process_definition_template) % replace_dict # HACK! ignore write=False case if len(params) == 0: # remove cIPD from OpenMP pragma (issue #349) file_lines = file.split('\n') @@ -1769,7 +1788,26 @@ def get_sigmaKin_lines(self, color_amplitudes, write=True): replace_dict['madE_update_answer'] = ' allMEs[iproc*nprocesses + ievt] *= multi_chanel_num/multi_chanel_denom;' replace_dict['nb_channel'] = len(self.multi_channel_map) - replace_dict['nb_color'] = max(1, len(self.matrix_elements[0].get('color_basis'))) + # same meaning as in edit_coloramps: the number of color flows, which + # is not the size of the color basis when the color sum runs on the DDM one + replace_dict['nb_color'] = max(1, len(self.color_flow_basis)) + + # Crossing-symmetry hole (per-event denominator); identity fill when off. + replace_dict.update(self.get_madmatrix_crossing_dict(self.matrix_elements[0])) + + # The BLAS variant of the helicity loop is a second copy of the loop + # below, so it carries the same crossing holes and has to be filled + # here, before the outer template is substituted. It does NOT carry the + # csym holes: the C-parity reuse stays on the scalar path only, which + # costs the batch nothing but the shortcut. + replace_dict['cpp_blas_helicity_loop'] = '' + replace_dict['cpp_blas_helicity_loop_end'] = '' + if self.cpp_blas_wanted(): + replace_dict['cpp_blas_helicity_loop'] = \ + self.read_template_file(self.blas_helicity_loop_template) \ + % replace_dict + replace_dict['cpp_blas_helicity_loop_end'] = \ + '\n#endif // MGONGPU_CPP_HAS_BLAS' if write: file = self.read_template_file(self.process_sigmaKin_function_template) % replace_dict @@ -1785,11 +1823,19 @@ def get_all_sigmaKin_lines(self, color_amplitudes, class_name): if self.single_helicities: ###misc.sprint(type(self.helas_call_writer)) ###misc.sprint( 'before get_matrix_element_calls', self.matrix_elements[0].get_number_of_wavefunctions() ) # WRONG value of nwf, eg 7 for gg_tt - helas_calls = self.helas_call_writer.get_matrix_element_calls(\ + # Crossing symmetry: tell the helas writer to emit the per-event + # momentum-permutation preamble + NSF-blended external calls. Read at + # emission time and reset afterwards (the writer is reused across + # outputs, per the fortran/standalone_cpp lesson). + self.helas_call_writer.use_crossing_ic = getattr(self, 'use_crossing', False) + try: + helas_calls = self.helas_call_writer.get_matrix_element_calls(\ self.matrix_elements[0], color_amplitudes[0], multi_channel_map = self.multi_channel_map ) + finally: + self.helas_call_writer.use_crossing_ic = False ###misc.sprint( 'after get_matrix_element_calls', self.matrix_elements[0].get_number_of_wavefunctions() ) # CORRECT value of nwf, eg 5 for gg_tt assert len(self.matrix_elements) == 1 or len(self.matrix_elements) == 2 # how to handle if this is not true? self.couplings2order = self.helas_call_writer.couplings2order @@ -1904,8 +1950,16 @@ def get_all_sigmaKin_lines(self, color_amplitudes, class_name): ret_lines.append(""" // Local variables for the given CUDA event (ievt) or C++ event page (ipagV) // [jamp: sum (for one event or event page) of the invariant amplitudes for all Feynman diagrams in a given color combination] - cxtype_sv jamp_sv[ncolor] = {}; // all zeros (NB: vector cxtype_v IS initialized to 0, but scalar cxtype is NOT, if "= {}" is missing!) - + cxtype_sv jamp_sv[ncolor] = {}; // all zeros (NB: vector cxtype_v IS initialized to 0, but scalar cxtype is NOT, if "= {}" is missing!)""") + # Shared sub-expressions of the color flows, filled in while the + # amplitudes go by (see MadMatrixUFOHelasCallWriter.build_jamp_plan). + # No "= {}": each one is assigned before it is ever read. + nb_tmp_jamp = getattr(self.helas_call_writer, 'nb_tmp_jamp', 0) + if nb_tmp_jamp: + ret_lines.append(""" + // [jampTmp: partial sums of amplitudes that several color flows share, so that they are computed only once] + cxtype_sv jampTmp_sv[%i];""" % nb_tmp_jamp) + ret_lines.append(""" // === Calculate wavefunctions and amplitudes for all diagrams in all processes === // === (for one event in CUDA, for one - or two in mixed mode - SIMD event pages in C++ === @@ -1930,7 +1984,16 @@ def get_all_sigmaKin_lines(self, color_amplitudes, class_name): file_extend.append( file ) assert i == 0, "more than one ME in get_all_sigmaKin_lines" # AV sanity check (added for color_sum.cc but valid independently) ret_lines.extend( file_extend ) - return '\n'.join(ret_lines) + result = '\n'.join(ret_lines) + if getattr(self, 'use_crossing', False): + # (A) Per-lane crossing: calculate_jamps takes the per-lane helicity + # rows (host only), read by the external block. Gated so a + # non-crossing build keeps the historical signature byte-for-byte. + result = result.replace( + 'const int ievt00 // input: first event number in current C++ event page (for CUDA, ievt depends on threadid)\n#endif', + 'const int ievt00, // input: first event number in current C++ event page (for CUDA, ievt depends on threadid)\n' + ' const int _ighel = -1 // crossing: good-hel index; the external block derives the per-lane helicity per page (>=0 = crossing, -1 = scalar ihel)\n#endif', 1) + return result # AV - modify export_cpp.OneProcessExporterCPP method (replace '# Process' by '// Process') def get_process_info_lines(self, matrix_element): @@ -1952,10 +2015,126 @@ def generate_process_files(self): self.edit_memorybuffers() # AV new file (NB this is generic in Subprocesses and then linked in Sigma-specific) self.edit_memoryaccesscouplings() # AV new file (NB this is generic in Subprocesses and then linked in Sigma-specific) super().generate_process_files() + self.edit_crossing_demo() # per-process folded-crossing flavor ids for check_sa # NB: symlink of cudacpp.mk to makefile is overwritten by madevent makefile if this exists (#480) # NB: this relies on the assumption that cudacpp code is generated before madevent code files.ln(pjoin(self.path, "..", "makefile"), self.path, "makefile") + def _folded_crossing_flavorids(self, matrix_element): + """Extended flavor ids of the crossed subprocesses folded into this base + ME (merge_crossing='record'). One id per asked crossing direction + (mirror pairs collapsed), matched LABEL-AWARE against the reachable + (index, cross, flav, pdg) enumeration so a merged _quark leg matches any + same-sign flavor -- the same selection check_sa.f's crossing demo uses. + The index IS the mg7 flavor id (cross*nflav+flav0), so flavorPDG(id, k) + gives the crossed PDG at runtime.""" + crossed = matrix_element.get('crossed_processes') + if not crossed: + return [] + import madgraph.iolibs.export_v4 as export_v4 + Fort = export_v4.ProcessExporterFortran + merged = matrix_element.get('processes')[0].get('model').get( + 'merged_particles') + entries = Fort.compute_crossing_pdg_entries(self, matrix_element) + pdg_to_id = {} + for (index, _cross, _flav0, pdg) in entries: + pdg_to_id.setdefault(pdg, index) + reach = [pdg for (_i, _c, _f, pdg) in entries] + + def leg_matches(leg_id, pdg): + a = abs(leg_id) + if a in merged: + return (leg_id > 0) == (pdg > 0) and abs(pdg) in merged[a] + return pdg == leg_id + + ninitial = matrix_element.get_nexternal_ninitial()[1] + ids, seen = [], set() + for (proc, _bp, _xp) in crossed: + legs = [l.get('id') for l in proc.get('legs')] + orients = [legs] + if ninitial == 2: + orients.append([legs[1], legs[0]] + legs[2:]) + hit = None + for orient in orients: + for r in reach: + if len(r) == len(orient) and \ + all(leg_matches(L, P) for L, P in zip(orient, r)): + hit = r + break + if hit is not None: + break + if hit is None: + continue + mirror = (hit[1], hit[0]) + hit[2:] if ninitial == 2 else hit + if hit in seen or mirror in seen: + continue + seen.add(hit) + seen.add(mirror) + ids.append(pdg_to_id[hit]) + return ids + + def _scanned_crossings(self, matrix_element): + """Crossing codes the good-helicity scan has to visit. + + A crossing code is only ever carried by an event if this ME actually + RECORDED that crossed subprocess (merge_crossing='record'), so the scan + needs the recorded codes and nothing else. Enumerating every code that + is merely structurally applicable instead costs a full ncomb-helicity + scan per code -- 48 of them for g g > t t~ g g g, which records none at + all -- and every one past the recorded set builds a cGoodHelOfCross row + no event can ever index. See the runtime guard in _crossing_preamble for + what happens if an unrecorded code does show up. + + The identity (0) is always included: it is the base process itself. + + NB this is deliberately NOT _folded_crossing_flavorids. That one answers + a different question -- one representative id per crossed subprocess, + mirror pairs collapsed -- which is what a demo wants and what a scan must + not use: the runtime may hand us EITHER member of a mirror pair, and a + collapsed partner would hit the guard and abort. Here every reachable + entry matching a recorded process in either orientation is kept.""" + crossings = set([0]) + crossed = matrix_element.get('crossed_processes') + if not crossed: + return sorted(crossings) + import madgraph.iolibs.export_v4 as export_v4 + Fort = export_v4.ProcessExporterFortran + merged = matrix_element.get('processes')[0].get('model').get( + 'merged_particles') + entries = Fort.compute_crossing_pdg_entries(self, matrix_element) + + def leg_matches(leg_id, pdg): + a = abs(leg_id) + if a in merged: + return (leg_id > 0) == (pdg > 0) and abs(pdg) in merged[a] + return pdg == leg_id + + ninitial = matrix_element.get_nexternal_ninitial()[1] + for (proc, _bp, _xp) in crossed: + legs = [l.get('id') for l in proc.get('legs')] + orients = [legs] + if ninitial == 2: + orients.append([legs[1], legs[0]] + legs[2:]) + for (_index, cross, _flav0, pdg) in entries: + if any(len(pdg) == len(orient) and + all(leg_matches(L, P) for L, P in zip(orient, pdg)) + for orient in orients): + crossings.add(cross) + return sorted(crossings) + + def edit_crossing_demo(self): + """Write crossing_demo.dat (the folded-crossing flavor ids) into the P* + directory so the shared check_sa.exe can demonstrate each crossed + subprocess at its own RAMBO point. Nothing is written when the ME has no + folded crossings (check_sa then just shows the base flavors).""" + if not getattr(self, 'use_crossing', False): + return + ids = self._folded_crossing_flavorids(self.matrix_elements[0]) + if not ids: + return + with open(pjoin(self.path, 'crossing_demo.dat'), 'w') as fsock: + fsock.write(' '.join(str(i) for i in ids) + '\n') + # AV - replace the export_cpp.OneProcessExporterCPP method (add debug printouts and multichannel handling #473) def edit_mgonGPU(self): """Generate mgOnGpuConfig.h""" @@ -1984,6 +2163,24 @@ def edit_processidfile(self): ff.write(template % replace_dict) ff.close() + # AV - new method + @classmethod + def cpp_blas_wanted_for(cls, ncolor): + """Whether the C++ color sum goes through a host BLAS: only when one + carrying SYMM can be linked, and when the color matrix is big enough + that the call is worth setting up. Both the probe and the threshold are + the ones the Fortran color sum already uses. With BLAS off nothing is + written out, so color_sum.cc and CPPProcess.cc are character for + character the files written before any of this existed.""" + from madgraph.iolibs.export_v4 import ProcessExporterFortran + if not ProcessExporterFortran.blas_is_available(): + return False + return ncolor >= ProcessExporterFortran.blas_min_ncolor + + def cpp_blas_wanted(self): + return self.cpp_blas_wanted_for( + max(1, len(self.matrix_elements[0].get('color_basis')))) + # AV - new method def edit_colorsum(self): """Generate color_sum.cc""" @@ -1992,6 +2189,11 @@ def edit_colorsum(self): replace_dict = {} # Extract color matrix again (this was also in get_matrix_single_process called within get_all_sigmaKin_lines) replace_dict['color_matrix_lines'] = self.get_color_matrix_lines(self.matrix_elements[0]) + replace_dict['cpp_blas_color_sum'] = '' + if self.cpp_blas_wanted(): + replace_dict['cpp_blas_color_sum'] = strip_banner( + open(pjoin(self.template_path, self.blas_color_sum_template), 'r').read(), + banner_mark='/') ff = open(pjoin(self.path, 'color_sum.cc'),'w') ff.write(template % replace_dict) ff.close() @@ -2020,7 +2222,9 @@ def edit_coloramps(self): ###misc.sprint('Entering OneProcessExporterMadMatrix.edit_coloramps') template = open(pjoin(self.template_path,'madmatrix','coloramps.h'),'r').read() - ff = open(pjoin(self.path, 'coloramps.h'),'w') + # NB: coloramps.h is opened only once the whole content is built, so a + # failure below cannot leave a truncated (0 byte) header behind -- which + # then looks like a silently skipped process at build time. # The following five lines from OneProcessExporterCPP.get_sigmaKin_lines (using OneProcessExporterCPP.get_icolamp_lines) replace_dict={} @@ -2058,7 +2262,11 @@ def edit_coloramps(self): replace_dict['nb_channel'] = len(self.active_color_map) # here I can do the conversion in between the active color map and true, false, and obtain a C++ compatible thing immediately replace_dict['nb_diag'] = nb_diag - nb_color = max(1, len(self.color_basis)) + # icolamp is the mask of the color flows allowed for a config, and the + # selection walks it over ncolor_flow entries, so it is dimensioned on + # the flow basis -- which is larger than the color basis itself when + # the color sum runs on the DDM one + nb_color = max(1, len(self.color_flow_basis)) replace_dict['nb_color'] = nb_color # AV extra formatting (e.g. gg_tt was "{{true,true};,{true,false};,{false,true};};") ###misc.sprint(replace_dict['is_LC']) @@ -2069,6 +2277,36 @@ def edit_coloramps(self): icolamp_text += text % (iconfigc+1, iconfig_to_diag[iconfigc+1]-1) # diag - 1 is to follow MadSpace indexing icolamp.append(icolamp_text) replace_dict['is_LC'] = '\n'.join(icolamp) + + # Canonical colour-flow code of each colour flow -- baked so the ME can + # return the self-describing code (the MG7 colour encoding) instead of a + # raw flow index. Same encoding as the fortran output / subprocesses.json + # (get_color_code_tables); valid==false leaves the flows to the fallback. + codes = None + if self.color_basis: + n_initial = self.matrix_element.get_nexternal_ninitial()[1] + legs = self.process.get_legs_with_decays() + repr_dict = {leg.get("number"): + self.model.get_particle(leg.get("id")).get_color() + * (-1) ** (1 + leg.get("state")) for leg in legs} + # This is about colour FLOWS, so always the trace basis: with the + # DDM basis the elements are products of f's and have no single + # flow each (color_flow_decomposition raises on it). get_flow_basis + # returns the basis itself when the colour sum is not on DDM. + color_flow_dicts = self.color_flow_basis.color_flow_decomposition( + repr_dict, n_initial) + codes, _slots = self.get_color_code_tables(color_flow_dicts, legs) + if codes is None: + replace_dict['colorflowcode_valid'] = 'false' + replace_dict['colorflowcode_lines'] = '\n'.join( + ' 0, // colour flow %d (no usable code -- use the tag table)' + % i for i in range(nb_color)) + else: + replace_dict['colorflowcode_valid'] = 'true' + replace_dict['colorflowcode_lines'] = '\n'.join( + ' %d, // colour flow %d' % (c, i) + for i, c in enumerate(codes)) + ff = open(pjoin(self.path, 'coloramps.h'),'w') ff.write(template % replace_dict) ff.close() @@ -2127,33 +2365,158 @@ def write_process_cc_file(self, writer): else: return replace_dict + # The folded color matrix is always written out, never rebuilt at run time + # as the fortran output can do, and folded it is a quarter of the size of + # the matrix which would be written otherwise. So take every folding found. + def jamp_fold_worthwhile(self, sign, nb_pairs): + return True + + # AV - cache the export_v4.ColorReflectionFolding method + def get_jamp_folding(self, matrix_element): + """Cache the folding: it is read once for CPPProcess.h (ncolorfold) and + once for color_sum.cc, and finding it walks the whole color basis.""" + cache = self.__dict__.setdefault('_jamp_folding_cache', {}) + key = id(matrix_element) + if key not in cache: + # keep the matrix element alive so that its id cannot be reused + cache[key] = (matrix_element, + super().get_jamp_folding(matrix_element)) + return cache[key][1] + + def get_ncolorfold(self, matrix_element, ncolor): + """The number of color flows |M|^2 is summed over: one per reversal pair + where the color basis folds, every flow otherwise. Mirrors what + get_color_matrix_lines writes the folded color matrix over, and is + exported as CPPProcess::ncolorfold because the BLAS color sum sizes its + buffers on it outside color_sum.cc (see MatrixElementKernels.cc).""" + if not matrix_element.get('color_matrix'): + return 1 + folding = self.get_jamp_folding(matrix_element) + return len(folding['representatives']) if folding else ncolor + # AV - replace the export_cpp.OneProcessExporterCPP method (fix fptype and improve formatting) + def set_color_flow_lines_cpp(self, matrix_element, replace_dict): + """Fill in replace_dict everything the process template needs to know + about the color flow basis. + + For a fully adjoint (multi-gluon) process the color sum can be done on + the (n-2)! Del Duca-Dixon-Maltoni basis, but a color flow still has to + be picked among the (n-1)! trace structures. The trace jamps are then + not built from the amplitudes but obtained from the DDM ones through + the Kleiss-Kuijf relations, which is (n-1) times cheaper.""" + + color_basis = matrix_element.get('color_basis') + flow_basis = color_basis.get_flow_basis() if color_basis else None + + if flow_basis is None or flow_basis is color_basis: + replace_dict['ncolor_flow'] = replace_dict['ncolor'] + replace_dict['jampflow_lines'] = '' + replace_dict['jamp_flow'] = 'jamp_sv' + return + + projection = color_basis.get_flow_projection() + lines = ['', + ' // The color flow jamps, rebuilt from the ones entering', + ' // the color sum through the Kleiss-Kuijf relations', + ' cxtype_sv jampf_sv[ncolor_flow] = {};'] + for i, coeff_list in enumerate(projection): + terms = ''.join('%sjamp_sv[%d]' % (self.coeff(coefficient[0], + coefficient[1], + coefficient[2], + coefficient[3]), + number - 1) + for coefficient, number in coeff_list) + lines.append(' jampf_sv[%d] = %s;' % (i, terms if terms + else 'cxzero_sv()')) + + replace_dict['ncolor_flow'] = max(1, len(flow_basis)) + replace_dict['jampflow_lines'] = '\n'.join(lines) + replace_dict['jamp_flow'] = 'jampf_sv' + + logger.debug('Color sum on %d DDM structures, color flow on %d trace ' + 'structures (%d Kleiss-Kuijf terms)', + replace_dict['ncolor'], replace_dict['ncolor_flow'], + sum(len(row) for row in projection)) + def get_color_matrix_lines(self, matrix_element): - """Return the color matrix definition lines for this matrix element. Split rows in chunks of size n.""" - import madgraph.core.color_algebra as color + """Return the color matrix definition lines for this matrix element. Split rows in chunks of size n. + + |M|^2 is summed over one color flow per reversal pair when the basis + allows it (see ColorReflectionFolding), so what is written out is the + color matrix folded onto those flows, together with the list of the + flows kept. Without a folding every flow is its own representative and + the matrix is the plain one.""" if not matrix_element.get('color_matrix'): - return '\n'.join([' static constexpr fptype2 colorDenom[1] = {1.};', 'static const fptype2 cf[1][1] = {1.};']) + return '\n'.join([ + self.get_color_fold_lines(None, 1), + ' static constexpr fptype2 colorDenom[1] = {1.};', + ' static constexpr fptype2 colorMatrix[1][1] = {1.};']) else: - color_denominators = matrix_element.get('color_matrix').\ - get_line_denominators() - denom_string = ' static constexpr fptype2 colorDenom[ncolor] = { %s }; // 1-D array[%i]' \ - % ( ', '.join(['%i' % denom for denom in color_denominators]), len(color_denominators) ) - matrix_strings = [] - for index, denominator in enumerate(color_denominators): - # Then write the numerators for the matrix elements - num_list = matrix_element.get('color_matrix').get_line_numerators(index, denominator) - matrix_strings.append('{ %s }' % ', '.join(['%d' % i for i in num_list])) - matrix_string = ' static constexpr fptype2 colorMatrix[ncolor][ncolor] = ' + folding = self.get_jamp_folding(matrix_element) + if folding: + denominator, rows = self.jamp_folded_color_matrix( + matrix_element, folding['reverse'], folding['sign']) + color_denominators = [denominator] * len(rows) + num_lists = rows + ncolor = len(folding['reverse']) + else: + color_denominators = matrix_element.get('color_matrix').\ + get_line_denominators() + num_lists = [matrix_element.get('color_matrix'). + get_line_numerators(index, denominator) + for index, denominator + in enumerate(color_denominators)] + ncolor = len(color_denominators) + nfold = len(color_denominators) + denom_string = ' static constexpr fptype2 colorDenom[ncolorfold] = { %s }; // 1-D array[%i]' \ + % ( ', '.join(['%i' % denom for denom in color_denominators]), nfold ) + matrix_strings = ['{ %s }' % ', '.join(['%d' % i for i in num_list]) + for num_list in num_lists] + matrix_string = ' static constexpr fptype2 colorMatrix[ncolorfold][ncolorfold] = ' if len( matrix_strings ) > 1: matrix_string += '{\n ' + ',\n '.join(matrix_strings) + ' };' else: matrix_string += '{ ' + matrix_strings[0] + ' };' - matrix_string += ' // 2-D array[%i][%i]' % ( len(color_denominators), len(color_denominators) ) - denom_comment = '\n // The color denominators (initialize all array elements, with ncolor=%i)\n // [NB do keep \'static\' for these constexpr arrays, see issue #283]\n' % len(color_denominators) - matrix_comment = '\n // The color matrix (initialize all array elements, with ncolor=%i)\n // [NB do keep \'static\' for these constexpr arrays, see issue #283]\n' % len(color_denominators) + matrix_string += ' // 2-D array[%i][%i]' % ( nfold, nfold ) + denom_comment = '\n // The color denominators (initialize all array elements, with ncolorfold=%i)\n // [NB do keep \'static\' for these constexpr arrays, see issue #283]\n' % nfold + matrix_comment = '\n // The color matrix (initialize all array elements, with ncolorfold=%i)\n // [NB do keep \'static\' for these constexpr arrays, see issue #283]\n' % nfold denom_string = denom_comment + denom_string matrix_string = matrix_comment + matrix_string - return '\n'.join([denom_string, matrix_string]) + return '\n'.join([self.get_color_fold_lines(folding, ncolor), + denom_string, matrix_string]) + + @staticmethod + def get_color_fold_lines(folding, ncolor): + """Which color flow the sum keeps out of every reversal pair. Without a + folding this is every flow. How many there are is CPPProcess::ncolorfold + (see get_ncolorfold), which is where color_sum.cc reads it from.""" + + if folding: + representatives = folding['representatives'] + comment = ( + '\n // Reversing a color flow gives the same flow back up to an overall sign\n' + ' // (JAMP[reverse(i)] = %+i * JAMP[i] here), so only one flow of each reversal\n' + ' // pair carries anything of its own: |M|^2 is summed over those, against the\n' + ' // color matrix folded onto them (see ColorReflectionFolding in export_v4.py).\n' + % folding['sign']) + else: + representatives = list(range(ncolor)) + comment = ( + '\n // Reversal does not map this color basis onto itself up to one overall\n' + ' // sign, so every color flow enters the sum on its own.\n') + chunks = [', '.join('%i' % line for line in representatives[start:start + 20]) + for start in range(0, len(representatives), 20)] + values = '{\n ' + ',\n '.join(chunks) + ' }' + # colorFoldRep is indexed at run time inside the GPU kernels, so it has + # to live in device memory: same split as channel2iconfig in coloramps.h + # (nvcc cannot read a plain constexpr array from device code without + # --expt-relaxed-constexpr, which the makefile does not pass) + return comment + \ + ' constexpr int ncolorfold = CPPProcess::ncolorfold; // the number of color flows |M|^2 is summed over (%i here)\n' % len(representatives) + \ + ' // Which color flow of each reversal pair is kept (C indexing, in [0, ncolor-1])\n' + \ + ' // (NB: this array is created on the host in C++ code and on the device in GPU code)\n' + \ + ' __device__ constexpr int colorFoldRep[ncolorfold] = %s; // 1-D array[%i]\n' \ + % (values, len(representatives)) # AV - replace the export_cpp.OneProcessExporterCPP method (improve formatting) def get_initProc_lines(self, matrix_element, color_amplitudes): @@ -2227,6 +2590,697 @@ def get_reset_jamp_lines(self, color_amplitudes): ret_lines = "" return ret_lines + # ------------------------------------------------------------------ + # Crossing symmetry (extended flavor id) for the madmatrix / cudacpp + # CPU-SIMD backend. Mirrors export_cpp.get_crossing_replace_dict and the + # fortran path but adapted to the SIMD structure of this backend: the + # per-event momentum permutation lives in calculate_jamps (emitted by the + # helas writer, gated by use_crossing_ic), while the crossing-aware + # good-helicity union, the per-event denominator and the crossed flavorPDG + # accessor are filled here. When self.use_crossing is False every hole gets + # the historical code so the output is byte-for-byte the old one. + # ------------------------------------------------------------------ + def get_madmatrix_crossing_dict(self, matrix_element): + plain = { + 'crossing_decl': '', + 'goodhel_scan_count': 'nmaxflavor', + 'goodhel_scan_skip': '', + 'sigmakin_denominator': + ' MEs_sv = MEs_sv * broken_symmetry_factor(iflavorVec[ievt0]) / helcolDenominators[0];', + 'flavorpdg_body': ' return flavorPDGs[iflavor][ipar];', + # No crossing: the base row, or -- when the C-parity dedup is on and + # cGoodHel therefore holds one representative per mirror pair -- that + # representative or its partner, at equal rate (csym_selected_row). + 'selected_hel_code_1': + 'csym_selected_row( cGoodHel[ighel], allrndhel[ievt] * _ctot, _clo, _chi ) + 1', + 'selected_hel_code_2': + 'csym_selected_row( cGoodHel[ighel], allrndhel[ievt2] * _ctot, _clo, _chi ) + 1', + # No crossing: union good-hel loop, scalar helicity (historical). + 'goodhel_percross_statics': '', + 'goodhel_percross_decl': '', + 'goodhel_percross_record': '', + 'goodhel_percross_build': '', + 'sigmakin_hel_bound': 'cNGoodHel', + 'sigmakin_perlane_decl': '', + 'sigmakin_ihel_expr': 'cGoodHel[ighel]', + 'calc_jamps_ihlane_arg': '', + # ---- C-parity good-helicity de-duplication (uncrossed only) ---- + # Two helicity rows that are exact mirrors (every helicity negated) + # give an identical |M|^2 under a parity/C-conserving amplitude, so + # only one of the two need ever be computed. This is the NON-crossing + # path: cGoodHel is REDUCED to the lower-index representative of each + # surviving C-pair, every representative carries a weight of 2, and + # the event-by-event helicity choice returns the representative or its + # cFlip partner at equal rate. That halves the sigmaKin trip count, + # the calculate_jamps + colour-sum calls and (on GPU builds, where the + # dedup is currently disabled, see below) it would halve the allJamps + # super-buffer, which is sized from nGoodHel. + # csym is detected in the (serial) getGoodHel scan, so sigmaKin only + # ever reads the tables and stays thread-safe. + # The crossing path keeps the full sum -- for an IMPLEMENTATION + # reason, not a physics one (see the crossing return). + 'csym_statics': + '#ifndef MGONGPUCPP_GPUIMPL\n' + ' static int cFlip[ncomb]; // C-parity partner: every helicity negated (an involution)\n' + ' static bool cCsymBad; // latched: ANY row unpaired or |M(ihel)| != |M(cFlip)| at a scan point\n' + ' static bool cCsymScanned; // the validating scan actually ran (never trust a default)\n' + ' static bool cCsymOk; // all-or-nothing: every good hel sits in a distinct C-symmetric pair\n' + '\n' + ' // Pick the helicity row to report for the ighel-th (reduced) good\n' + ' // helicity. Without the dedup that is the row itself. With it, the row\n' + ' // stands for a C-parity PAIR counted twice, so either member must come\n' + ' // out at equal rate or the event-level helicity distribution is biased\n' + ' // while |M|^2 and the cross section stay perfectly correct.\n' + ' // The fair coin is recycled from the selection variate itself: given\n' + ' // that the (unnormalised) CDF landed in [lo,hi), rnd is exactly uniform\n' + ' // on that interval, so its position within the bin is an independent\n' + ' // U(0,1). Drawing a fresh random number instead would desynchronise the\n' + ' // stream shared with the Fortran integrator.\n' + ' static inline int csym_selected_row( const int ihel, const fptype rnd, const fptype lo, const fptype hi )\n' + ' {\n' + ' if( !cCsymOk ) return ihel;\n' + ' const fptype _w = hi - lo;\n' + ' if( !( _w > (fptype)0 ) ) return ihel; // degenerate bin: cannot be selected anyway\n' + ' return ( ( rnd - lo ) < (fptype)0.5 * _w ) ? ihel : cFlip[ihel];\n' + ' }\n' + '#endif', + 'csym_gh_flip': + ' fptype me_scan[ncomb][neppV]; // per-hel |M|^2 of this scan page, for the C-parity test\n' + ' cCsymBad = false;\n' + ' cCsymScanned = false;\n' + ' for( int _h = 0; _h < ncomb; _h++ ) {\n' + ' cFlip[_h] = _h;\n' + ' for( int _j = 0; _j < ncomb; _j++ ) {\n' + ' bool _same = true;\n' + ' for( int _k = 0; _k < npar; _k++ ) if( cHel[_j][_k] != -cHel[_h][_k] ) _same = false;\n' + ' if( _same ) { cFlip[_h] = _j; break; }\n' + ' }\n' + ' }\n', + 'csym_gh_record': + ' for( int _ie = 0; _ie < neppV; ++_ie ) me_scan[ihel][_ie] = allMEs[ievt00 + _ie];\n', + 'csym_gh_check': + ' { // Largest |M|^2 of this (flavor, page): the scale a difference\n' + ' // has to be significant against. A RELATIVE test alone compares\n' + ' // the roundoff noise of two numerically-zero rows against itself\n' + ' // and fails at random -- which latched "not C-symmetric" on\n' + ' // manifestly C-symmetric processes (the MHV-vanishing gluon\n' + ' // configurations of u u~ > g g sit at |M|^2 ~ 1e-30 out of ~10),\n' + ' // silently disabling the dedup. A row that far below the largest\n' + ' // cannot bias the helicity sum whichever way it is paired, while\n' + ' // a genuine parity violation shows up at the relative level.\n' + ' fptype _mmax = (fptype)0.;\n' + ' for( int _h = 0; _h < ncomb; _h++ )\n' + ' for( int _ie = 0; _ie < neppV; ++_ie ) {\n' + ' const fptype _v = me_scan[_h][_ie] < (fptype)0. ? -me_scan[_h][_ie] : me_scan[_h][_ie];\n' + ' if( _v > _mmax ) _mmax = _v;\n' + ' }\n' + ' for( int _h = 0; _h < ncomb; _h++ ) {\n' + ' if( cFlip[_h] > _h ) {\n' + ' for( int _ie = 0; _ie < neppV; ++_ie ) {\n' + ' const fptype _a = me_scan[_h][_ie];\n' + ' const fptype _b = me_scan[cFlip[_h]][_ie];\n' + ' fptype _d = _a - _b; if( _d < (fptype)0. ) _d = -_d;\n' + ' fptype _aa = _a < (fptype)0. ? -_a : _a;\n' + ' fptype _bb = _b < (fptype)0. ? -_b : _b;\n' + ' if( _d > (fptype)1e-6 * ( _aa + _bb ) && _d > (fptype)1e-12 * _mmax ) cCsymBad = true;\n' + ' }\n' + ' }\n' + ' }\n' + ' }\n' + ' cCsymScanned = true; // a full ncomb-row comparison has been made\n', + 'csym_pairbuild': + '#ifndef MGONGPUCPP_GPUIMPL\n' + ' // All-or-nothing C-parity verdict. cCsymScanned is the load-bearing\n' + ' // term: if the validating scan never ran (cached good helicities, an\n' + ' // API caller reaching setGoodHel on its own) the flag must default to\n' + ' // OFF, never to ON -- trusting an un-run scan is how this dedup was\n' + ' // once silently enabled on a parity-violating process.\n' + ' cCsymOk = cCsymScanned && !cCsymBad;\n' + ' for( int _h = 0; _h < ncomb; _h++ )\n' + ' if( isGoodHel[_h] && ( cFlip[_h] == _h || !isGoodHel[cFlip[_h]] ) ) cCsymOk = false;\n' + '#ifdef MGONGPU_NOCSYM\n' + ' cCsymOk = false; // ablation knob: force the full helicity sum\n' + '#endif\n' + ' if( cCsymOk )\n' + ' {\n' + ' // Keep only the lower-index representative of every C-parity pair.\n' + ' // sigmaKin counts each one twice and csym_selected_row hands back the\n' + ' // representative or its mirror at equal rate, so this is exact rather\n' + ' // than approximate: the dropped rows have an identical |M|^2.\n' + ' int _n = 0;\n' + ' for( int _g = 0; _g < nGoodHel; _g++ )\n' + ' if( goodHel[_g] < cFlip[goodHel[_g]] ) { cGoodHel[_n] = goodHel[_g]; _n++; }\n' + ' for( int _h = _n; _h < ncomb; _h++ ) cGoodHel[_h] = 0;\n' + ' cNGoodHel = _n;\n' + ' nGoodHel = _n;\n' + ' }\n' + '#endif\n', + # cCsymOk is read lexically inside the OMP `default(none)` region (in + # csym_weight), so it needs an explicit data-sharing attribute; cFlip is + # only touched from inside csym_selected_row, which is a function call + # and therefore outside the construct's scope. Both are written once in + # the serial getGoodHel/setGoodHel and only read here. + 'csym_page_decl': '', + 'extra_omp_shared': ', cCsymOk', + # Snapshot the running |M|^2 sum before this helicity's contribution is + # added, so csym_weight can add the very same contribution a second time. + 'csym_me_before': + ' const fptype_sv _me1before = E_ACCESS::kernelAccess( E_ACCESS::ieventAccessRecord( allMEs, ievt00 ) );\n' + '#if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT\n' + ' const fptype_sv _me2before = E_ACCESS::kernelAccess( E_ACCESS::ieventAccessRecord( allMEs, ievt00 + neppV ) );\n' + '#endif\n', + # Weight 2: cGoodHel now holds one representative per C-parity pair, and + # the mirror row it stands for has an identical |M|^2. MEs_ighel must be + # updated too -- it is the running CDF the helicity choice samples. + 'csym_weight': + ' if( cCsymOk ) {\n' + ' fptype_sv& _me1 = E_ACCESS::kernelAccess( E_ACCESS::ieventAccessRecord( allMEs, ievt00 ) );\n' + ' _me1 = _me1 + ( MEs_ighel[ighel] - _me1before );\n' + ' MEs_ighel[ighel] = _me1;\n' + '#if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT\n' + ' fptype_sv& _me2 = E_ACCESS::kernelAccess( E_ACCESS::ieventAccessRecord( allMEs, ievt00 + neppV ) );\n' + ' _me2 = _me2 + ( MEs_ighel2[ighel] - _me2before );\n' + ' MEs_ighel2[ighel] = _me2;\n' + '#endif\n' + ' }\n', + # Unnormalised CDF bin [_clo,_chi) of the selected ighel, and the total + # _ctot the stored variate is normalised by (okhel tested rnd < hi/tot). + 'csym_sel_1': + ' fptype _clo = (fptype)0;\n' + '#if defined MGONGPU_CPPSIMD\n' + ' const fptype _ctot = MEs_ighel[cNGoodHel - 1][ieppV];\n' + ' const fptype _chi = MEs_ighel[ighel][ieppV];\n' + ' if( ighel > 0 ) _clo = MEs_ighel[ighel - 1][ieppV];\n' + '#else\n' + ' const fptype _ctot = MEs_ighel[cNGoodHel - 1];\n' + ' const fptype _chi = MEs_ighel[ighel];\n' + ' if( ighel > 0 ) _clo = MEs_ighel[ighel - 1];\n' + '#endif\n', + 'csym_sel_2': + ' fptype _clo = (fptype)0;\n' + ' const fptype _ctot = MEs_ighel2[cNGoodHel - 1][ieppV];\n' + ' const fptype _chi = MEs_ighel2[ighel][ieppV];\n' + ' if( ighel > 0 ) _clo = MEs_ighel2[ighel - 1][ieppV];\n', + } + if not getattr(self, 'use_crossing', False): + return plain + + import madgraph.iolibs.export_v4 as export_v4 + Fort = export_v4.ProcessExporterFortran + me = matrix_element + tables = Fort.compute_crossing_tables(self, me) + nexternal = tables['nexternal'] + ninitial = tables['ninitial'] + ncross = (nexternal + 1) * (nexternal + 1) + nflav = len(me.get_external_flavors_with_iden()) + # Per-leg base tables only: the crossing is decoded at runtime + # (cross_perm_ic, mirroring the fortran GET_CROSS_PERM) instead of + # tabulating anything per crossing. _build_flav_pdg_tables gives the base + # signed PDG per (flavor, leg) and its charge conjugate, from which + # flavorPDG rebuilds the crossed PDGs at runtime (see flavorpdg_body). + n_flavors, pdg_flat, antipdg_flat = Fort._build_flav_pdg_tables(self, me) + scanned_crossings = set(self._scanned_crossings(me)) + + def arr(vals): + return '{ ' + ', '.join(str(v) for v in vals) + ' }' + + crossing_decl = ( + " // ---- Crossing symmetry (extended id = cross*nmaxflavor + flav) ----\n" + " // A crossing is a fixed slot relabelling decoded from the crossing\n" + " // code at runtime (cross_perm_ic, mirroring the fortran\n" + " // GET_CROSS_PERM): perm[k] is the input slot landing in crossed slot\n" + " // k and ic[k] its NSF sign flip, left a valid permutation (identity\n" + " // for an inapplicable code) so a momentum gather never reads out of\n" + " // range. The two halves of the denominator are rebuilt from small\n" + " // per-leg tables, so no cross-indexed table is stored.\n" + " __host__ __device__ inline bool cross_perm_ic( int cross, int* perm, int* ic )\n" + " {\n" + " constexpr int ncross = ( npar + 1 ) * ( npar + 1 );\n" + " for ( int k = 0; k < npar; k++ ) { perm[k] = k; ic[k] = 1; }\n" + " if ( cross < 0 || cross >= ncross ) return false;\n" + " const int xi = cross / ( npar + 1 );\n" + " const int xj = cross %% ( npar + 1 );\n" + " // Overlapping-swap codes compose into a 3-cycle the consumers read\n" + " // with opposite orientation: pure redundancy, invalid.\n" + " if ( xi != 0 && xi != 1 && xj != 0 && xj != 2 &&\n" + " ( xi == 2 || xj == 1 || xi == xj ) ) return false;\n" + " if ( xi != 0 && xi != 1 )\n" + " { int t = perm[0]; perm[0] = perm[xi - 1]; perm[xi - 1] = t; ic[0] = -ic[0]; ic[xi - 1] = -ic[xi - 1]; }\n" + " if ( xj != 0 && xj != 2 )\n" + " { int t = perm[1]; perm[1] = perm[xj - 1]; perm[xj - 1] = t; ic[1] = -ic[1]; ic[xj - 1] = -ic[xj - 1]; }\n" + " return true;\n" + " }\n" + " // Crossing codes this ME actually RECORDED (merge_crossing='record'),\n" + " // i.e. the only ones an event can ever carry. cross_perm_ic above\n" + " // answers whether a code is structurally APPLICABLE, which is a much\n" + " // weaker statement: g g > t t~ g g g has 48 applicable codes and 0\n" + " // recorded ones. The good-helicity scan walks THIS set (one full\n" + " // ncomb-helicity scan per code), and calculate_jamps checks incoming\n" + " // events against it. The identity is always in.\n" + " __device__ inline bool cross_recorded( int cross )\n" + " {\n" + " constexpr int ncross = ( npar + 1 ) * ( npar + 1 );\n" + " static const bool recorded[ncross] = %(cross_recorded)s;\n" + " return cross >= 0 && cross < ncross && recorded[cross];\n" + " }\n" + " // Initial-state spin*color average of the crossed process: product of\n" + " // the per-leg spin*color (spincol_part, conjugation invariant) over\n" + " // the legs the crossing puts in the initial state. 0 if inapplicable.\n" + " __device__ inline int spincol_cross( int cross )\n" + " {\n" + " static const int spincol_part[npar] = %(spincol_part)s;\n" + " int perm[npar], ic[npar];\n" + " if ( !cross_perm_ic( cross, perm, ic ) ) return 0;\n" + " int factor = 1;\n" + " for ( int k = 0; k < %(ninitial)d; k++ ) factor *= spincol_part[perm[k]];\n" + " return factor;\n" + " }\n" + " // Identical-final-state factor (product of n!) of the crossed\n" + " // process. Flavor dependent -> runtime: two crossed final legs are\n" + " // identical when they carry the same flavor group (same representative\n" + " // PDG -- ids_base, conjugated to antipid_base when the leg swapped\n" + " // side) and the same actual flavor. FLAVOR is not permuted, so slot k\n" + " // reads cFlavors[iflavor][perm[k]].\n" + " __device__ int ident_cross( int cross, int iflavor )\n" + " {\n" + " static const int ids_base[npar] = %(ids_base)s;\n" + " static const int antipid_base[npar] = %(antipid_base)s;\n" + " int perm[npar], ic[npar];\n" + " cross_perm_ic( cross, perm, ic );\n" + " int bpid[npar];\n" + " for ( int k = 0; k < npar; k++ )\n" + " bpid[k] = ( ic[k] == 1 ) ? ids_base[perm[k]] : antipid_base[perm[k]];\n" + " bool used[npar];\n" + " for ( int k = 0; k < npar; k++ ) used[k] = false;\n" + " int fact = 1;\n" + " for ( int k = %(ninitial)d; k < npar; k++ )\n" + " {\n" + " if ( used[k] ) continue;\n" + " int n = 1;\n" + " for ( int l = k + 1; l < npar; l++ )\n" + " {\n" + " if ( used[l] ) continue;\n" + " if ( bpid[k] == bpid[l] &&\n" + " cFlavors[iflavor][perm[k]] == cFlavors[iflavor][perm[l]] )\n" + " {\n" + " used[l] = true;\n" + " n = n + 1;\n" + " fact = fact * n;\n" + " }\n" + " }\n" + " }\n" + " return fact;\n" + " }\n" + ) % {'spincol_part': arr(tables['spincol_part']), + 'ids_base': arr(tables['ids_base']), + 'antipid_base': arr(tables['antipid_base']), + 'cross_recorded': arr(['true' if c in scanned_crossings else 'false' + for c in range(ncross)]), + 'ninitial': ninitial} + + # Per-leg helicity states used to re-encode a crossed helicity config + # into its canonical code. allow_reverse=True is NOT optional: it is the + # order the cHel/tHel table itself is built in (get_helicity_matrix + # above, allow_reverse=True) AND the order the fortran ENCODE_HEL STATES + # table uses (get_helicity_encoder_dict), which together define the + # canonical code. get_helicity_states reverses the list for an + # ANTIparticle leg, so with allow_reverse=False every such leg's digit + # lookup is off by one state and the code comes out wrong: for + # u u~ > t t~ legs 1 and 4 give (+1,-1) not (-1,+1), and all 16 rows + # mis-encode. Like the fortran encoder this deliberately ignores + # wf['polarization'] -- the code space is the FULL mixed-radix space, a + # polarized leg simply never reaches its filtered-out digits. + pdict = me.get('processes')[0].get('model').get('particle_dict') + hstates = [pdict[wf.get('pdg_code')].get_helicity_states(True) + for wf in me.get_external_wavefunctions()] + hnstate = [len(s) for s in hstates] + maxhel = max(hnstate) if hnstate else 1 + states_flat = [] + for k in range(nexternal): + states_flat.extend(hstates[k][i] if i < hnstate[k] else 0 + for i in range(maxhel)) + # Crossed-event selected helicity (allselhel), validated at runtime + # against the fortran backend -- see the generated comment. + crossing_decl = crossing_decl + ( + " // ---- Crossed-event selected helicity code (allselhel) ----\n" + " // For a crossed event the reported per-event helicity must be the\n" + " // CROSSED code, not the base row: mirror the fortran\n" + " // APPLY_CROSSING_TABLE, which permutes the base NHEL config by the\n" + " // crossing slot permutation (NHEL(k)=NHEL_IN(perm(k)), no sign flip\n" + " // -- the NSF sign lives in IC), then ENCODE_HEL it into the\n" + " // canonical mixed-radix code over the base per-leg helicity states.\n" + " // cross 0 is the identity (base row+1), so the non-crossing path is\n" + " // unchanged.\n" + " //\n" + " // The perm digit-permute with NO NSF sign flip is the right\n" + " // transform, and it is what mg7 needs: the LHE writer indexes the\n" + " // BASE helicity table POSITIONALLY (export_mg7 ships\n" + " // get_helicity_matrix() as `helicities`, lhe_output.cpp reads row\n" + " // `helicity_index` slot by slot), so the reported row must be the\n" + " // base row whose config EQUALS the crossed one -- not the row the\n" + " // lane evaluated. Validated at runtime against the fortran backend\n" + " // (SMATRIXHEL per canonical code at the same momenta and the same\n" + " // extended flavor id): for the recorded crossing of p p > w+ j and\n" + " // for u u~ > g g crossed to u g > u g, every reported code has a\n" + " // non-zero |M|^2 and the reported frequencies follow the fortran\n" + " // per-code |M|^2 weights.\n" + " //\n" + " // xhel_states MUST be the allow_reverse=True per-leg order: it is\n" + " // both the order cHel is built in and the order the fortran\n" + " // ENCODE_HEL STATES table uses. allow_reverse=False reverses every\n" + " // ANTIparticle leg, which silently shifts the code onto a row whose\n" + " // |M|^2 is zero and aborts helicity-by-helicity reweighting.\n" + " //\n" + " // Limitation (shared with the fortran ENCODE_HEL, whose D=1 fallback\n" + " // this mirrors): a crossing that lands a leg in a slot with a\n" + " // DIFFERENT number of helicity states -- e.g. a massive vector moved\n" + " // into a fermion slot -- has no representable base row, and the\n" + " // lookup falls back to digit 0. That can only happen for a crossing\n" + " // that is merely APPLICABLE and never recorded by the generation\n" + " // (a recorded one only ever swaps partons, all 2-state); consumers\n" + " // must intersect with the recorded crossing codes anyway.\n" + " __device__ inline int selected_hel_code( int base_ihel, unsigned int flavor_id )\n" + " {\n" + " const int xcross = (int)( flavor_id / nmaxflavor );\n" + " if ( xcross == 0 ) return base_ihel + 1;\n" + " constexpr int maxhel = %(maxhel)d;\n" + " static const int xhel_nhstate[npar] = %(xnhstate)s;\n" + " static const int xhel_states[npar * maxhel] = %(xstates)s;\n" + " int xperm[npar], xic[npar];\n" + " cross_perm_ic( xcross, xperm, xic ); // NSF sign in xic is not used here\n" + " int code = 0;\n" + " for ( int k = 0; k < npar; k++ )\n" + " {\n" + " const int val = (int)cHel[base_ihel][xperm[k]];\n" + " int d = 0;\n" + " for ( int dd = 0; dd < xhel_nhstate[k]; dd++ )\n" + " {\n" + " if ( xhel_states[k * maxhel + dd] == val )\n" + " {\n" + " d = dd;\n" + " break;\n" + " }\n" + " }\n" + " code = code * xhel_nhstate[k] + d;\n" + " }\n" + " return code + 1;\n" + " }\n" + "#ifndef MGONGPUCPP_GPUIMPL\n" + " // Reported helicity of ONE lane. The host good-helicity loop runs\n" + " // over cNGoodMaxCross and every lane evaluates its OWN crossing's\n" + " // ighel-th good helicity (cGoodHelOfCross, see calculate_jamps), so\n" + " // the reported row must be read from that same per-crossing list.\n" + " // Reading the union cGoodHel[ighel] instead names a row the lane\n" + " // never evaluated: as soon as the crossings widen the union beyond a\n" + " // single crossing's list the two lists stop agreeing even for the\n" + " // identity crossing, and the event is written out with a helicity\n" + " // whose |M|^2 is zero (breaking helicity-by-helicity reweighting).\n" + " __device__ inline int selected_hel_code_lane( int ighel, unsigned int flavor_id )\n" + " {\n" + " const int lcross = (int)( flavor_id / nmaxflavor );\n" + " const int lngood = cNGoodPerCross[lcross];\n" + " // ighel < lngood always holds when the CDF selected this lane's\n" + " // row (the rows past lngood add nothing to the running sum); the\n" + " // clamp only keeps a degenerate lane inside the table.\n" + " const int lbase = cGoodHelOfCross[lcross][( ighel < lngood ) ? ighel\n" + " : ( lngood > 0 ? lngood - 1 : 0 )];\n" + " return selected_hel_code( lbase, flavor_id );\n" + " }\n" + "\n" + " // Same, for a lane whose crossing is C-parity de-duplicated: the row it\n" + " // evaluated stands for a PAIR counted twice, so the representative and\n" + " // its mirror must come out at equal rate or the event helicity\n" + " // distribution is biased while |M|^2 stays perfectly correct. The fair\n" + " // coin is recycled from the selection variate -- given that the\n" + " // (unnormalised) CDF landed in [lo,hi), rnd is uniform there, so its\n" + " // position inside the bin is an independent U(0,1) -- so no extra random\n" + " // number is drawn and the stream shared with the integrator is intact.\n" + " __device__ inline int selected_hel_code_lane_csym( int ighel, unsigned int flavor_id,\n" + " fptype rnd, fptype lo, fptype hi )\n" + " {\n" + " const int lcross = (int)( flavor_id / nmaxflavor );\n" + " const int lngood = cNGoodPerCross[lcross];\n" + " int lbase = cGoodHelOfCross[lcross][( ighel < lngood ) ? ighel\n" + " : ( lngood > 0 ? lngood - 1 : 0 )];\n" + " if( cCsymOkCross[lcross] ) {\n" + " const fptype _w = hi - lo;\n" + " if( _w > (fptype)0 && !( ( rnd - lo ) < (fptype)0.5 * _w ) ) lbase = cFlip[lbase];\n" + " }\n" + " return selected_hel_code( lbase, flavor_id );\n" + " }\n" + "#endif\n" + ) % {'xnhstate': arr(hnstate), + 'maxhel': maxhel, 'xstates': arr(states_flat)} + + sigmakin_denominator = ( + " // Per-event crossing-aware denominator: cross may differ per event.\n" + " // cross==0 keeps the historical IDEN/BROKEN_SYM path; a genuine\n" + " // crossing rebuilds it from the crossed initial-state spin*color\n" + " // times the identical-final-state factor of the actual flavors.\n" + " // Applied per lane straight onto MEs_sv: an invalid crossing must\n" + " // ASSIGN 0 (not multiply), because its unphysical momentum\n" + " // relabelling can make the lane's |M|^2 a NaN and nan*0 = nan.\n" + " for ( int ieppV = 0; ieppV < neppV; ++ieppV )\n" + " {\n" + " const unsigned int fid = iflavorVec[ievt0 + ieppV];\n" + " const int dcr = (int)( fid / nmaxflavor );\n" + " const int dfl = (int)( fid % nmaxflavor );\n" + " fptype& me = reinterpret_cast( &MEs_sv )[ieppV];\n" + " if ( dcr == 0 )\n" + " me *= (fptype)broken_symmetry_factor( dfl ) / helcolDenominators[0];\n" + " else if ( spincol_cross( dcr ) == 0 )\n" + " me = (fptype)0.; // invalid crossing (out of range / overlapping swap) -> ME 0\n" + " else\n" + " me *= (fptype)1. / ( (fptype)spincol_cross( dcr ) * (fptype)ident_cross( dcr, dfl ) );\n" + " }" + ) + + # Crossed physical signed PDG per (extended id, leg), rebuilt at runtime + # like the fortran GET_PDG_FOR_FLAVOR: base signed PDG of the leg the + # crossing moves into slot ipar (base_pdg per (flavor, leg)), charge- + # conjugated when that leg swapped side -- no per-crossing PDG table. + flavorpdg_body = ( + " const int ncross = ( npar + 1 ) * ( npar + 1 );\n" + " if ( iflavor < 0 || iflavor >= ncross * nmaxflavor ) return 0;\n" + " static const int base_pdg[nmaxflavor * npar] = %(base_pdg)s;\n" + " static const int base_antipdg[nmaxflavor * npar] = %(base_antipdg)s;\n" + " const int cross = iflavor / nmaxflavor;\n" + " const int flav0 = iflavor %% nmaxflavor;\n" + " int perm[npar], ic[npar];\n" + " if ( !cross_perm_ic( cross, perm, ic ) ) return 0; // invalid crossing\n" + " const int src = perm[ipar];\n" + " return ( ic[ipar] == 1 ) ? base_pdg[flav0 * npar + src]\n" + " : base_antipdg[flav0 * npar + src];" + ) % {'base_pdg': arr(pdg_flat[:nflav * nexternal]), + 'base_antipdg': arr(antipdg_flat[:nflav * nexternal])} + + return { + 'crossing_decl': crossing_decl, + # Good-helicity UNION now also spans crossings: sample every + # RECORDED extended flavor id (see cross_recorded; spincol==0 is + # still skipped) so cGoodHel covers the crossed helicity rows too. A + # helicity that vanishes for a given event's crossing simply + # contributes 0 at run time. + # + # The loop still counts to ncross*nflav but the two gates below cost + # nothing on a skipped code, whereas each code that gets through + # costs a full ncomb-helicity calculate_jamps scan. Scanning all + # APPLICABLE codes rather than the recorded ones was a 46x one-off + # startup cost on g g > t t~ g g g (48 applicable, 0 recorded), which + # check_sa's `perf 1 32 8` reports as a 4.2x matrix-element slowdown + # because it amortises the scan over 8 iterations. + 'goodhel_scan_count': str(ncross * nflav), + 'goodhel_scan_skip': + ' if ( !cross_recorded( iflav / nmaxflavor ) ) continue;\n' + ' if ( spincol_cross( iflav / nmaxflavor ) == 0 ) continue;\n ', + 'sigmakin_denominator': sigmakin_denominator, + 'flavorpdg_body': flavorpdg_body, + # Reported per-event helicity: the row this lane actually evaluated + # (its crossing's ighel-th good helicity, NOT the union list), mapped + # to the crossed code for the event's crossing (the crossed mapping + # itself is unvalidated at runtime, see selected_hel_code). + 'selected_hel_code_1': + 'selected_hel_code_lane_csym( ighel, iflavorVec[ievt], allrndhel[ievt] * _ctot, _clo, _chi )', + 'selected_hel_code_2': + 'selected_hel_code_lane_csym( ighel, iflavorVec[ievt2], allrndhel[ievt2] * _ctot, _clo, _chi )', + # (A) Per-lane helicity: the C++ good-hel loop runs once over the + # per-crossing good-hel count; each lane uses its crossing's ighel-th + # good helicity (the union is never materialised on the hot path). + # Host only -- GPU + mixed-precision stay on the union (untested here). + # Validated byte-identical on sse4 with divergent lanes (see + # [[mg7-perlane-helicity]]). + 'goodhel_percross_statics': + '#ifndef MGONGPUCPP_GPUIMPL\n' + ' static constexpr int cNcross = ( npar + 1 ) * ( npar + 1 );\n' + ' static int cGoodHelOfCross[cNcross][ncomb]; // per-crossing good-hel rows\n' + ' static int cNGoodPerCross[cNcross]; // #good hel per crossing\n' + ' static int cNGoodMaxCross; // max over crossings\n' + '#endif', + 'goodhel_percross_decl': + ' static bool _gpc[cNcross][ncomb];\n' + ' for( int _c = 0; _c < cNcross; _c++ ) for( int _h = 0; _h < ncomb; _h++ ) _gpc[_c][_h] = false;\n', + 'goodhel_percross_record': + ' _gpc[iflav / nmaxflavor][ihel] = true;\n', + 'goodhel_percross_build': + ' for( int _c = 0; _c < cNcross; _c++ ) {\n' + ' int _n = 0;\n' + ' for( int _h = 0; _h < ncomb; _h++ ) if( _gpc[_c][_h] ) { cGoodHelOfCross[_c][_n] = _h; _n++; }\n' + ' cNGoodPerCross[_c] = _n;\n' + ' // Per-crossing C-parity verdict: the validating scan ran, no pair\n' + ' // mismatched for THIS crossing, and every good row of this crossing\n' + ' // sits in a distinct pair whose partner is also good for it.\n' + ' bool _ok = cCsymScanned && !cCsymBadCross[_c] && _n > 0;\n' + ' for( int _h = 0; _h < ncomb && _ok; _h++ )\n' + ' if( _gpc[_c][_h] && ( cFlip[_h] == _h || !_gpc[_c][cFlip[_h]] ) ) _ok = false;\n' + '#ifdef MGONGPU_NOCSYM\n' + ' _ok = false; // ablation knob: force the full helicity sum\n' + '#endif\n' + ' cCsymOkCross[_c] = _ok;\n' + ' }\n' + ' // ALL-OR-NOTHING ACROSS CROSSINGS, and not for a physics reason:\n' + ' // reducing only some of them would leave cNGoodPerCross non-uniform,\n' + ' // and the lanes of a SHORTER crossing would then reach the\n' + ' // ighel >= cNGoodPerCross padding row (_hr = -1 in calculate_jamps).\n' + ' // That row yields NaN rather than 0 -- its zeroed wavefunctions give a\n' + ' // 0/0 propagator, and for a VALID crossing the per-event denominator\n' + ' // multiplies instead of assigning 0, so the NaN reaches the output.\n' + ' // Pre-existing hazard (reproduce with -DMGONGPU_NOCSYM by shortening\n' + ' // one crossing\'s list by hand), latent today only because every\n' + ' // crossing happens to have the same good-hel count. Keeping the\n' + ' // verdict uniform preserves that invariant exactly.\n' + ' bool _allok = cCsymScanned;\n' + ' for( int _c = 0; _c < cNcross; _c++ )\n' + ' if( cNGoodPerCross[_c] > 0 && !cCsymOkCross[_c] ) _allok = false;\n' + ' for( int _c = 0; _c < cNcross; _c++ ) {\n' + ' if( !_allok ) { cCsymOkCross[_c] = false; continue; }\n' + ' if( !cCsymOkCross[_c] ) continue;\n' + ' int _r = 0;\n' + ' for( int _g = 0; _g < cNGoodPerCross[_c]; _g++ )\n' + ' if( cGoodHelOfCross[_c][_g] < cFlip[cGoodHelOfCross[_c][_g]] )\n' + ' { cGoodHelOfCross[_c][_r] = cGoodHelOfCross[_c][_g]; _r++; }\n' + ' for( int _g = _r; _g < ncomb; _g++ ) cGoodHelOfCross[_c][_g] = 0;\n' + ' cNGoodPerCross[_c] = _r;\n' + ' }\n' + ' cNGoodMaxCross = 0;\n' + ' for( int _c = 0; _c < cNcross; _c++ ) if( cNGoodPerCross[_c] > cNGoodMaxCross ) cNGoodMaxCross = cNGoodPerCross[_c];\n', + 'sigmakin_hel_bound': 'cNGoodMaxCross', + # No per-page precompute in sigmaKin: pass the good-hel index ighel + # and let the external block derive the per-lane helicity per page + # (so mixed precision's second page is handled). The scalar ihel arg + # is unused when crossing (a dummy 0). + 'sigmakin_perlane_decl': '', + 'sigmakin_ihel_expr': '0', + 'calc_jamps_ihlane_arg': ', ighel', + # ---- C-parity de-duplication, PER CROSSING ---- + # The symmetry holds under crossing: a crossing acts on a helicity + # row as a slot permutation plus a per-leg sign flip, and global + # negation commutes with both, so mirror(crossed row) == + # crossed(mirror row) and each crossing's good-hel set is closed + # under the mirror (verified exactly, reldiff 0 on every row, for + # u u~ > g g at extended flavor ids 1, 3, 4, 5, 6 and 21). + # What makes this harder than the uncrossed path is that lanes of ONE + # SIMD page may carry DIFFERENT crossings, so the verdict, the weight + # and the 50/50 are all per crossing and applied PER LANE. + # NB emitted right after goodhel_percross_statics (the template + # concatenates the two holes), so cNcross is already in scope. + 'csym_statics': + '\n#ifndef MGONGPUCPP_GPUIMPL\n' + ' static int cFlip[ncomb]; // C-parity partner: every helicity negated\n' + ' static bool cCsymScanned; // the validating scan actually ran\n' + ' static bool cCsymBadCross[cNcross]; // per crossing: a pair mismatched\n' + ' static bool cCsymOkCross[cNcross]; // per crossing: de-duplication on\n' + '#endif', + 'csym_gh_flip': + ' fptype me_scan[ncomb][neppV]; // per-hel |M|^2 of this scan page, for the C-parity test\n' + ' cCsymScanned = false;\n' + ' for( int _c = 0; _c < cNcross; _c++ ) { cCsymBadCross[_c] = false; cCsymOkCross[_c] = false; }\n' + ' for( int _h = 0; _h < ncomb; _h++ ) {\n' + ' cFlip[_h] = _h;\n' + ' for( int _j = 0; _j < ncomb; _j++ ) {\n' + ' bool _same = true;\n' + ' for( int _k = 0; _k < npar; _k++ ) if( cHel[_j][_k] != -cHel[_h][_k] ) _same = false;\n' + ' if( _same ) { cFlip[_h] = _j; break; }\n' + ' }\n' + ' }\n', + 'csym_gh_record': + ' for( int _ie = 0; _ie < neppV; ++_ie ) me_scan[ihel][_ie] = allMEs[ievt00 + _ie];\n', + # Latch per CROSSING (iflav encodes cross*nmaxflavor + flav) so one + # parity-violating crossing cannot disable the others. Same absolute + # floor as the uncrossed path: a relative test alone compares the + # roundoff noise of two numerically-zero rows against itself. + 'csym_gh_check': + ' { fptype _mmax = (fptype)0.;\n' + ' for( int _h = 0; _h < ncomb; _h++ )\n' + ' for( int _ie = 0; _ie < neppV; ++_ie ) {\n' + ' const fptype _v = me_scan[_h][_ie] < (fptype)0. ? -me_scan[_h][_ie] : me_scan[_h][_ie];\n' + ' if( _v > _mmax ) _mmax = _v;\n' + ' }\n' + ' const int _cr = iflav / nmaxflavor;\n' + ' for( int _h = 0; _h < ncomb; _h++ ) {\n' + ' if( cFlip[_h] > _h ) {\n' + ' for( int _ie = 0; _ie < neppV; ++_ie ) {\n' + ' const fptype _a = me_scan[_h][_ie];\n' + ' const fptype _b = me_scan[cFlip[_h]][_ie];\n' + ' fptype _d = _a - _b; if( _d < (fptype)0. ) _d = -_d;\n' + ' fptype _aa = _a < (fptype)0. ? -_a : _a;\n' + ' fptype _bb = _b < (fptype)0. ? -_b : _b;\n' + ' if( _d > (fptype)1e-6 * ( _aa + _bb ) && _d > (fptype)1e-12 * _mmax ) cCsymBadCross[_cr] = true;\n' + ' }\n' + ' }\n' + ' }\n' + ' }\n' + ' cCsymScanned = true;\n', + 'csym_pairbuild': '', + # Per-lane doubling: the crossing is a per-event property, so build a + # 0/1 vector once per page rather than per helicity. + 'csym_page_decl': + ' fptype_sv _csymExtra{}; // per lane: 1 where this lane\'s crossing is de-duplicated\n' + ' for( int _ie = 0; _ie < neppV; _ie++ ) {\n' + ' const int _cr = (int)( iflavorVec[ievt00 + _ie] / nmaxflavor );\n' + ' reinterpret_cast( &_csymExtra )[_ie] = cCsymOkCross[_cr] ? (fptype)1. : (fptype)0.;\n' + ' }\n' + '#if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT\n' + ' fptype_sv _csymExtra2{};\n' + ' for( int _ie = 0; _ie < neppV; _ie++ ) {\n' + ' const int _cr = (int)( iflavorVec[ievt00 + neppV + _ie] / nmaxflavor );\n' + ' reinterpret_cast( &_csymExtra2 )[_ie] = cCsymOkCross[_cr] ? (fptype)1. : (fptype)0.;\n' + ' }\n' + '#endif\n', + 'csym_me_before': + ' const fptype_sv _me1before = E_ACCESS::kernelAccess( E_ACCESS::ieventAccessRecord( allMEs, ievt00 ) );\n' + '#if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT\n' + ' const fptype_sv _me2before = E_ACCESS::kernelAccess( E_ACCESS::ieventAccessRecord( allMEs, ievt00 + neppV ) );\n' + '#endif\n', + 'csym_weight': + ' {\n' + ' fptype_sv& _me1 = E_ACCESS::kernelAccess( E_ACCESS::ieventAccessRecord( allMEs, ievt00 ) );\n' + ' _me1 = _me1 + ( MEs_ighel[ighel] - _me1before ) * _csymExtra;\n' + ' MEs_ighel[ighel] = _me1;\n' + '#if defined MGONGPU_CPPSIMD and defined MGONGPU_FPTYPE_DOUBLE and defined MGONGPU_FPTYPE2_FLOAT\n' + ' fptype_sv& _me2 = E_ACCESS::kernelAccess( E_ACCESS::ieventAccessRecord( allMEs, ievt00 + neppV ) );\n' + ' _me2 = _me2 + ( MEs_ighel2[ighel] - _me2before ) * _csymExtra2;\n' + ' MEs_ighel2[ighel] = _me2;\n' + '#endif\n' + ' }\n', + 'csym_sel_1': + ' fptype _clo = (fptype)0;\n' + '#if defined MGONGPU_CPPSIMD\n' + ' const fptype _ctot = MEs_ighel[cNGoodMaxCross - 1][ieppV];\n' + ' const fptype _chi = MEs_ighel[ighel][ieppV];\n' + ' if( ighel > 0 ) _clo = MEs_ighel[ighel - 1][ieppV];\n' + '#else\n' + ' const fptype _ctot = MEs_ighel[cNGoodMaxCross - 1];\n' + ' const fptype _chi = MEs_ighel[ighel];\n' + ' if( ighel > 0 ) _clo = MEs_ighel[ighel - 1];\n' + '#endif\n', + 'csym_sel_2': + ' fptype _clo = (fptype)0;\n' + ' const fptype _ctot = MEs_ighel2[cNGoodMaxCross - 1][ieppV];\n' + ' const fptype _chi = MEs_ighel2[ighel][ieppV];\n' + ' if( ighel > 0 ) _clo = MEs_ighel2[ighel - 1][ieppV];\n', + 'extra_omp_shared': ', cCsymOkCross, cNGoodMaxCross', + } + #------------------------------------------------------------------------------------ import madgraph.core.helas_objects as helas_objects @@ -2234,13 +3288,22 @@ def get_reset_jamp_lines(self, color_amplitudes): # AV - define a custom HelasCallWriter # (NB: enable this via ProcessExporterMadMatrix.helas_exporter in output.py - this fixes #341) -class MadMatrixUFOHelasCallWriter(helas_call_writers.GPUFOHelasCallWriter): +class MadMatrixUFOHelasCallWriter(helas_call_writers.GPUFOHelasCallWriter, + jamp_optimiser.JampOptimiser): """ A Custom HelasCallWriter """ # Flavor-mask optimization: skip wavefunction/amplitude calls that vanish # for the selected flavor (see super_get_matrix_element_calls). Toggled by # the output command's --mask=True|False; default on. use_flavor_mask = True + # Write the color flows through the shared sub-expressions the color-flow + # optimisation finds, instead of one line per (color flow, amplitude) pair + # (see build_jamp_plan). Toggled by --jamp_optim=True|False. + jamp_optim = True + # Look for those sub-expressions by whole orbits of the permutations + # leaving the color basis invariant (see JampOptimiser). Toggled by + # --jamp_orbit=True|False. + jamp_orbit = True # Class structure information # - object # - dict(object) [built-in] @@ -2249,8 +3312,9 @@ class MadMatrixUFOHelasCallWriter(helas_call_writers.GPUFOHelasCallWriter): # - UFOHelasCallWriter(HelasCallWriter) [in madgraph/iolibs/helas_call_writers.py] # - CPPUFOHelasCallWriter(UFOHelasCallWriter) [in madgraph/iolibs/helas_call_writers.py] # - GPUFOHelasCallWriter(CPPUFOHelasCallWriter) [in madgraph/iolibs/helas_call_writers.py] - # - MadMatrixUFOHelasCallWriter(GPUFOHelasCallWriter) - # This class + # - MadMatrixUFOHelasCallWriter(GPUFOHelasCallWriter, JampOptimiser) + # This class (JampOptimiser is in madgraph/iolibs/jamp_optimiser.py and + # brings the color-flow optimisation shared with the fortran exporter) def __init__(self, *args, **opts): @@ -2404,6 +3468,166 @@ def format_coupling(self, call): def format_call(call): return call.replace('(','( ').replace(')',' )').replace(',',', ') + # --- Color flows through shared sub-expressions -------------------------- + # + # Written out as it stands, a color flow is one 'jamp_sv[i] += c*amp_sv[0]' + # per (color flow, amplitude) pair: eight thousand of them for g g > g g g + # g. The optimisation in jamp_optimiser finds the partial sums that several + # flows have in common and returns them as definitions + # + # TMP(i) = + frac * + # + # where an operand is either an amplitude or an earlier definition, leaving + # the color flows as a much shorter combination of those definitions. + # + # The fortran output has AMP(NGRAPHS) to read the amplitudes back from, so + # it prints the definitions as they come. Here every amplitude passes + # through the single slot amp_sv[0] and is gone by the next diagram, so the + # definitions are accumulated instead: each amplitude is added into the one + # definition that uses it while it is still there, and the definitions of + # definitions follow as soon as everything they need has been seen (see + # jamp_definition_order). + + @staticmethod + def jamp_number(value): + """A C++ literal for a real coefficient, kept exact where it can be + (an integer, or a ratio the compiler divides out itself).""" + + frac = Fraction(value).limit_denominator(10**9) + if float(frac) == float(value): + if frac.denominator == 1: + return '%d.' % frac.numerator + return '%d. / %d.' % (frac.numerator, frac.denominator) + text = '%.17g' % value + if '.' not in text and 'e' not in text and 'n' not in text: + text += '.' + return text + + @classmethod + def jamp_factor(cls, value): + """(sign, factor) of a JAMP coefficient, the factor being the C++ text + multiplying the operand (empty when the coefficient is +-1).""" + + number = complex(value) + if number.imag == 0: + magnitude, imaginary = number.real, False + elif number.real == 0: + magnitude, imaginary = number.imag, True + else: + # never seen in practice: a color coefficient is real or imaginary + return 1, 'cxtype( %s, %s ) * ' % (cls.jamp_number(number.real), + cls.jamp_number(number.imag)) + sign = -1 if magnitude < 0 else 1 + magnitude = abs(magnitude) + if magnitude == 1: + return sign, ('cxtype( 0, 1 ) * ' if imaginary else '') + if imaginary: + return sign, '%s * cxtype( 0, 1 ) * ' % cls.jamp_number(magnitude) + return sign, '%s * ' % cls.jamp_number(magnitude) + + @classmethod + def jamp_statement(cls, target, terms, assign): + """'target = t1 - t2;' (assign) or 'target += t1 - t2;', from a list of + (coefficient, operand) terms.""" + + pieces = [] + for pos, (value, name) in enumerate(terms): + sign, factor = cls.jamp_factor(value) + if pos == 0 and not assign and len(terms) == 1: + # the common case: keep the sign on the operator, as the + # expanded output does + return '%s %s= %s%s;' % (target, '-' if sign < 0 else '+', + factor, name) + if pos == 0: + pieces.append('%s%s%s' % ('-' if sign < 0 else '', factor, name)) + else: + pieces.append('%s %s%s' % ('-' if sign < 0 else '+', factor, name)) + return '%s %s %s;' % (target, '=' if assign else '+=', ' '.join(pieces)) + + def build_jamp_plan(self, matrix_element, color_amplitudes): + """Work out how the color flows are built from shared sub-expressions, + and return (ntmp, captures, combines, final): + - captures[n] are the lines to write while amplitude n sits in + amp_sv[0], as (line, target, is_first_write) so that a masked + amplitude can be told to zero its target first; + - combines[n] are the definitions ready once amplitude n has been + added, to write just after it; + - final are the lines assembling jamp_sv out of the definitions. + Returns None when there is nothing to share, so that the caller keeps + the expanded output.""" + + if not self.jamp_optim_enabled(): + return None + all_element = self.jamp_matrix(color_amplitudes) + if not all_element: + return None + new_mat, defs = self.optimise_jamp_matrix(all_element, + matrix_element=matrix_element) + if not defs: + return None + order, ready = self.jamp_definition_order(defs) + definition = {i: (amp1, amp2, frac) for i, amp1, amp2, frac, _nb in defs} + + # what each amplitude has to be added into while it is still in amp_sv + captures = defaultdict(list) # amplitude -> [(target, coefficient)] + for i, amp1, amp2, frac, _nb in defs: + for amp, coefficient in ((amp1, 1), (amp2, frac)): + if amp > 0: + captures[amp].append(('jampTmp_sv[%d]' % (i - 1), coefficient)) + for (jamp, var), factor in sorted(new_mat.items()): + if var > 0 and factor: + captures[var].append(('jamp_sv[%d]' % (jamp - 1), factor)) + + # the definitions in the order they become available, grouped by the + # amplitude they are ready after + ready_after = defaultdict(list) + for i in order: + ready_after[ready[i]].append(i) + + started = set() # definitions already assigned to + done = set() # definitions holding their full value + capture_lines = defaultdict(list) + combine_lines = defaultdict(list) + for amp in sorted(set(list(captures) + list(ready_after))): + for target, coefficient in captures.get(amp, []): + # jamp_sv is zeroed at the top of the event page, so it is only + # the definitions that have to start with an assignment + first = target.startswith('jampTmp_sv') and target not in started + if first: + started.add(target) + capture_lines[amp].append( + (self.jamp_statement(target, [(coefficient, 'amp_sv[0]')], + first), target, first)) + for i in ready_after.get(amp, []): + amp1, amp2, frac = definition[i] + operands = [operand for operand in (amp1, amp2) if operand < 0] + assert all(-operand in done for operand in operands), \ + 'a color-flow definition is used before it is complete' + done.add(i) + if not operands: + continue # both operands were amplitudes, already added + terms = [(coefficient, 'jampTmp_sv[%d]' % (-operand - 1)) + for operand, coefficient in ((amp1, 1), (amp2, frac)) + if operand < 0] + target = 'jampTmp_sv[%d]' % (i - 1) + first = target not in started + started.add(target) + combine_lines[amp].append( + self.jamp_statement(target, terms, first)) + assert len(started) == len(defs) == len(done), \ + 'a color-flow definition is never written' + + # what is left of the color flows: a combination of the definitions + final = [] + by_jamp = defaultdict(list) + for (jamp, var), factor in sorted(new_mat.items()): + if var < 0 and factor: + by_jamp[jamp].append((factor, 'jampTmp_sv[%d]' % (-var - 1))) + for jamp in sorted(by_jamp): + final.append(self.jamp_statement('jamp_sv[%d]' % (jamp - 1), + by_jamp[jamp], False)) + return len(defs), capture_lines, combine_lines, final + # AV - replace helas_call_writers.GPUFOHelasCallWriter method (improve formatting) def super_get_matrix_element_calls(self, matrix_element, color_amplitudes, multi_channel_map): """Return a list of strings, corresponding to the Helas calls for the matrix element""" @@ -2412,6 +3636,7 @@ def super_get_matrix_element_calls(self, matrix_element, color_amplitudes, multi assert isinstance(matrix_element, helas_objects.HelasMatrixElement), \ '%s not valid argument for get_matrix_element_calls' % \ type(matrix_element) + self.nb_tmp_jamp = 0 # Do not reuse the wavefunctions for loop matrix elements if isinstance(matrix_element, loop_helas_objects.LoopHelasMatrixElement): return self.get_loop_matrix_element_calls(matrix_element) @@ -2422,6 +3647,12 @@ def super_get_matrix_element_calls(self, matrix_element, color_amplitudes, multi if namp not in color: color[namp] = {} color[namp][njamp] = coeff + # Color flows through shared sub-expressions (None to write them out + # one (color flow, amplitude) pair at a time, as before) + jamp_plan = self.build_jamp_plan(matrix_element, color_amplitudes) + self.nb_tmp_jamp = jamp_plan[0] if jamp_plan else 0 + if jamp_plan is not None: + _ntmp, jamp_captures, jamp_combines, jamp_final = jamp_plan me = matrix_element.get('diagrams') matrix_element.reuse_outdated_wavefunctions(me) ###misc.sprint(multi_channel_map) @@ -2501,13 +3732,13 @@ def super_get_matrix_element_calls(self, matrix_element, color_amplitudes, multi // for GPU it is an int // for SIMD it is also an int, since it is constant across the SIMD vector #ifdef MGONGPUCPP_GPUIMPL - const unsigned int iflavor = F_ACCESS::kernelAccessConst( iflavorVec ); + const unsigned int iflavor = F_ACCESS::kernelAccessConst( iflavorVec )""" + self._crossing_flav_reduce() + """; #else const unsigned int* iflavor_rec = F_ACCESS::ieventAccessRecordConst( iflavorVec, ievt0 ); const uint_sv iflavor_sv = F_ACCESS::kernelAccessConst( iflavor_rec ); - const unsigned int iflavor = reinterpret_cast(&iflavor_sv)[0]; + const unsigned int iflavor = reinterpret_cast(&iflavor_sv)[0]""" + self._crossing_flav_reduce() + """; #endif -""") +""" + (self._crossing_preamble(matrix_element) if getattr(self, 'use_crossing_ic', False) else '')) diagrams = matrix_element.get('diagrams') diag_to_config = {} for config in sorted(multi_channel_map.keys()): @@ -2531,6 +3762,23 @@ def _guard_open(group_mask): # Emit the opening of an `if` guard for a non-full grouped mask. return 'if( ( 0x%xULL >> iflavor ) & 0x1ULL ) {' % group_mask + # OM - the four gluon optimisation (set merge_quartic_vertices). A quartic + # current and the cubic current carrying the same colour factor are + # summed into a third one, which the amplitude reads instead, so that + # one call gets both contributions and the quartic amplitude is never + # computed. The sum is written as soon as the later of the two + # currents is made. Unlike Fortran there is no AMP array here, so the + # amplitudes which cannot be reached this way are simply left alone: + # their own colour coefficients put them in the right JAMPs, which is + # why get_color_amplitudes is asked not to drop them. + sums, sum_uses, sum_folded = matrix_element.get_quartic_current_sums() + sum_slots = matrix_element.get_quartic_sum_me_ids() + sum_written = set() + sum_after = {} + for isum, (cubic, quartic, coeff) in enumerate(sums): + sum_after.setdefault(max(cubic.get('number'), + quartic.get('number')), []).append(isum) + id_amp = 0 for diagram in matrix_element.get('diagrams'): ###print('DIAGRAM %3d: #wavefunctions=%3d, #diagrams=%3d' % @@ -2547,13 +3795,39 @@ def _guard_open(group_mask): res.append('}') else: res.append(call) + for isum in sum_after.get(wf.get('number'), []): + # a wavefunction number can be listed by more than one + # diagram here, and the sum must only be written once + if isum in sum_written: + continue + sum_written.add(isum) + cubic, quartic, coeff = sums[isum] + res.append('%s( aloha_obj[%d], aloha_obj[%d],' + ' aloha_obj[%d] );' + % ('SUMW_1' if coeff == 1 else 'SUBW_1', + cubic.get('me_id') - 1, + quartic.get('me_id') - 1, + sum_slots[isum] - 1)) if len(diagram.get('wavefunctions')) == 0 : res.append('// (none)') # AV res.append('\n // Amplitude(s) for diagram number %d' % diagram.get('number')) for amplitude in diagram.get('amplitudes'): id_amp +=1 + if amplitude.get('number') in sum_folded: + continue # summed into another amplitude as a current namp = amplitude.get('number') amplitude.set('number', 1) + # OM - read the current sum in place of the cubic current it + # was built from, the same way the Fortran writer does + sum_original = [] + for mother in amplitude.get('mothers'): + isum = sum_uses.get(namp, {}).get(mother.get('number')) + if isum is None: + continue + sum_original.append((mother, mother.get('me_id'))) + mother.set('me_id', sum_slots[isum]) amp_block = [ self.get_amplitude_call(amplitude) ] # AV new: avoid format_call + for mother, me_id in sum_original: + mother.set('me_id', me_id) if id_amp in diag_to_config: ###res.append("if( channelId == %i ) numerators_sv += cxabs2( amp_sv[0] );" % diag_to_config[id_amp]) # BUG #472 ###res.append("if( channelId == %i ) numerators_sv += cxabs2( amp_sv[0] );" % id_amp) # wrong fix for BUG #472 @@ -2563,27 +3837,45 @@ def _guard_open(group_mask): amp_block.append(" numerators_sv[%i] += cxabs2( amp_sv[0] );" % (diagnum-1)) amp_block.append(" denominators_sv += cxabs2( amp_sv[0] );") amp_block.append("}") - for njamp, coeff in color[namp].items(): - scoeff = OneProcessExporterMadMatrix.coeff(*coeff) # AV - if scoeff[0] == '+' : scoeff = scoeff[1:] - scoeff = scoeff.replace('(','( ') - scoeff = scoeff.replace(')',' )') - scoeff = scoeff.replace(',',', ') - scoeff = scoeff.replace('*',' * ') - scoeff = scoeff.replace('/',' / ') - if scoeff.startswith('-'): amp_block.append('jamp_sv[%s] -= %samp_sv[0];' % (njamp, scoeff[1:])) # AV - else: amp_block.append('jamp_sv[%s] += %samp_sv[0];' % (njamp, scoeff)) # AV # The amplitude (and the jamp/channel contributions that read its # amp_sv[0]) only contributes for the flavors in the diagram's # mask, so guard the whole block as a unit. gmask = diag_group_mask.get(id(diagram)) + before_guard = [] + if jamp_plan is None: + for njamp, coeff in color[namp].items(): + scoeff = OneProcessExporterMadMatrix.coeff(*coeff) # AV + if scoeff[0] == '+' : scoeff = scoeff[1:] + scoeff = scoeff.replace('(','( ') + scoeff = scoeff.replace(')',' )') + scoeff = scoeff.replace(',',', ') + scoeff = scoeff.replace('*',' * ') + scoeff = scoeff.replace('/',' / ') + if scoeff.startswith('-'): amp_block.append('jamp_sv[%s] -= %samp_sv[0];' % (njamp, scoeff[1:])) # AV + else: amp_block.append('jamp_sv[%s] += %samp_sv[0];' % (njamp, scoeff)) # AV + else: + for line, target, first in jamp_captures.get(namp, []): + if first and gmask is not None: + # the guard can skip the line that opens this + # sub-expression, so it has to start from zero + before_guard.append('%s = cxzero_sv();' % target) + line = line.replace(' = ', ' += ', 1) + amp_block.append(line) + res.extend(before_guard) if gmask is not None: res.append(_guard_open(gmask)) res.extend(amp_block) res.append('}') else: res.extend(amp_block) + if jamp_plan is not None: + # sub-expressions that have now seen every amplitude they + # need: they always run, whatever the flavor + res.extend(jamp_combines.get(namp, [])) if len(diagram.get('amplitudes')) == 0 : res.append('// (none)') # AV + if jamp_plan is not None: + res.append('\n // *** COLOR FLOWS FROM THE SHARED SUB-EXPRESSIONS ***') + res.extend(jamp_final) ###res.append('\n // *** END OF DIAGRAMS ***' ) # AV - no longer needed ('COLOR MATRIX BELOW') return res @@ -2648,13 +3940,195 @@ def get_matrix_element_calls(self, matrix_element, color_amplitudes, multi_chann if not item.startswith('\n') and not item.startswith('#'): res[i]=' '+item return res + # ------------------------------------------------------------------ + # Crossing-symmetry helpers (only active when self.use_crossing_ic). + # When off, every path below is a no-op and the emitted code is + # byte-identical to the historical (no-crossing) output. + # ------------------------------------------------------------------ + def _crossing_flav_reduce(self): + """Reduce the extended flavor id to the flavor group index (flav_use). + The runtime iflavorVec entry is cross*nmaxflavor+flav_use; flav_use is + what indexes cFlavors/masks (constant across the SIMD page).""" + return ' % nmaxflavor' if getattr(self, 'use_crossing_ic', False) else '' + + def _crossing_tables(self, matrix_element): + import madgraph.iolibs.export_v4 as export_v4 + return export_v4.ProcessExporterFortran.compute_crossing_tables( + self, matrix_element) + + def _crossing_preamble(self, matrix_element): + """Per-event momentum permutation for crossing symmetry (C++/SIMD). + + All events in a SIMD page share flav_use but may carry DIFFERENT + crossings, so this gather is genuinely per-event (NOT vectorized): for + each event we permute its momenta into the crossed slot order (xmom, + positive energy preserved) and record the per-event NSF sign flips + (icsign). The momentum sign flip of a swapped leg is applied through the + NSF flag inside the HELAS routines (see _crossing_external_block).""" + return """#ifndef MGONGPUCPP_GPUIMPL + // === CROSSING SYMMETRY: per-event momentum permutation (NOT vectorized) === + // The crossing slot permutation and NSF signs are decoded per event from + // its crossing code (cross_perm_ic), not read from a per-crossing table. + alignas( mgOnGpu::cppAlign ) fptype xmom[npar * np4 * neppV]; + fptype_sv icsign[npar]; + // 2 scratch external wavefunctions for the per-event NSF-sign blend + fptype_sv pvec_x[2][np4]; + cxtype_sv w_x[2][nw6]; + ALOHAOBJ aloha_x[2]; + aloha_x[0] = ALOHAOBJ{ pvec_x[0], w_x[0] }; + aloha_x[1] = ALOHAOBJ{ pvec_x[1], w_x[1] }; + for( int ieppV = 0; ieppV < neppV; ++ieppV ) + { + const int xcr = (int)( iflavorVec[ievt0 + ieppV] / nmaxflavor ); + // GUARD: the good-helicity scan only builds a cGoodHelOfCross row for the + // crossings this ME records, so a code outside that set would find an + // empty row, mask every helicity in the per-lane blend below, and hand + // back a SILENTLY ZERO |M|^2 -- an event quietly lost, not a crash. Fail + // loudly instead. (_ighel < 0 is the good-helicity scan itself, which + // runs before the table exists and is gated by cross_recorded already.) + // + // A structurally INVALID code (an overlapping swap, spincol_cross == 0) + // is deliberately NOT an error: the per-event denominator already + // ASSIGNS 0 for it, which is the documented contract. Only an + // APPLICABLE-but-unrecorded code is the ambiguous, dangerous case. + // + // Order matters: cross_recorded is a table lookup but spincol_cross runs + // cross_perm_ic, and this sits in the per-helicity path (ncomb calls per + // page). Short-circuiting on the recorded test keeps spincol_cross off + // the hot path for every event that has a recorded crossing, i.e. all + // of them outside the error case. + if( _ighel >= 0 && !cross_recorded( xcr ) && spincol_cross( xcr ) != 0 ) + { + std::cerr << "ERROR! calculate_jamps: event " << ( ievt0 + ieppV ) + << " carries crossing code " << xcr + << ", which this process does not record: no good-helicity row was" + << " scanned for it and its matrix element would be silently zero." + << std::endl; + std::abort(); + } + int xperm[npar], xic[npar]; + cross_perm_ic( xcr, xperm, xic ); + for( int s = 0; s < npar; ++s ) + { + const int src = xperm[s]; + for( int ip4 = 0; ip4 < np4; ++ip4 ) + xmom[s * np4 * neppV + ip4 * neppV + ieppV] = + MemoryAccessMomenta::ieventAccessIp4IparConst( momenta, ieppV, ip4, src ); + reinterpret_cast( &icsign[s] )[ieppV] = (fptype)xic[s]; + } + } +#endif +""" + + @staticmethod + def _hel_state_values(spin, mass): + """Helicity values of an external leg (matching Particle.get_helicity_ + states) so the per-lane blend can loop over exactly the states cHel + holds. Scalars (spin 1) have none. Massive vectors add the 0 state.""" + massless = mass in ('ZERO', 'zero') + if spin == 2: # fermion + return [-1, 1] + if spin == 3: # vector + return [-1, 1] if massless else [-1, 0, 1] + if spin == 5: # spin-2 + return [-2, 2] if massless else [-2, -1, 0, 1, 2] + return None # spin 1 scalar (no helicity) + + def _crossing_external_block(self, wf, argument): + """External HELAS call under crossing symmetry (C++/SIMD). + + Reads the per-event permuted momenta (xmom, in crossed slot order) and + applies the per-event NSF sign flip by computing the wavefunction twice + (nsf = +base and -base) and blending lane-wise through icsign. + + Helicity is PER-LANE: each lane's helicity row is _ihlane[lane] (set by + sigmaKin from the event's crossing; nullptr -> the scalar ihel, used by + getGoodHel). For a helicity-carrying leg the wavefunction is built for + each of the leg's helicity states and accumulated weighted by a per-lane + mask (does this lane want state _v?), so a single pass computes each + lane's own good helicity. get_amp downstream stays fully SIMD. Scalars + carry no helicity, so their block is the plain NSF blend. GPU unchanged.""" + routine = helas_call_writers.HelasCallWriter.mother_dict[ + argument.get_spin_state_number()].lower() + routine = routine + 'x' * (6 - len(routine)) + routine = routine + '' + s = wf.get('number_external') - 1 + me = wf.get('me_id') - 1 + spin = argument.get('spin') + if spin == 1: + nsf = (-1) ** (wf.get('state') == 'initial') + elif argument.is_boson(): + nsf = (-1) ** (wf.get('state') == 'initial') + else: + nsf = - (-1) ** wf.get_with_flow('is_part') + mass = wf.get('mass') + states = self._hel_state_values(spin, mass) + + def one_call(sign, obj, hel=None): + if spin == 1: + call = '%s( xmom, %+d, cFlavors[iflavor][%d], %s, %d );' % \ + (routine, sign, s, obj, s) + else: + call = '%s( xmom, m_pars->%s, %s, %+d, cFlavors[iflavor][%d], %s, %d );' % \ + (routine, mass, hel, sign, s, obj, s) + return self.format_coupling(call) + + lines = ['#ifndef MGONGPUCPP_GPUIMPL'] + if states is None: + # Scalar: no helicity, plain NSF blend (unchanged). + lines.append(' ' + one_call(nsf, 'aloha_x[0]')) + lines.append(' ' + one_call(-nsf, 'aloha_x[1]')) + lines.append(' { const fptype_sv _sp = ( icsign[%d] + (fptype)1. ) * (fptype)0.5;' % s) + lines.append(' const fptype_sv _sm = ( (fptype)1. - icsign[%d] ) * (fptype)0.5;' % s) + lines.append(' for( int _k = 0; _k < np4; _k++ ) pvec_sv[%d][_k] = _sp * pvec_x[0][_k] + _sm * pvec_x[1][_k];' % me) + lines.append(' for( int _k = 0; _k < nw6; _k++ ) w_sv[%d][_k] = _sp * w_x[0][_k] + _sm * w_x[1][_k];' % me) + lines.append(' aloha_obj[%d].flv_index = aloha_x[0].flv_index; }' % me) + else: + stlist = ', '.join(str(v) for v in states) + lines.append(' { static const int _st%d[%d] = { %s };' % (s, len(states), stlist)) + lines.append(' bool _first%d = true;' % s) + lines.append(' for( int _vi = 0; _vi < %d; _vi++ ) {' % len(states)) + lines.append(' const int _v = _st%d[_vi];' % s) + lines.append(' ' + one_call(nsf, 'aloha_x[0]', '_v')) + lines.append(' ' + one_call(-nsf, 'aloha_x[1]', '_v')) + lines.append(' const fptype_sv _sp = ( icsign[%d] + (fptype)1. ) * (fptype)0.5;' % s) + lines.append(' const fptype_sv _sm = ( (fptype)1. - icsign[%d] ) * (fptype)0.5;' % s) + lines.append(' fptype_sv _hm{};') + # Per-lane helicity row, derived PER PAGE (ievt0 = this iParity page's + # first event) so mixed precision (nParity=2) picks the right page. + # _ighel<0 -> scalar ihel (getGoodHel scan / non-crossing). + lines.append(' for( int _ie = 0; _ie < neppV; _ie++ ) {') + lines.append(' int _hr;') + lines.append(' if( _ighel < 0 ) { _hr = ihel; }') + lines.append(' else { const int _cr = (int)( iflavorVec[ievt0 + _ie] / nmaxflavor ); _hr = ( _ighel < cNGoodPerCross[_cr] ) ? cGoodHelOfCross[_cr][_ighel] : -1; }') + lines.append(' reinterpret_cast( &_hm )[_ie] = ( _hr >= 0 && (int)cHel[_hr][%d] == _v ) ? (fptype)1. : (fptype)0.;' % s) + lines.append(' }') + lines.append(' if( _first%d ) {' % s) + lines.append(' for( int _k = 0; _k < np4; _k++ ) pvec_sv[%d][_k] = _hm * ( _sp * pvec_x[0][_k] + _sm * pvec_x[1][_k] );' % me) + lines.append(' for( int _k = 0; _k < nw6; _k++ ) w_sv[%d][_k] = _hm * ( _sp * w_x[0][_k] + _sm * w_x[1][_k] );' % me) + lines.append(' _first%d = false;' % s) + lines.append(' } else {') + lines.append(' for( int _k = 0; _k < np4; _k++ ) pvec_sv[%d][_k] += _hm * ( _sp * pvec_x[0][_k] + _sm * pvec_x[1][_k] );' % me) + lines.append(' for( int _k = 0; _k < nw6; _k++ ) w_sv[%d][_k] += _hm * ( _sp * w_x[0][_k] + _sm * w_x[1][_k] );' % me) + lines.append(' } }') + lines.append(' aloha_obj[%d].flv_index = aloha_x[0].flv_index; }' % me) + lines.append('#else') + # GPU: crossing not implemented; emit the plain (identity) external call + # so the file still compiles for GPU (only CPU/SIMD is validated). + gpu = self.get_external(wf, argument, _no_crossing=True) + lines.append(gpu.rstrip('\n')) + lines.append('#endif\n') + return '\n'.join(lines) + # AV - replace helas_call_writers.GPUFOHelasCallWriter method (improve formatting) # [GPUFOHelasCallWriter.format_coupling is called by GPUFOHelasCallWriter.get_external_line/generate_helas_call] # [GPUFOHelasCallWriter.get_external_line is called by GPUFOHelasCallWriter.get_external] # [=> GPUFOHelasCallWriter.get_external is called by GPUFOHelasCallWriter.generate_helas_call] # [GPUFOHelasCallWriter.generate_helas_call is called by UFOHelasCallWriter.get_wavefunction_call/get_amplitude_call] first_get_external = True - def get_external(self, wf, argument): + def get_external(self, wf, argument, _no_crossing=False): + if getattr(self, 'use_crossing_ic', False) and not _no_crossing: + return self._crossing_external_block(wf, argument) line = self.get_external_line(wf, argument) split_line = line.split(',') split_line = [ str.lstrip(' ').rstrip(' ') for str in split_line] # AV diff --git a/madmatrix/output.py b/madmatrix/output.py index 1820b10e96..10cd20ea83 100644 --- a/madmatrix/output.py +++ b/madmatrix/output.py @@ -54,6 +54,14 @@ class ProcessExporterMadMatrix(export_cpp.ProcessExporterMG7): # AV - keep OM's default for this plugin (using grouped_mode=False, "can decide to merge uu~ and u~u anyway") sa_symmetry = True + # The color sum can run on the (n-2)! Del Duca-Dixon-Maltoni basis for a + # multi-gluon process, but a color flow still has to be picked among the + # (n-1)! trace structures, so the trace basis is built alongside and the + # trace jamps are rebuilt from the DDM ones through the Kleiss-Kuijf + # relations (see set_color_flow_lines_cpp in model_handling.py). + support_ddm_color_basis = True + ddm_needs_flow_basis = True + # Below are the class variable that are defined in export_cpp.ProcessExporterGPU # AV - keep defaults from export_cpp.ProcessExporterGPU # Decide which type of merging is used [madevent/madweight] @@ -70,6 +78,12 @@ class ProcessExporterMadMatrix(export_cpp.ProcessExporterMG7): # AV - use a custom OneProcessExporter oneprocessclass = model_handling.OneProcessExporterMadMatrix + # Crossing symmetry (extended flavor id) is supported by the madmatrix / + # cudacpp CPU-SIMD backend (gated by --use_crossing, default on). The MG7 + # (pure-cpp mg7_v5) exporter keeps supports_crossing=False. When + # --use_crossing=False the generated output is byte-identical to before. + supports_crossing = True + # Information to find the template file that we want to include from madgraph # you can include additional file from the plugin directory as well # AV - use template files from PLUGINDIR instead of MG5DIR and add gpu/mgOnGpuVectors.h @@ -159,6 +173,19 @@ def _parse_flavor_mask_option(self): return val.strip().lower() not in ('false', '0', 'no', 'off') return bool(val) + def get_makefile_replace_dict(self, model): + """Add what madmatrix.mk needs to know about a host BLAS for the C++ + color sum. Whether a given process actually takes it is decided when + that process is written out (see cpp_blas_wanted); this only settles + whether one could be linked at all.""" + + from madgraph.iolibs.export_v4 import ProcessExporterFortran + replace_dict = super().get_makefile_replace_dict(model) + flags = ProcessExporterFortran.blas_available_flags() + replace_dict['cpp_blas_default'] = 'hasBlas' if flags else 'hasNoBlas' + replace_dict['cpp_blas_libflags'] = flags + return replace_dict + # AV - overload the default version: create CMake directory, do not create lib directory def copy_template(self, model): misc.sprint('Entering ProcessExporterMadMatrix.copy_template (initialise the directory)') @@ -177,9 +204,11 @@ def generate_subprocess_directory(self, matrix_element, cpp_helas_call_writer, p misc.sprint(' type(proc_number)=%s me=%s'%(type(proc_number) if proc_number is not None else None, proc_number)) # e.g. int misc.sprint("need to link", self.to_link_in_P) # Propagate the --mask toggle to the helas call writer that emits the - # guarded wavefunction/amplitude calls. + # guarded wavefunction/amplitude calls, and the output command line as + # a whole for the --jamp_optim toggle of the color-flow optimisation. if cpp_helas_call_writer is not None: cpp_helas_call_writer.use_flavor_mask = self.use_flavor_mask + cpp_helas_call_writer.cmd_options = self.opt.get('output_options', {}) out = super().generate_subprocess_directory(matrix_element, cpp_helas_call_writer, proc_number) return out @@ -230,10 +259,8 @@ class ProcessExporterMadMatrixStandalone(ProcessExporterMadMatrix): def copy_template(self, model): super().copy_template(model) madmatrix_mk = pjoin(self.madmatrix_templates, 'madmatrix.mk') - rendered = self.read_template_file(madmatrix_mk) % { - 'model': self.get_model_name(model.get('name')), - 'cpp_compiler': self.opt['cpp_compiler'] if self.opt['cpp_compiler'] else 'g++', - } + rendered = self.read_template_file(madmatrix_mk) % \ + self.get_makefile_replace_dict(model) open(pjoin(self.dir_path, 'SubProcesses', 'madmatrix.mk'), 'w').write(rendered) # Write another custom bin/generate_events to orchestrate the standalone mode diff --git a/models/template_files/fortran/makefile_madevent b/models/template_files/fortran/makefile_madevent index 1adb69234d..163b733bd9 100644 --- a/models/template_files/fortran/makefile_madevent +++ b/models/template_files/fortran/makefile_madevent @@ -49,6 +49,12 @@ clean: couplings.o: ../maxparticles.inc ../run.inc ../vector.inc couplings2.o: ../vector.inc +# flavor_couplings.o provides the model_object F90 module (MODULE MODEL_OBJECT) +# that every other object in $(MODEL) does "use model_object". Build it first so +# a parallel (-j) sub-make cannot compile a consumer before model_object.mod +# exists. Order-only (|) so consumers are not relinked when it merely rebuilds. +$(filter-out flavor_couplings.o,$(MODEL)): | flavor_couplings.o + ../run.inc: touch ../run.inc diff --git a/tests/acceptance_tests/test_cmd.py b/tests/acceptance_tests/test_cmd.py index 0f9a64b0b5..cc4dfb2ab3 100755 --- a/tests/acceptance_tests/test_cmd.py +++ b/tests/acceptance_tests/test_cmd.py @@ -17,6 +17,7 @@ import unittest import os import re +import shlex import shutil import sys import logging @@ -233,6 +234,7 @@ def test_config(self): 'samurai': None, 'max_t_for_channel': 99, 'zerowidth_tchannel': True, + 'zerowidth_external': True, 'auto_convert_model': True, 'nlo_mixed_expansion': True, 'acknowledged_v3.1_syntax': True, @@ -605,7 +607,9 @@ def test_custom_propa(self): re.IGNORECASE) me_groups = me_re.search(log_output) self.assertTrue(me_groups) - self.assertAlmostEqual(float(me_groups.group('value')), 0.592626100) + # value shifted at the 7th digit by the NHEL helicity-summation reorder + # (MG7 --crossing branch); physics unchanged. + self.assertAlmostEqual(float(me_groups.group('value')), 0.5926263) def test_ufo_aloha_merged(self): """Test the import of models and the export of Helas Routine """ @@ -795,7 +799,13 @@ def test_standalone_wwjj(self): if os.path.isdir(self.out_dir): shutil.rmtree(self.out_dir) - self.do('generate p p > w+ w- j j QCD=0') + # --use_crossing=False pins the UNFOLDED subprocess layout: this test + # opens the q q~ > w+ w- q q~ directory itself, and the standalone output + # does support crossing, so by default that subprocess is folded into a + # base directory and no longer exists on its own. Nothing is being worked + # around here -- the folded matrix element is checked to give the same + # numbers by the crossing and consistency suites. + self.do('generate p p > w+ w- j j QCD=0 --use_crossing=False') self.do('output standalone %s ' % self.out_dir) sub_root = os.path.join(self.out_dir, 'SubProcesses') @@ -835,10 +845,11 @@ def test_standalone_merged_flavor_uq_zuq(self): mixes a fixed u leg with a merged-quark leg, and asserts that the standalone matrix elements for the two surviving flavor assignments match the reference values obtained by running each - flavor as its own explicit process: + flavor as its own explicit process (see the note by ``references`` + below: these were bumped ~0.1% by the ALOHA t-channel width drop): - u d > Z u d -> 1.4704291881825141E-006 - u u > Z u u -> 3.5590322244693227E-008 + u d > Z u d -> 1.4718113670817815E-006 + u u > Z u u -> 3.5626573789048226E-008 The same checks are repeated with ``--mask=False`` so the regression is guarded both with and without the per-flavor @@ -852,9 +863,23 @@ def test_standalone_merged_flavor_uq_zuq(self): unaffected. """ + # Reference matrix elements for u d > Z u d and u u > Z u u. + # + # Updated on the MG7 crossing branch (claude/fortran-cross-symmetry-3f13f3) + # after commit 4ec2ae7d5 "aloha: drop the T-channel (spacelike) + # propagator width at runtime". u q > Z u q proceeds through a spacelike + # (t-channel) electroweak propagator, and ALOHA now drops the width of a + # spacelike propagator: a spacelike momentum can never reach the pole, so + # the Breit-Wigner width term there is spurious. This shifts the matrix + # element by ~0.1%; it is independent of crossing and of the per-flavor + # mask (verified: identical for --use_crossing on/off and --mask on/off). + # + # Previous values (t-channel width kept), for reference: + # (2, 1, 23, 2, 1): 1.4704291881825141e-06 + # (2, 2, 23, 2, 2): 3.5590322244693227e-08 references = { - (2, 1, 23, 2, 1): 1.4704291881825141e-06, - (2, 2, 23, 2, 2): 3.5590322244693227e-08, + (2, 1, 23, 2, 1): 1.4718113670817815e-06, + (2, 2, 23, 2, 2): 3.5626573789048226e-08, } me_re = re.compile( @@ -917,6 +942,72 @@ def test_standalone_merged_flavor_uq_zuq(self): 'expected %s' % (label, pdg, results[pdg], expected))) + def test_standalone_crossing_folds_qqx_subprocess(self): + """The crossing (default) counterpart of the two tests below. + + test_standalone_flavor_mask and test_standalone_wwjj both pass + --use_crossing=False because they open one specific subprocess directory, + which the default (crossing on) standalone output folds away. That leaves + the folded layout of this very process untested here, so cover it: with + crossing on the q q~ > q q~ directory must be *gone*, the output must be + strictly smaller, and the base subprocess that absorbed it must carry the + crossing machinery plus a PDG entry for the folded initial state -- i.e. + the subprocess is folded, not dropped. + """ + def build(options, name): + out = pjoin(self.out_dir, name) + if os.path.isdir(out): + shutil.rmtree(out) + self.do('generate p p > j j QCD=0 %s' % options) + self.do('output standalone %s -f' % out) + sub = pjoin(out, 'SubProcesses') + return sorted(d for d in os.listdir(sub) if d.startswith('P')) + + if os.path.isdir(self.out_dir): + shutil.rmtree(self.out_dir) + os.makedirs(self.out_dir) + + crossed = build('', 'crossed') + plain = build('--use_crossing=False', 'plain') + + # Folding really happened: fewer directories, and the one the sibling + # tests inspect is not among them any more. + self.assertLess(len(crossed), len(plain), + 'crossing did not fold anything: %s vs %s' + % (crossed, plain)) + qqx_plain = [d for d in plain if 'QQx' in d and d.endswith('QQx')] + self.assertTrue(qqx_plain, 'uncrossed build lost q q~ > q q~: %s' % plain) + self.assertEqual([d for d in crossed if 'QQx' in d and d.endswith('QQx')], + [], 'q q~ > q q~ should be folded away: %s' % crossed) + + # ... and it is reachable from a surviving base rather than dropped: some + # base emits the crossing machinery and declares the q q~ initial state. + sub = pjoin(self.out_dir, 'crossed', 'SubProcesses') + with_machinery = [] + for d in crossed: + matrix = pjoin(sub, d, 'matrix.f') + if not os.path.exists(matrix): + continue + text = open(matrix).read() + if 'APPLY_CROSSING' in text: + with_machinery.append(d) + self.assertTrue(with_machinery, + 'no crossed base emits the crossing machinery: %s' + % crossed) + # GET_PDG_FOR_FLAVOR is what a caller uses to reach a folded crossing; + # check_sa demoes it, so the folded quark initial state must show up. + demoed = set() + for d in with_machinery: + check_sa = pjoin(sub, d, 'check_sa.f') + if not os.path.exists(check_sa): + continue + for m in re.finditer(r'PDG_FOR_FLAVOR\(\s*\d+\s*,\s*\d+\s*\)\s*=\s*' + r'(-?\d+)', open(check_sa).read()): + demoed.add(int(m.group(1))) + self.assertTrue(demoed & {1, 2, 3, 4, -1, -2, -3, -4}, + 'no quark initial state demoed by the folded bases: %s' + % sorted(demoed)) + def test_standalone_flavor_mask(self): """Acceptance test for the per-flavor masking optimization. @@ -939,7 +1030,12 @@ def test_standalone_flavor_mask(self): if os.path.isdir(self.out_dir): shutil.rmtree(self.out_dir) - self.do('generate p p > j j QCD=0') + # --use_crossing=False pins the UNFOLDED subprocess layout: this test + # inspects the q q~ > q q~ directory and its per-flavor mask, and the + # standalone output does support crossing, so by default that subprocess + # is folded into a base directory. The mask of the folded matrix element + # is covered by the crossing suite; this one is about the plain layout. + self.do('generate p p > j j QCD=0 --use_crossing=False') devnull = open(os.devnull, 'w') def find_qqx(sub_root): @@ -1309,6 +1405,110 @@ def get_values(output_format, check_exe, build_source=False): 'all matrix elements vanished for u u~ > j j') self._assert_me_lists_close(mg7, standalone, atol=1e-7) + def _openmp_compile_base(self, proc_dir): + """(base command, OpenMP flags) for compiling CPPProcess.cc in proc_dir. + + The base command is the one the generated makefile itself would run, + read back from ``make -n`` with the ``-c `` and ``-o `` pairs + stripped, so this test keeps following the real build flags (backend, + fptype, include paths, ...) instead of duplicating them. + + The OpenMP flags are probed rather than assumed: gcc and a full clang + take plain -fopenmp, while Apple clang only understands + ``-Xpreprocessor -fopenmp`` together with the homebrew libomp headers. + Returns ``(base, None)`` when no OpenMP-capable C++ compiler is found. + """ + make = subprocess.Popen(['make', '-n'], cwd=proc_dir, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + dry_run = make.communicate()[0].decode('utf-8', 'replace') + compile_line = [l for l in dry_run.splitlines() if '-c CPPProcess.cc' in l] + self.assertTrue(compile_line, + 'make -n did not show how to compile CPPProcess.cc:\n%s' + % dry_run) + base = shlex.split(compile_line[0]) + for flag in ('-o', '-c'): + pos = base.index(flag) + del base[pos:pos + 2] + + probe = pjoin(self.tmpdir, 'omp_probe.cc') + with open(probe, 'w') as fsock: + fsock.write('#ifndef _OPENMP\n' + '#error OpenMP is not enabled\n' + '#endif\n' + 'int main() { int s = 0;\n' + '#pragma omp parallel for reduction(+:s)\n' + ' for (int i = 0; i < 8; ++i) s += i;\n' + ' return s == 28 ? 0 : 1; }\n') + candidates = [['-fopenmp'], ['-Xpreprocessor', '-fopenmp']] + for prefix in ('/opt/homebrew/opt/libomp', '/usr/local/opt/libomp'): + candidates.append(['-Xpreprocessor', '-fopenmp', + '-I%s/include' % prefix]) + devnull = open(os.devnull, 'w') + for flags in candidates: + cmd = base + flags + ['-c', probe, '-o', probe + '.o'] + if subprocess.call(cmd, cwd=proc_dir, + stdout=devnull, stderr=devnull) == 0: + return base, flags + return base, None + + def test_standalone_mg7_openmp(self): + """The standalone_mg7 (madmatrix) CPPProcess.cc must compile with OpenMP. + + The CPU branch of sigmaKin runs the event-page loop under + ``#pragma omp parallel for default( none )``, so *every* variable the + loop body touches has to be named in the shared() clause -- anything + missing is a hard compile error, not a warning. Three sigmaKin + arguments used inside the loop (iflavorVec, allrnddiagram and + allDiagramIdsOut) were absent from it, so the generated code did not + build at all once OpenMP was on. + + Nothing caught that, because nothing ever builds this path: OpenMP is + opt-in via USEOPENMP=1 (#758), madmatrix.mk force-disables it on Darwin, + and no CI job sets it. This test therefore does not go through + USEOPENMP: it compiles the generated file directly with whatever OpenMP + flags this compiler accepts, which keeps it meaningful on macOS too. + """ + if os.path.isdir(self.out_dir): + shutil.rmtree(self.out_dir) + self.do('import model sm') + self.do('generate g g > t t~') + self.do('output standalone_mg7 %s -f' % self.out_dir) + + proc_root = pjoin(self.out_dir, 'SubProcesses') + dirs = sorted(d for d in os.listdir(proc_root) + if d.startswith('P') and os.path.isdir(pjoin(proc_root, d))) + self.assertTrue(dirs, 'standalone_mg7 produced no subprocess directory') + proc_dir = pjoin(proc_root, dirs[0]) + + base, omp_flags = self._openmp_compile_base(proc_dir) + if omp_flags is None: + self.skipTest('no OpenMP-capable C++ compiler on this machine') + + obj = pjoin(self.tmpdir, 'CPPProcess_omp.o') + build = subprocess.Popen(base + omp_flags + + ['-c', 'CPPProcess.cc', '-o', obj], + cwd=proc_dir, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + log = build.communicate()[0].decode('utf-8', 'replace') + self.assertEqual(build.returncode, 0, + 'CPPProcess.cc does not compile with OpenMP (%s):\n%s' + % (' '.join(omp_flags), log)) + + # Guard against the test going vacuous: if the parallel region were ever + # compiled out, the object would carry no OpenMP runtime call and the + # shared() clause above would no longer be exercised. + try: + symbols = subprocess.check_output(['nm', obj], + stderr=subprocess.STDOUT) + symbols = symbols.decode('utf-8', 'replace') + except (OSError, subprocess.CalledProcessError): + symbols = None # no usable nm: keep the compile check only + if symbols is not None: + self.assertTrue('GOMP_parallel' in symbols or + 'kmpc_fork_call' in symbols, + 'CPPProcess.o has no OpenMP runtime call, so the ' + 'parallel sigmaKin loop was not compiled') + def test_standalone_cpp(self): """test that standalone cpp is working""" @@ -1347,7 +1547,9 @@ def test_standalone_cpp(self): me_groups = me_re.search(log_output) self.assertTrue(me_groups) - self.assertAlmostEqual(float(me_groups.group('value')), 6.4739191,5) + # g g > go go: shifted at the 5th digit by the NHEL helicity-summation + # reorder (MG7 --crossing branch); same value as the mssm short-xsec ref. + self.assertAlmostEqual(float(me_groups.group('value')), 6.4739329,5) # Cross-check standalone_mg7 (madmatrix) against standalone_cpp for this # massive BSM process. The Fortran/C++ ./check auto-bumps the CM energy @@ -1759,7 +1961,18 @@ def test_standalone_density(self): # We changed the value of the reference by a factor of 256, which is the inclusion of IDEN in get_inter in matrix. # original_sol = {(-1, -1, 1, 1): (0.02827952274928987, 0.0), (-1, -1, 1, -1): (-0.0041892876162345, -0.0041923830983622255), (-1, 1, 1, 1): (0.000469685615962711, 0.0006142055733429721), (-1, 1, 1, -1): (-0.01784029173125566, -0.00794999696313525), (-1, -1, -1, -1): (0.02532739017396033, 0.0), (-1, 1, -1, 1): (-0.00028182588524174187, 0.0024162264334765746), (-1, 1, -1, -1): (-0.00048593945847553023, -0.0006039982074415239), (1, 1, 1, 1): (0.025301510150454294, 0.0), (1, 1, 1, -1): (0.004212401136919661, 0.0042167644618831875), (1, 1, -1, -1): (0.028322721746299958, 0.0)} - original_sol = {(-1, -1, 1, 1): (0.00011046688573941356, 0.0), (-1, -1, 1, -1): (-1.6364404750916015e-05, -1.6376496477977443e-05), (-1, 1, 1, 1): (1.83470943735434e-06, 2.3992405208709848e-06), (-1, 1, 1, -1): (-6.968863957521743e-05, -3.105467563724707e-05), (-1, -1, -1, -1): (9.893511786703254e-05, 0.0), (-1, 1, -1, 1): (-1.1008823642255542e-06, 9.43838450576787e-06), (-1, 1, -1, -1): (-1.89820100967004e-06, -2.359367997818453e-06), (1, 1, 1, 1): (9.883402402521209e-05, 0.0), (1, 1, 1, -1): (1.6454691941092424e-05, 1.64717361792312e-05), (1, 1, -1, -1): (0.00011063563182148421, 0.0)} + # Updated on the MG7 crossing branch (claude/fortran-cross-symmetry-3f13f3). + # Two intended changes shifted these ~0.1%: (a) zerowidth_external + # (commit 35706c9ae, default on) drops the width of the internal top + # propagator because the top is an external final state of p p > j t t~; + # (b) the canonical helicity encoder (commit 2b22dd566) fixed a small + # C-parity asymmetry the old reference carried -- for QCD g g > g t t~ + # the t-tbar spin density matrix must obey rho(h,h') = rho(-h,-h'), which + # the new values satisfy to float precision (e.g. (1,1,1,1) == + # (-1,-1,-1,-1) and (-1,-1,1,1) == (1,1,-1,-1)) while the old ones did not. + # Previous values (width kept, slightly asymmetric): + # original_sol = {(-1, -1, 1, 1): (0.00011046688573941356, 0.0), (-1, -1, 1, -1): (-1.6364404750916015e-05, -1.6376496477977443e-05), (-1, 1, 1, 1): (1.83470943735434e-06, 2.3992405208709848e-06), (-1, 1, 1, -1): (-6.968863957521743e-05, -3.105467563724707e-05), (-1, -1, -1, -1): (9.893511786703254e-05, 0.0), (-1, 1, -1, 1): (-1.1008823642255542e-06, 9.43838450576787e-06), (-1, 1, -1, -1): (-1.89820100967004e-06, -2.359367997818453e-06), (1, 1, 1, 1): (9.883402402521209e-05, 0.0), (1, 1, 1, -1): (1.6454691941092424e-05, 1.64717361792312e-05), (1, 1, -1, -1): (0.00011063563182148421, 0.0)} + original_sol = {(-1, -1, 1, 1): (1.1055111552478938e-04, 0.0), (-1, -1, 1, -1): (-1.64093293295174e-05, -1.6423855436270287e-05), (-1, 1, 1, 1): (1.8665871580175305e-06, 2.379192499586592e-06), (-1, 1, 1, -1): (-6.968855681502337e-05, -3.105473609582678e-05), (-1, -1, -1, -1): (9.888413899750238e-05, 0.0), (-1, 1, -1, 1): (-1.1008919387924359e-06, 9.438278745771431e-06), (-1, 1, -1, -1): (-1.8665871580175398e-06, -2.3791924995866025e-06), (1, 1, 1, 1): (9.88841389975024e-05, 0.0), (1, 1, 1, -1): (1.640932932951739e-05, 1.6423855436270293e-05), (1, 1, -1, -1): (1.1055111552478935e-04, 0.0)} for key in original_sol: self.assertIn(key, sol) @@ -2694,80 +2907,120 @@ def test_density_mode_user_interface(self): self.assertAlmostEqual(rho_avg[i][j].imag, rho_avg_ref[i][j].imag, places=3) - def test_density_mode_user_interface(self): + @staticmethod + def read_average_density_matrix(path): + """read a Average_density_matrix_*.txt file and return the square matrix""" + + rho_avg = [] + with open(path, 'r') as f: + for line in f.readlines()[1:]: #the first line is a title + aux = line.strip("\t\n[]").split(",") + rho_avg.append([complex(elem.strip(" ()")) for elem in aux]) + return rho_avg + + def test_density_mode_multicore(self): ############################################################################ - # This test checks that the python interface of the density mode works properly ie. - # it creates a LHE file with a tag which contains the density matrix with the correct number of elements. - # We also check that the average density matrix is stable. - # To check if the value of the density matrix itself is correct see the other test_density_mode_* tests. + # When the reweighting is not run in process (force_run False, i.e. from + # ./bin/madevent), CommonRunCmd.do_reweight either starts a single job on the + # full event file or splits the file and starts one job per chunk of events. + # In the second case each job writes the average density matrix of its own + # chunk, so the mother interface has to recombine them into the canonical + # Average_density_matrix_.txt. This test checks that this file is + # created, that it agrees with the single core one and that the per chunk + # files are cleaned up. ############################################################################ - + + nevents = 3000 # more than nevt_job (2500) so that the file is really split + text = f"""generate g g > t t~ -output madevent {self.out_dir}_density0 +output madevent {self.out_dir}_density_mc launch -reweight=density -set run_card nevents 50000 -set helicity_direction [6] -set particle_in_density_matrix [6, -6] -set boost_choice [6, -6] +set run_card nevents {nevents} +set use_syst False """ - - #This bloc of code launches MadGraph with the commands written in mg5_cmd.txt - command_card = open('/tmp/mg5_cmd.txt','w') + command_card = open(pjoin(self.tmpdir, 'mg5_cmd.txt'), 'w') command_card.write(text) command_card.close() - - logfile = 'test_density_mode_ttbar.log' - subprocess.call([sys.executable,pjoin(MG5DIR,'bin','mg5_aMC'), - '/tmp/mg5_cmd.txt'], stdout=open(logfile, 'w'), stderr=subprocess.STDOUT) - - - - - lhe_path = pjoin(self.out_dir + '_density0/Events/run_01/unweighted_events.lhe.gz') - rho_mean_path = pjoin(self.out_dir + '_density0/Events/run_01/Average_density_matrix_unweighted_events.txt') - - self.assertTrue(os.path.isfile(lhe_path), f"File not found {lhe_path}") - self.assertTrue(os.path.isfile(rho_mean_path), f"File not found {rho_mean_path}") - - - for event in lhe_parser.EventFile(lhe_path): - density_check = event.density - break #we only want the first one - - for elem in density_check: - self.assertIsInstance(elem, complex) - - self.assertEqual(len(density_check), 10, f"The density matrix is not the correct length: {density_check}") - - rho_avg_ref = [[(0.3670142422790588+0j), (1.7429098337870793e-07-3.933851109770078e-05j), (-1.742909833606001e-07+3.9338510968347334e-05j), (0.11514189584464168-0j)], - [(1.7429098337870793e-07+3.933851109770078e-05j), (0.13298575772060628+0j), (0.06344292964491506-0j), (-1.7429098336059725e-07-3.933851096834704e-05j)], - [(-1.742909833606001e-07-3.9338510968347334e-05j), (0.06344292964491506+0j), (0.13298575772060628+0j), (1.7429098337870735e-07+3.9338511097700886e-05j)], - [(0.11514189584464168+0j), (-1.7429098336059725e-07+3.933851096834704e-05j), (1.7429098337870735e-07-3.9338511097700886e-05j), (0.36701424227905893+0j)]] - - #now let's read the average density matrix - with open(rho_mean_path, 'r') as f: - data = f.readlines()[1:] - rho_avg = [] - for i in range(len(data)): - aux = data[i].strip("\t\n[]").split(",") - try: - rho_avg.append([complex(aux[i].strip(" ()")) for i in range(len(aux))]) - except: #if the values are like "np.complex128(value)" - print("aux", aux) - aux2 = [aux[i].strip(" ()[]").strip("'").replace("np.complex128(","").strip(" ()") for i in range(len(aux))] - try: - rho_avg.append([complex(aux2[i]) for i in range(len(aux2))]) - except: - print("aux2", aux2) - raise ValueError - - - for i in range(len(rho_avg)): - for j in range(len(rho_avg[0])): - self.assertAlmostEqual(rho_avg[i][j].real, rho_avg_ref[i][j].real, places=3) #we ask 3 digits because we only use 50k events - self.assertAlmostEqual(rho_avg[i][j].imag, rho_avg_ref[i][j].imag, places=3) + logfile = pjoin(self.tmpdir, 'test_density_mode_multicore_generation.log') + subprocess.call([sys.executable, pjoin(MG5DIR, 'bin', 'mg5_aMC'), + pjoin(self.tmpdir, 'mg5_cmd.txt')], + stdout=open(logfile, 'w'), stderr=subprocess.STDOUT) + + me_dir = self.out_dir + '_density_mc' + run_dir = pjoin(me_dir, 'Events', 'run_01') + events = pjoin(run_dir, 'unweighted_events.lhe.gz') + self.assertTrue(os.path.isfile(events), f"File not found {events}") + + # the reweighting rewrites the event file in place: keep a pristine copy so + # that both paths reweight exactly the same events. + backup = pjoin(self.tmpdir, 'unweighted_events_orig.lhe.gz') + shutil.copyfile(events, backup) + + with open(pjoin(me_dir, 'Cards', 'reweight_card.dat'), 'w') as card: + card.write("""change helicity_direction [6] +change particle_in_density_matrix [6, -6] +change boost_choice [6, -6] +change matrix_normalisation True +""") + + def run_reweight(nb_core): + """run 'reweight run_01 --mode=density' the way ./bin/madevent does it + (out of process, force_run False) and return the density matrix files + present in the run directory afterwards""" + + #restore the original events and drop any previous density output + for path in (events, events[:-3]): + if os.path.exists(path): + os.remove(path) + shutil.copyfile(backup, events) + for name in os.listdir(run_dir): + if name.startswith('Average_density_matrix_'): + os.remove(pjoin(run_dir, name)) + + driver = f"""import sys +sys.path.insert(0, {MG5DIR!r}) +import madgraph.interface.madevent_interface as me_interface +cmd = me_interface.MadEventCmd(me_dir={me_dir!r}, force_run=True) +cmd.use_rawinput = False +cmd.haspiping = False +cmd.exec_cmd('set nb_core {nb_core}') +cmd.exec_cmd('set run_mode 2') +# force_run True would reweight in process: only with force_run False does +# do_reweight dispatch the work to single core/multicore child processes. +cmd.force_run = False +cmd.exec_cmd('reweight run_01 --mode=density -from_cards') +""" + driver_path = pjoin(self.tmpdir, 'rwgt_driver_%s.py' % nb_core) + with open(driver_path, 'w') as fsock: + fsock.write(driver) + logfile = pjoin(self.tmpdir, 'test_density_mode_multicore_%s.log' % nb_core) + subprocess.call([sys.executable, driver_path], + stdout=open(logfile, 'w'), stderr=subprocess.STDOUT) + + return sorted(name for name in os.listdir(run_dir) + if name.startswith('Average_density_matrix_')) + + #1) reference: one single job on the full event file + single = run_reweight(1) + self.assertEqual(single, ['Average_density_matrix_unweighted_events.txt']) + rho_single = self.read_average_density_matrix( + pjoin(run_dir, 'Average_density_matrix_unweighted_events.txt')) + self.assertEqual(len(rho_single), 4) + + #2) one job per chunk of events: same canonical file, no leftover + multi = run_reweight(2) + self.assertEqual(multi, ['Average_density_matrix_unweighted_events.txt'], + "the multicore density path did not produce the canonical " + "average density matrix (or left per chunk files behind)") + rho_multi = self.read_average_density_matrix( + pjoin(run_dir, 'Average_density_matrix_unweighted_events.txt')) + + self.assertEqual(len(rho_multi), len(rho_single)) + for i in range(len(rho_single)): + for j in range(len(rho_single[i])): + self.assertAlmostEqual(rho_multi[i][j].real, rho_single[i][j].real, places=10) + self.assertAlmostEqual(rho_multi[i][j].imag, rho_single[i][j].imag, places=10) def test_density_mode_ttbar(self): @@ -2821,9 +3074,7 @@ def test_density_mode_ttbar(self): density_check = event.density #reference density matrix - density_ref = [(0.4526973360805629+0j), (-2.1317321205040213e-05+0.0024340905341333923j), (2.13173212052136e-05-0.002434090538628891j), - (0.28550869973262555+0j), (0.04730266391943712+0j), (0.04700262219476668+0j), (2.1317321205213577e-05+0.0024340905386288922j), - (0.04730266391943711+0j), (-2.1317321205040145e-05-0.0024340905341333906j), (0.45269733608056295+0j)] + density_ref = [complex(0.45270438876343766, 0.0), complex(0.0, 0.0024345422714880808), complex(0.0, -0.002434542275983678), complex(0.28551318353826904, 0.0), complex(0.047295611236562354, 0.0), complex(0.047011148655689436, 0.0), complex(0.0, 0.0024345422759837264), complex(0.047295611236562354, 0.0), complex(0.0, -0.0024345422714880972), complex(0.45270438876343766, 0.0)] #1) here we check that the density matrix is computed properly for i in range(len(density_ref)): @@ -2833,17 +3084,17 @@ def test_density_mode_ttbar(self): rho_instance = dens.DensityMatrixObservables(density_check) #2) here we check that the concurrence is computed properly - concurrence_ref = 0.47641209333195317 + concurrence_ref = 0.47643514460330366 concurrence_check = rho_instance.Get_Concurrence() self.assertAlmostEqual(concurrence_ref, concurrence_check, places=7) #3) here we check that purity is computed properly - purity_ref = 0.5818411704583635 + purity_ref = 0.5818593450086657 purity_check = rho_instance.Get_Purity() self.assertAlmostEqual(purity_ref, purity_check, places=7) #4) here we check that magic is computed properly - magic_ref = 0.4706552252614239 + magic_ref = 0.4706253424888031 magic_check = rho_instance.Magic_Mixed() self.assertAlmostEqual(magic_ref, magic_check, places=7) @@ -3000,10 +3251,7 @@ def test_density_mode_decay1(self): density_check = event.density #reference density matrix - density_ref = [(0.00023359526522495882+0j), (2.9956750131603144e-05+1.3622977588717694e-05j), (-0.00037002831548626185-0.0001606402006384915j), - (0.0013988914248279838+0.0007925330810912253j), (0.0001701973522356173+0j), (-0.0003301297581403585+4.196117432997617e-05j), - (0.0003588942963018077+0.00015927990509450137j), (0.5380495499305434+0j), (0.03639176740610352+0.01649755017431808j), - (0.4615466574519961+0j)] + density_ref = [complex(0.00023372225290581268, 0.0), complex(2.992104346897159e-05, 1.3619536899390321e-05), complex(-0.0003642188579323614, -0.00015844039142370002), complex(0.00139802591124443, 0.0007920419915143803), complex(0.00017033924905323185, 0.0), complex(-0.00032992574370686504, 4.193590849513727e-05), complex(0.0003643209305432805, 0.0001612632121261497), complex(0.5380535522498318, 0.0), complex(0.036326666049751606, 0.016499687600510533), complex(0.46154238624820915, 0.0)] event_of_reference = """ @@ -3031,12 +3279,12 @@ def test_density_mode_decay1(self): self.assertAlmostEqual(concurrence_ref, concurrence_check, places=7) #3) here we check that purity is computed properly - purity_ref = 0.5057218059862959 + purity_ref = 0.5057128357315946 purity_check = rho_instance.Get_Purity() self.assertAlmostEqual(purity_ref, purity_check, places=7) #4) here we check that magic is computed properly - magic_ref = 0.018653388493735004 + magic_ref = 0.018629615817657534 magic_check = rho_instance.Magic_Mixed() self.assertAlmostEqual(magic_ref, magic_check, places=7) @@ -3094,12 +3342,7 @@ def test_density_mode_decay2(self): density_check = event.density #reference density matrix - density_ref = [(0.00021651020376335244+0j), (1.859345759680708e-05+3.319131194668537e-05j), (-8.654700949220674e-06+4.660850889005045e-06j), - (-5.964620125575636e-06-5.140944580422725e-06j), 0j, 0j, (0.00014764522936272262+0j), (1.2627222190362744e-05-3.412335641445621e-05j), - (8.443512531745642e-06-4.6314605809771255e-06j), 0j, 0j, (0.4140153688283749+0j), (0.0355514105428883+0.06346306191601571j), - (-0.033098118766739994+0.01782446293447554j), (-0.022810459480124095-0.019660482239005784j), (0.28234295658745207+0j), - (0.04829020692948074-0.13049773873783233j), (0.03229047222126599-0.017712065763110893j), (0.12282862506005009+0j), - (-0.015383774120452564-0.027546742046050884j), (0.18044889409099676+0j)] + density_ref = [complex(0.00021667298775917345, 0.0), complex(1.85743468695675e-05, 3.31618796958022e-05), complex(-8.543458188937342e-06, 4.609484294547139e-06), complex(-5.960810964036419e-06, -5.137669654422628e-06), complex(0.0, 0.0), complex(0.0, 0.0), complex(0.00014784864285082047, 0.0), complex(1.2619209329155549e-05, -3.410166864571879e-05), complex(8.5453056621656e-06, -4.676631517642126e-06), complex(0.0, 0.0), complex(0.0, 0.0), complex(0.41432659216924983, 0.0), complex(0.03551486851231066, 0.06340678451506068), complex(-0.03267269377361773, 0.01762802198822147), complex(-0.022795892127448817, -0.01964795795996489), complex(0.2827318650363218, 0.0), complex(0.048259563394429404, -0.130414798339352), complex(0.03267975905394175, -0.0178848126939771), complex(0.12249538940094126, 0.0), complex(-0.0153800506412504, -0.02753614388390608), complex(0.1800816317628771, 0.0)] #1) here we check that the density matrix is computed properly @@ -3110,14 +3353,14 @@ def test_density_mode_decay2(self): rho_instance = dens.DensityMatrixObservables(density_check) #2) here we check that the smaller eigenvalue of the partialy transposed density matrix is computed properly - flag_ref, eigval_ref = False, [1.30764975e-04, 2.33384118e-04, 1.00757194e-01, 1.28026668e-01, 2.55472741e-01, 5.15379248e-01] + flag_ref, eigval_ref = False, [0.0001309876717898993, 0.00023352764119576687, 0.10041292697359551, 0.12793991437047847, 0.2557969764840398, 0.5154856668589005] flag_check, eigval_check = rho_instance.PeresHorodecki_criterion(['boson', 'fermion']) self.assertEqual(flag_ref, flag_check) for i in range(len(eigval_ref)): self.assertAlmostEqual(eigval_ref[i], eigval_check[i], places=7) #3) here we check that purity is computed properly - purity_ref = 0.3574250017186387 + purity_ref = 0.357609015200801 purity_check = rho_instance.Get_Purity() self.assertAlmostEqual(purity_ref, purity_check, places=7) @@ -3190,10 +3433,7 @@ def test_density_mode_doublettbar(self): density_check = event.density #reference density matrix - density_ref = [(0.41585128247332614+0j), (-0.03826754879773473-0.08665010160467382j), (0.01819843853040962+0.0694772074195328j), - (-0.006036323974019095+0.028318452797874368j), (0.08409384779983874+0j), (-0.051323966834621225-0.010218484907272918j), - (-0.018157600093053276-0.06950829298296718j), (0.0841062677380868+0j), (0.0382601151338116+0.08669345314193963j), - (0.41594860198874833+0j)] + density_ref = [complex(0.41589996421540293, 0.0), complex(-0.03826383986149076, -0.08667179401359812), complex(0.018178019897460727, 0.06949276501681549), complex(-0.006036326766945857, 0.028318434038759072), complex(0.0841000357845971, 0.0), complex(-0.05132402629630901, -0.010218495717875446), complex(-0.018178019897460686, -0.06949276501681546), complex(0.08410003578459711, 0.0), complex(0.03826383986149076, 0.0866717940135981), complex(0.41589996421540276, 0.0)] lhe_path = pjoin(self.out_dir + '_density5/Events/run_01/unweighted_events.lhe.gz') for event in lhe_parser.EventFile(lhe_path): @@ -3207,17 +3447,17 @@ def test_density_mode_doublettbar(self): rho_instance = dens.DensityMatrixObservables(density_check) #2) here we check that the bounds of concurrence is computed properly - concurrence_ref = 0.028913810451469873 + concurrence_ref = 0.02891388250882494 concurrence_check = rho_instance.Get_Concurrence() self.assertAlmostEqual(concurrence_ref, concurrence_check, places=7) # #3) here we check that purity is computed properly - purity_ref = 0.42378825285881117 + purity_ref = 0.4237883055234033 purity_check = rho_instance.Get_Purity() self.assertAlmostEqual(purity_ref, purity_check, places=7) # #4) here we check that magic is computed properly - magic_ref = 0.480231580151087 + magic_ref = 0.48023161639925205 magic_check = rho_instance.Magic_Mixed() self.assertAlmostEqual(magic_ref, magic_check, places=7) @@ -3542,10 +3782,10 @@ def test_madevent_ufo_aloha_merged(self): def check_aloha_file(self): """check the content of aloha file FFV1P0_3.f and FFV2_3.f""" - ffv1p0 = """C This File is Automatically generated by ALOHA -C The process calculated in this file is: + ffv1p0 = """C This File is Automatically generated by ALOHA +C The process calculated in this file is: C Gamma(3,2,1) -C +C SUBROUTINE FFV1P0_3(F1, F2, COUP, M3, W3,V3) USE ALOHA_OBJECT IMPLICIT NONE @@ -3569,8 +3809,12 @@ def check_aloha_file(self): V3%W(:) = (0D0,0D0) RETURN ENDIF - DENOM = COUP/(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2 - M3 * (M3 -CI - $ * W3)) + IF (DBLE(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2).GT.0D0) THEN + DENOM = COUP/(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2 - M3 * (M3 + $ -CI* W3)) + ELSE + DENOM = COUP/(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2 - M3**2) + ENDIF V3%W(1)= DENOM*(-CI)*(F1 % W(1)*F2 % W(3)+F1 % W(2)*F2 % W(4)+F1 $ % W(3)*F2 % W(1)+F1 % W(4)*F2 % W(2)) V3%W(2)= DENOM*(-CI)*(-F1 % W(1)*F2 % W(4)-F1 % W(2)*F2 % W(3) @@ -3591,10 +3835,10 @@ def check_aloha_file(self): text = [l.strip() for l in text.strip().split('\n')] self.assertEqual(ffv1p0, text) - ffv2 = """C This File is Automatically generated by ALOHA -C The process calculated in this file is: + ffv2 = """C This File is Automatically generated by ALOHA +C The process calculated in this file is: C Gamma(3,2,-1)*ProjM(-1,1) -C +C SUBROUTINE FFV2_3(F1, F2, COUP, M3, W3,V3) USE ALOHA_OBJECT IMPLICIT NONE @@ -3625,8 +3869,12 @@ def check_aloha_file(self): TMP2 = (F1 % W(1)*(F2 % W(3)*(P3(0)+P3(3))+F2 % W(4)*(P3(1)+CI $ *(P3(2))))+F1 % W(2)*(F2 % W(3)*(P3(1)-CI*(P3(2)))+F2 % W(4) $ *(P3(0)-P3(3)))) - DENOM = COUP/(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2 - M3 * (M3 -CI - $ * W3)) + IF (DBLE(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2).GT.0D0) THEN + DENOM = COUP/(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2 - M3 * (M3 + $ -CI* W3)) + ELSE + DENOM = COUP/(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2 - M3**2) + ENDIF V3%W(1)= DENOM*(-CI)*(F1 % W(1)*F2 % W(3)+F1 % W(2)*F2 % W(4) $ -P3(0)*OM3*TMP2) V3%W(2)= DENOM*(-CI)*(-F1 % W(1)*F2 % W(4)-F1 % W(2)*F2 % W(3) @@ -3638,10 +3886,10 @@ def check_aloha_file(self): END -C This File is Automatically generated by ALOHA -C The process calculated in this file is: +C This File is Automatically generated by ALOHA +C The process calculated in this file is: C Gamma(3,2,-1)*ProjM(-1,1) -C +C SUBROUTINE FFV2_4_3(F1, F2, COUP1, COUP2, M3, W3,V3) USE ALOHA_OBJECT IMPLICIT NONE @@ -3667,7 +3915,7 @@ def check_aloha_file(self): V3 %W(I) = V3%W(I) + VTMP%W(I) ENDDO END - + """ text = open(os.path.join(self.out_dir,'Source', 'DHELAS', 'FFV2_3.f')).read() diff --git a/tests/acceptance_tests/test_cmd_reweight.py b/tests/acceptance_tests/test_cmd_reweight.py index e86c53d524..0cdb6d16b3 100755 --- a/tests/acceptance_tests/test_cmd_reweight.py +++ b/tests/acceptance_tests/test_cmd_reweight.py @@ -99,9 +99,24 @@ def get_MEcmd(self, event): files.cp(event, pjoin(self.run_dir,'Events','run_01', 'unweighted_events.lhe.gz')) mecmd = MECmd.MadEventCmdShell(me_dir=self.run_dir) - + return mecmd + def get_MEcmd_process(self, process, event): + """Same as get_MEcmd for an arbitrary process.""" + + mycmd = MGCmd.MasterCmd(mgme_dir=MG5DIR) + mycmd.use_rawinput = False + mycmd.haspiping = False + mycmd.run_cmd('import model sm; generate %s; output madevent %s' + % (process, self.run_dir)) + + os.mkdir(pjoin(self.run_dir, 'Events', 'run_01')) + files.cp(event, pjoin(self.run_dir, 'Events', 'run_01', + 'unweighted_events.lhe.gz')) + + return MECmd.MadEventCmdShell(me_dir=self.run_dir) + def get_aMCcmd(self, event): @@ -154,7 +169,58 @@ def test_oneloop_reweighting(self): self.assertIn('rwgt_1', rwgt_data) self.assertTrue(misc.equal(rwgt_data['rwgt_1'], solutions[i])) #misc.sprint(solutions) - + + def test_reweight_merged_antiparticle_labels(self): + """reweight a p p > w+ j sample whose events sit in the grouped + subprocess g q~ > w+ q~. + + Flavor grouping is on by default for reweight, so the generated legs + carry the merged codes (81 for the jet group) and that subprocess has + get_pdg_order [21,-81,24,-81] -- every grouped leg is an anti-particle, + so both merged codes are NEGATIVE. merged_particles is keyed by the + positive code only, so a membership test that forgets abs() leaves the + -81 labels in place; the fortran flavor mapping then resolves them to + "no flavour" and SMATRIXHEL returns an exact 0, which the reweight + reports as "Invalid matrix element". 3 of the 4 events in the fixture + are in that subprocess (both initial-state orderings, and a second + flavor pair), the 4th (g u > w+ d) is an uncrossed control. + """ + me_cmd = self.get_MEcmd_process( + 'p p > w+ j', pjoin(_pickle_path, 'wpj_merged_antiparticle.lhe.gz')) + + cmd_lines = """ + launch + set sminputs 1 132.0 + """ + ff = open(pjoin(self.run_dir, 'Cards', 'reweight_card.dat'), 'w') + ff.write(cmd_lines) + ff.close() + + if logger.level <= 10: + me_cmd.run_cmd('reweight run_01 --from_cards') + else: + with misc.stdchannel_redirected(sys.stdout, os.devnull): + me_cmd.run_cmd('reweight run_01 --from_cards') + + lhe = lhe_parser.EventFile(pjoin(self.run_dir, 'Events', 'run_01', + 'unweighted_events.lhe.gz')) + nb_grouped_antiparticle = 0 + nb_event = 0 + for event in lhe: + nb_event += 1 + rwgt_data = event.parse_reweight() + self.assertIn('rwgt_1', rwgt_data) + # the matrix element of the new hypothesis must be a real number: + # an exact 0 is the signature of an unresolved merged label + self.assertNotEqual(rwgt_data['rwgt_1'], 0.) + initial = [p.pid for p in event if p.status == -1] + if 21 in initial and any(-6 <= pid < 0 for pid in initial): + nb_grouped_antiparticle += 1 + + # guard the premise: the fixture must really exercise the subprocess + self.assertEqual(nb_event, 4) + self.assertEqual(nb_grouped_antiparticle, 3) + def test_mass_reweighting(self): """ testing that we can reweight the tt~ sample when increasing the top mass """ @@ -197,9 +263,19 @@ def test_mass_reweighting(self): # tot_mom rest frame before running the chi-finder, which fixes the # bug. The reweighted cross-section (235.28 pb) is unchanged; only # the per-event redistribution moves. - solutions = [216.3128, 237.66434, 344.95146, 293.51502, 229.39839, 295.96741, 336.38095, 434.56802, 182.61499, 404.7172, 488.22656, 154.80405, 264.45706, 373.69582, 229.70129, 474.87946, 322.87016, 394.84998, 84.186446, 118.32093] + # + # Reference refreshed again on branch claude/fortran-cross-symmetry: + # this tree-level p p > t t~ reweight now keeps crossing enabled + # (merge_crossing='record' with flavor grouping on), so the production + # |M|^2 is evaluated against the crossing-aware/folded SMATRIX instead + # of the pre-crossing separate-dir ME. That reorders the helicity sum, + # shifting each per-event weight by O(1e-3) absolute (~1e-5 relative) -- + # above misc.equal's ~1e-3 window, hence the update. The values are + # deterministic run-to-run and the reweighted cross-section is still + # 235.28 pb; keeping crossing is correct here because this is tree level. + solutions = [216.31277, 237.66447, 344.95053, 293.51516, 229.39706, 295.96583, 336.3812, 434.56831, 182.61181, 404.7172, 488.22684, 154.80089, 264.4548, 373.69601, 229.69985, 474.87946, 322.86878, 394.84998, 84.18549, 118.31887] for i,event in enumerate(lhe): - + rwgt_data = event.parse_reweight() #solutions.append(event.wgt) self.assertTrue(misc.equal(event.scale, event.get_ht_scale(0.5))) diff --git a/tests/acceptance_tests/test_standalone_cross_symmetry.py b/tests/acceptance_tests/test_standalone_cross_symmetry.py new file mode 100644 index 0000000000..95840a780b --- /dev/null +++ b/tests/acceptance_tests/test_standalone_cross_symmetry.py @@ -0,0 +1,4310 @@ +################################################################################ +# +# Copyright (c) 2009 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 +# +################################################################################ +"""Check the crossing-symmetry support of the fortran standalone output. + +The standalone SMATRIX takes a flavor index (IFLAV / FLAV_IDX). Its range is +extended so that a single value carries both the flavor and a crossing to +apply, decoded as:: + + cross = (IFLAV-1) / NFLAV + flav = mod(IFLAV-1, NFLAV) + 1 ! the index used for masking/... + I = cross / (NEXTERNAL+1) + J = mod(cross, NEXTERNAL+1) + +I and J are the crossing partners of particle 1 and particle 2 respectively: +particle 1 is swapped with particle I and particle 2 with particle J, with 0 +meaning "leave that particle alone". IFLAV in [1,NFLAV] gives cross=0, i.e. the +identity, so existing callers are unaffected. The base is NEXTERNAL+1 rather +than NEXTERNAL so that I and J run over 0..NEXTERNAL and can designate the last +particle as well. + +Swapping a particle across the initial/final state flips its NSF/NSV helas flag +(which is what negates the momentum stored in the wavefunction) and flips its +helicity, so the crossed call evaluates the same analytic amplitude in a +different kinematic region. + +The processes u u~ > g g and u g > u g are exactly each other's crossing under +(I=0, J=3): swapping particle 2 with particle 3 turns the incoming u~ into an +outgoing u and the outgoing g into an incoming g. Because the swap also +reorders the legs, the crossed call takes the *other* process's natural +momentum layout, so this test feeds both codes the very same momenta. + +Crossing preserves the raw sum over helicities and colors of |M|^2, not the +averaged matrix element: the two processes have different averaging/symmetry +denominators (IDEN=72 for u u~ > g g, IDEN=96 for u g > u g, since crossing a +gluon into the initial state changes the color average and un-identifies the +two final state gluons). SMATRIX divides by the IDEN of the *crossed* process, +so a crossed call returns the properly averaged matrix element of the process +it crosses into and can be compared directly against the other code. +""" + +from __future__ import absolute_import + +import itertools +import json +import math +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +import logging + +logger = logging.getLogger('madgraph.stdout.cross_symmetry') + +import madgraph +import madgraph.interface.master_interface as cmd_interface + +pjoin = os.path.join + +# The two processes are each other's crossing under (I=0, J=3). +PROC_QQ_GG = 'u u~ > g g' +PROC_QG_QG = 'u g > u g' +# The crossed partner that `g g > q q~` reaches with cross 3 (slot 1 <-> slot 2), +# used by the madmatrix tests that need the crossing to be a RECORDED one. +PROC_GQX_GQX = 'g u~ > g u~' + +# A CHIRAL pair: the W+ couples only to a left-handed u and a right-handed d~, so +# every external quark is 100% polarized and the per-leg density matrix diagonal +# is fully asymmetric ((++) empty, (--) full, or vice versa). That is what makes +# a crossed-fermion helicity FLIP detectable: on u u~ > g g the fermion density +# is (++)==(--), so a flip would be invisible; here it would swap a full entry +# with an empty one. u d~ > w+ g is mapped onto u g > w+ d by (I=0, J=NEXTERNAL): +# the incoming d~ becomes the outgoing d of the last slot (the crossed, still +# 100%-polarized fermion), the outgoing g becomes incoming. +PROC_UDX_WPG = 'u d~ > w+ g' +PROC_UG_WPD = 'u g > w+ d' + +# q q~ > g q q~ is likewise mapped onto q g > q q q~ by the same (I=0, J=3) +# crossing: the incoming q~ becomes the outgoing q of slot 3 and the outgoing g +# becomes an incoming one, leaving the legs ordered as (q, g, q, q, q~). +# Repeated over the quark flavors to exercise the flavor tables / masks and the +# BROKEN_SYM factor, which sees two identical final u's on the crossed side. +PROC_QQX_GQQX = '%(q)s %(q)s~ > g %(q)s %(q)s~' +PROC_QG_QQQX = '%(q)s g > %(q)s %(q)s %(q)s~' +QUARK_FLAVORS = ['u', 'd', 's', 'c'] + +# The merged (multi-flavor) form of the same pair. Generated with the group +# labels so that flavor grouping keeps every quark combination in a single +# matrix element, which is the only way to get NFLAV>1 and a non-trivial mask. +PROC_MERGED_QQX_GQQX = '_quark _anti_quark > g _quark _anti_quark' +PROC_MERGED_QG_QQQX = '_quark g > _quark _quark _anti_quark' + +# The same merged process constrained to a single squared coupling order. A +# squared-order constraint is what sets the process' 'split_orders', which is +# what makes write_matrix_element_v4 pick matrix_standalone_splitOrders_v4.inc +# instead of the default template. Same final state, so BROKEN_SYM is still 2 +# on the rows where the two final quarks differ. +PROC_MERGED_QG_QQQX_SO = '_quark g > _quark _quark _anti_quark QED^2==0' + +# Processes constraining an s-channel propagator. A crossing moves legs between +# the initial and the final state, so what is s-channel in the generated process +# is not s-channel in its crossings: `> z >` (required) and `$$ z` (forbidden, +# diagram removed) must therefore disable the crossing machinery on their own. +# A single `$ z` only forbids the on-shell *region* of a kept diagram, which +# survives the crossing, so it must NOT disable anything. +PROC_REQUIRED_S = 'u u~ > z > e+ e-' +PROC_FORBIDDEN_S = 'u u~ > e+ e- $$ z' +PROC_FORBIDDEN_ONSH_S = 'u u~ > e+ e- $ z' +PROC_UNCONSTRAINED = 'u u~ > e+ e-' + +# Every routine/table that only exists to decode an extended FLAV_IDX. +CROSSING_MACHINERY_NAMES = [ + 'APPLY_CROSSING', 'APPLY_CROSSING_TABLE', 'GET_CROSS_PERM', + 'GET_SPINCOL_CROSS', 'GET_IDENT_CROSS', 'SWAP_LEGS', + 'SPINCOL_CROSS_TABLE', 'BASEPID_CROSS_TABLE', 'SRC_CROSS_TABLE'] + +# cross = I*(NEXTERNAL+1) + J = 0*5 + 3 = 3. Both processes have NFLAV=1, so +# IFLAV = cross*NFLAV + flav = 3*1 + 1 = 4. +NEXTERNAL = 4 +CROSS_2_3 = 0 * (NEXTERNAL + 1) + 3 +# Same crossing for the 2->3 pair, where the base is NEXTERNAL+1 = 6. +NEXTERNAL_5 = 5 +CROSS_2_3_5 = 0 * (NEXTERNAL_5 + 1) + 3 +# Crossing particle 2 with the *last* particle. Only expressible because the +# base is NEXTERNAL+1: with base NEXTERNAL, mod(cross, NEXTERNAL) could never +# yield NEXTERNAL. +CROSS_2_LAST = 0 * (NEXTERNAL + 1) + NEXTERNAL +IFLAV_IDENTITY = 1 + + +def _iflav(cross, flav, nflav): + """Encode a crossing code and a flavor index into the extended IFLAV.""" + return cross * nflav + flav + + +def _massless_2to2(energy, cos_theta): + """A massless 2->2 point: (leg1_in, leg2_in, leg3_out, leg4_out).""" + halfe = 0.5 * energy + sin_theta = math.sqrt(1.0 - cos_theta ** 2) + return [(halfe, 0.0, 0.0, halfe), + (halfe, 0.0, 0.0, -halfe), + (halfe, halfe * sin_theta, 0.0, halfe * cos_theta), + (halfe, -halfe * sin_theta, 0.0, -halfe * cos_theta)] + + +# The C-parity de-duplication halves the helicity sum by pairing every row with +# its fully flipped partner. Two all-massless 2->2 processes bracket the rule: +# u u~ > g g pure QCD, parity conserving -- every pair matches, so the +# reuse ENGAGES and its halve-and-double arithmetic must leave +# the answer alone. +# d u~ > e- ve~ pure charged current, maximally parity violating (V-A) -- only +# left-handed fermions couple, so the flipped partner of the one +# surviving row is identically zero and the all-or-nothing rule +# must REFUSE the reuse for the whole flavor. +PROC_CPARITY_PAIRED = 'u u~ > g g' +PROC_CPARITY_BROKEN = 'd u~ > e- ve~' + + +# Subprocess probe for the good-helicity remap (GHREMAP) relation. Run against +# a compiled matrix2py module: for every DERIVABLE crossing (active partners all +# final), the crossed good-helicity set -- the rows where py_smatrixhel_idx is +# non-zero, unioned over many phase-space points -- must equal the identity +# good-helicity set mapped through the crossing's own row permutation sigma +# (config h -> (ic[k]*nhel[perm[k],h])_k). This is the invariant the generated +# GHREMAP encodes, so a wrong table (or a wrong derivability condition) breaks +# the fix. Run in a subprocess: importing an f2py .so into the test interpreter +# would leak a compiled module and clash across tests. +# +# GOTCHA locked in by this probe: 3 phase-space points are NOT enough -- for +# u u~ > g g, cross=23 then showed 6 non-zero rows instead of 8 (an accidental +# zero at the probed points). NPTS is deliberately >= 12. +_GOODHEL_PROBE = r''' +import sys, math +import numpy as np +sys.path.insert(0, %(pdir)r) +import matrix2py as m + +NINITIAL = %(ninitial)d +NPTS = %(npts)d + +def get_crossing_permutation(cross, nexternal): + base = nexternal + 1 + i_part, j_part = cross // base, cross %% base + perm = list(range(nexternal)); ic = [1] * nexternal + def swap(a, b): + perm[a], perm[b] = perm[b], perm[a]; ic[a] = -ic[a]; ic[b] = -ic[b] + valid = not (i_part not in (0, 1) and j_part not in (0, 2) + and (i_part == 2 or j_part == 1 or i_part == j_part)) + if i_part not in (0, 1): swap(0, i_part - 1) + if j_part not in (0, 2): swap(1, j_part - 1) + return perm, ic, valid + +def rambo(nf, ecm, rng): + q = np.zeros((4, nf)) + for i in range(nf): + c = 2 * rng.random() - 1 + s = math.sqrt(1 - c * c) + phi = 2 * math.pi * rng.random() + r1, r2 = rng.random(), rng.random() + q[0, i] = -math.log(r1 * r2) + q[3, i] = q[0, i] * c + q[2, i] = q[0, i] * s * math.cos(phi) + q[1, i] = q[0, i] * s * math.sin(phi) + Q = q.sum(axis=1) + M = math.sqrt(Q[0]**2 - Q[1]**2 - Q[2]**2 - Q[3]**2) + b = -Q[1:] / M; g = Q[0] / M; a = 1.0 / (1.0 + g); x = ecm / M + p = np.zeros((4, nf)) + for i in range(nf): + bq = b @ q[1:, i] + p[1:, i] = x * (q[1:, i] + b * (q[0, i] + a * bq)) + p[0, i] = x * (g * q[0, i] + bq) + return p + +def momenta(nexternal, ninitial, npts, seed): + rng = np.random.default_rng(seed) + ecm = 1000.0; nf = nexternal - ninitial; ps = [] + for _ in range(npts): + P = np.zeros((4, nexternal)) + P[0, 0] = ecm / 2; P[3, 0] = ecm / 2 + if ninitial >= 2: + P[0, 1] = ecm / 2; P[3, 1] = -ecm / 2 + P[:, ninitial:] = rambo(nf, ecm, rng) + ps.append(np.asfortranarray(P)) + return ps + +m.py_initialisemodel(%(card)r) +nflav, nexternal_l, ncross = m.py_get_flavor_layout() +_iden, nhel = m.py_get_nhel_idx(1) +nhel = np.array(nhel) # (nexternal, ncomb) +nexternal, ncomb = nhel.shape +ps = momenta(nexternal, NINITIAL, NPTS, seed=20260721) +row_of = {tuple(nhel[:, h]): h + 1 for h in range(ncomb)} + +def good_set(flav_idx): + good = set() + for P in ps: + for h in range(1, ncomb + 1): + if abs(m.py_smatrixhel_idx(P, h, flav_idx)) > 1e-30: + good.add(h) + return good + +g_id = good_set(1) +assert g_id, 'identity has no good helicity -- probe is broken' +base = nexternal + 1 +checked = genuine = 0 +for cross in range(1, base * base): + perm, ic, valid = get_crossing_permutation(cross, nexternal) + if not valid: + continue + I, J = cross // base, cross %% base + # DERIVABLE = the crossing's active partners are all final particles. + final_only = ((I in (0, 1) or I > NINITIAL) and (J in (0, 2) or J > NINITIAL)) + if not final_only: + continue + flav_idx = cross * nflav + 1 + # Skip a crossing that is not evaluable (spincol==0 -> SMATRIX returns 0). + tot = sum(abs(m.py_smatrixhel_idx(ps[0], h, flav_idx)) + for h in range(1, ncomb + 1)) + if tot == 0: + continue + sigma = {} + for h in range(ncomb): + cfg = tuple(ic[k] * nhel[perm[k], h] for k in range(nexternal)) + hp = row_of.get(cfg) + assert hp is not None, 'cross %%d: sigma is not a row bijection' %% cross + sigma[h + 1] = hp + expected = {sigma[h] for h in g_id} + g_cr = good_set(flav_idx) + assert g_cr == expected, ( + 'cross %%d (I=%%d,J=%%d): crossed good-hel %%s != sigma(identity) %%s' + %% (cross, I, J, sorted(g_cr), sorted(expected))) + checked += 1 + if perm != list(range(nexternal)): + genuine += 1 +assert genuine >= 1, 'no genuine (non-identity) derivable crossing was checked' +print('GHREMAP_RELATION_OK checked=%%d genuine=%%d points=%%d' %% + (checked, genuine, NPTS)) +''' + + +# Subprocess probe for the CROSSED spin-density matrix through the f2py wrapper +# PY_GET_DENSITY_IDX -- the only path by which a python caller can request a +# crossed density matrix (the FLAVOR-array PY_GET_DENSITY resolves through +# GET_FLAVOR_INDEX, which only returns 1..NFLAV and so cannot carry a crossing). +# Prints, per external leg, the three interference terms (++),(+-),(--) of that +# leg's density matrix, so the parent can compare a crossed evaluation against a +# natively generated reference term by term. Run in a subprocess because an +# f2py .so leaks into the importing interpreter and clashes across dirs/tests. +_DENSITY_PROBE = r''' +import sys, json +import numpy as np +sys.path.insert(0, %(pdir)r) +import matrix2py as m +m.py_initialisemodel(%(card)r) +momenta = %(momenta)s # [[E,px,py,pz], ...] per leg +P = np.asfortranarray(np.array(momenta, dtype=float).T) # (4, nexternal) +flav_idx = %(flav_idx)d +allow_hel = np.array([1, -1], dtype=np.int32) +out = {} +for leg in %(legs)s: + pos = np.array([leg], dtype=np.int32) + inter = np.asarray(m.py_get_density_idx( + P, pos, 1, allow_hel, 2, flav_idx, 0.0, 0.0)).ravel() + out[str(leg)] = [[float(z.real), float(z.imag)] for z in inter] +print('DENSITY_JSON ' + json.dumps(out)) +''' + + +class TestStandaloneCrossSymmetry(unittest.TestCase): + """u u~ > g g and u g > u g must reproduce each other under crossing.""" + + # A crossing swaps a leg between the initial and final state, so it probes + # a genuinely different kinematic region of the same analytic amplitude. + # Compare at a few scattering angles rather than a single point. + cos_thetas = [0.3, -0.62, 0.85] + energy = 1000.0 + tolerance = 1e-11 + + debugging = getattr(unittest, 'debug', False) + + def setUp(self): + self.cmd = cmd_interface.MasterCmd() + self.cmd.no_notification() + prefix = 'cross_debug_' if self.debugging else 'cross_' + self.tmpdir = tempfile.mkdtemp(prefix=prefix) + + def tearDown(self): + if not self.debugging and os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + # ------------------------------------------------------------------ + # generation / build helpers + # ------------------------------------------------------------------ + def _generate(self, process, name, options='', split_orders=False): + """Generate the standalone output for `process`, return its P* dir. + + `options` is appended to the generate command (e.g. --use_crossing=False). + `split_orders` selects the driver for the split-orders template, whose + density entry point takes the FLAVOR array rather than a FLAV_IDX. + """ + pdir = self._output_standalone(process, name, options) + self._write_driver(pdir, split_orders=split_orders) + self._build(pdir) + return pdir + + def _output_standalone(self, process, name, options=''): + """Write the standalone output for `process` and return its P* dir. + + Split out of _generate for the tests that only inspect the emitted + fortran and so have no reason to pay for a compile. + """ + outdir = pjoin(self.tmpdir, name) + self.cmd.exec_cmd('set automatic_html_opening False') + self.cmd.exec_cmd('set group_subprocesses False') + self.cmd.exec_cmd('set apply_flavor_grouping True') + self.cmd.exec_cmd('import model sm') + self.cmd.exec_cmd(('generate %s %s' % (process, options)).strip()) + self.cmd.exec_cmd('output standalone %s -f' % outdir) + + subproc_root = pjoin(outdir, 'SubProcesses') + pdirs = [pjoin(subproc_root, name) for name in sorted(os.listdir(subproc_root)) + if name.startswith('P') and os.path.isdir(pjoin(subproc_root, name))] + self.assertEqual(len(pdirs), 1, + 'Expected a single subprocess directory for %s, got %s' + % (process, pdirs)) + return pdirs[0] + + def _matrix_code(self, pdir): + """The emitted matrix.f with comment lines stripped. + + Only definitions/uses must be matched, not the prose: a comment may + legitimately still mention the machinery to explain its absence. + """ + with open(pjoin(pdir, 'matrix.f')) as fsock: + source = fsock.read() + return '\n'.join(line for line in source.split('\n') + if not line.lstrip().upper().startswith('C')) + + def _write_driver(self, pdir, split_orders=False): + """Replace check_sa.f by a driver reading momenta+IFLAV from a file. + + Reading the input rather than hardcoding it lets each process be + compiled once and then probed at many points / flavor indices. + + The split-orders template has no crossing machinery and hence no + GET_DENSITY_IDX: its density entry point takes the FLAVOR array, so the + driver resolves the index through GET_FLAVOR first. Everything else + (SMATRIX, GET_FLAVOR, GET_FLAVOR_INDEX) has the same interface, so only + that one call differs. + """ + if split_orders: + density_call = ''' CALL GET_FLAVOR(FLAV_IDX, FLAVOR) + CALL GET_DENSITY(P, DPOS, 1, ALLOW_HEL, 2, FLAVOR, + & 0D0, 0D0, INTER)''' + else: + density_call = ''' CALL GET_DENSITY_IDX(P, DPOS, 1, ALLOW_HEL, 2, FLAV_IDX, + & 0D0, 0D0, INTER)''' + # GET_NHEL_IDX / GET_PDG_FOR_FLAVOR only exist in matrix_standalone_v4; + # the split-orders template lacks them, so its driver must not reference + # them or it will not link. + if split_orders: + nhel_idx_call = ''' WRITE(*,*) 'IDEN= ', -1 + WRITE(*,*) 'PDG= ', 0''' + else: + nhel_idx_call = ''' CALL GET_NHEL_IDX(FLAV_IDX, IDEN_STAR, NHEL_STAR) + CALL GET_PDG_FOR_FLAVOR(FLAV_IDX, PDGS) + WRITE(*,*) 'IDEN= ', IDEN_STAR + WRITE(*,*) 'PDG= ', (PDGS(I),I=1,NEXTERNAL)''' + # GET_NHEL writes NEXTERNAL*NCOMB entries into NHEL_STAR using its own + # NCOMB; an oversized array in the caller is safe and avoids parsing + # NCOMB out of matrix.f. + driver = ''' PROGRAM DRIVER + use model_object + IMPLICIT NONE + INCLUDE "coupl.inc" + INCLUDE "nexternal.inc" + INTEGER NCOMB_MAX + PARAMETER (NCOMB_MAX=4096) + REAL*8 P(0:3,NEXTERNAL), MATELEM + INTEGER FLAV_IDX, I, J, MODE + INTEGER FLAVOR(NEXTERNAL) + INTEGER GET_FLAVOR_INDEX + INTEGER NHEL_STAR(NEXTERNAL,NCOMB_MAX), IDEN_STAR + INTEGER DPOS(1), ALLOW_HEL(2) + INTEGER PDGS(NEXTERNAL) + DOUBLE COMPLEX INTER(3) + call setpara('param_card.dat') + OPEN(UNIT=42,FILE='cross_input.dat',STATUS='OLD') + READ(42,*) MODE + IF (MODE.EQ.1) THEN + READ(42,*) FLAV_IDX + CALL GET_FLAVOR(FLAV_IDX, FLAVOR) + WRITE(*,*) 'POS= ', (FLAVOR(I),I=1,NEXTERNAL) + ELSEIF (MODE.EQ.2) THEN + READ(42,*) (FLAVOR(I),I=1,NEXTERNAL) + WRITE(*,*) 'IDX= ', GET_FLAVOR_INDEX(FLAVOR) + ELSEIF (MODE.EQ.4) THEN +C Density matrix: interference between the helicity states of one leg. +C GET_DENSITY_IDX takes the index directly, so it can carry a crossing; +C the FLAVOR-array entry point cannot express one. + READ(42,*) FLAV_IDX + READ(42,*) DPOS(1) + DO I=1,NEXTERNAL + READ(42,*) (P(J,I),J=0,3) + ENDDO + ALLOW_HEL(1) = +1 + ALLOW_HEL(2) = -1 +%(density_call)s + DO I=1,3 + WRITE(*,*) 'INTER= ', DREAL(INTER(I)), DIMAG(INTER(I)) + ENDDO + ELSEIF (MODE.EQ.5) THEN +C The f2py-facing crossing accessors: GET_NHEL_IDX returns the crossed +C averaging denominator (unlike GET_NHEL, which only knows the static +C uncrossed one), and GET_PDG_FOR_FLAVOR returns the per-leg signed PDG +C of the process the extended FLAV_IDX selects (crossed and conjugated). + READ(42,*) FLAV_IDX +%(nhel_idx_call)s + ELSE + READ(42,*) FLAV_IDX + DO I=1,NEXTERNAL + READ(42,*) (P(J,I),J=0,3) + ENDDO + CALL SMATRIX(P,FLAV_IDX,MATELEM) + CALL GET_NHEL(IDEN_STAR,NHEL_STAR) + WRITE(*,*) 'ANS= ', MATELEM + WRITE(*,*) 'IDEN= ', IDEN_STAR + ENDIF + CLOSE(42) + END +''' + with open(pjoin(pdir, 'check_sa.f'), 'w') as fsock: + fsock.write(driver % {'density_call': density_call, + 'nhel_idx_call': nhel_idx_call}) + + def _build(self, pdir): + retcode = self._call(['make', 'check'], pdir) + self.assertEqual(retcode, 0, 'Failed to compile standalone check in %s' % pdir) + + def _build_f2py(self, pdir): + """Build the f2py matrix2py module in `pdir`, or skip the test. + + f2py needs a working numpy build backend (meson on numpy>=1.26 / + python>=3.12), which is not guaranteed in every environment. When it is + missing this raises SkipTest rather than a failure: the wrapper logic is + also covered by a mock-backed test that has no toolchain dependency. + """ + env = dict(os.environ) + with open(os.devnull, 'w') as devnull: + retcode = subprocess.call(['make', 'matrix2py.so'], cwd=pdir, + stdout=devnull, stderr=devnull, env=env) + modules = [name for name in os.listdir(pdir) + if name.startswith('matrix2py') and name.endswith('.so')] + if retcode != 0 or not modules: + raise unittest.SkipTest( + 'Could not build the f2py module in %s (f2py/numpy build ' + 'backend unavailable); skipping the compiled-module test.' + % pdir) + + def _call(self, command, cwd): + if logger.isEnabledFor(logging.INFO): + return subprocess.call(command, cwd=cwd) + with open(os.devnull, 'w') as devnull: + return subprocess.call(command, stdout=devnull, stderr=devnull, cwd=cwd) + + # ------------------------------------------------------------------ + # running + # ------------------------------------------------------------------ + def _probe(self, pdir, lines): + """Feed the driver an input block and return its stdout.""" + with open(pjoin(pdir, 'cross_input.dat'), 'w') as fsock: + fsock.write('\n'.join(lines) + '\n') + return subprocess.Popen(['./check'], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=pdir).communicate()[0].decode() + + def _flavor_positions(self, pdir, flav): + """GET_FLAVOR: the per-leg flavor-group positions of a flavor index.""" + output = self._probe(pdir, ['1', '%d' % flav]) + match = re.search(r'POS=\s*(.*)', output) + self.assertTrue(match, 'No POS from %s, got:\n%s' % (pdir, output)) + return tuple(int(token) for token in match.group(1).split()) + + def _flavor_index(self, pdir, positions): + """GET_FLAVOR_INDEX: flavor index of a position vector, 0 if absent.""" + output = self._probe(pdir, ['2', ' '.join(str(p) for p in positions)]) + match = re.search(r'IDX=\s*(-?\d+)', output) + self.assertTrue(match, 'No IDX from %s, got:\n%s' % (pdir, output)) + return int(match.group(1)) + + def _nhel_idx(self, pdir, iflav): + """(crossed IDEN, per-leg signed PDG) an extended FLAV_IDX selects. + + Exercises the two f2py-facing accessors GET_NHEL_IDX / + GET_PDG_FOR_FLAVOR that a python caller working in PDG codes relies on. + """ + output = self._probe(pdir, ['5', '%d' % iflav]) + iden = re.search(r'IDEN=\s*(-?\d+)', output) + pdg = re.search(r'PDG=\s*(.*)', output) + self.assertTrue(iden and pdg, + 'No IDEN/PDG from %s, got:\n%s' % (pdir, output)) + return int(iden.group(1)), tuple(int(t) for t in pdg.group(1).split()) + + def _density(self, pdir, momenta, iflav, leg): + """Return the 3 interference terms of the density matrix of `leg`. + + (++), (+-) and (--) for the two helicity states of that single leg, + each as a complex number. + """ + lines = ['4', '%d' % iflav, '%d' % leg] + for mom in momenta: + lines.append(' '.join('%.17e' % component for component in mom)) + output = self._probe(pdir, lines) + values = re.findall(r'INTER=\s*(\S+)\s+(\S+)', output) + self.assertEqual(len(values), 3, + 'Expected 3 interference terms from %s, got:\n%s' + % (pdir, output)) + return [complex(float(re.sub('[dD]', 'e', real)), + float(re.sub('[dD]', 'e', imag))) + for real, imag in values] + + def _density_f2py(self, pdir, momenta, iflav, legs): + """The same per-leg density matrix as _density, but obtained through the + compiled f2py module's PY_GET_DENSITY_IDX. Returns {leg: [c++, c+-, c--]}. + + Requires the module already built (_build_f2py). Runs in a subprocess so + the f2py .so does not leak into the test interpreter and clash with the + other process' module. + """ + card = pjoin(pdir, os.pardir, os.pardir, 'Cards', 'param_card.dat') + script = _DENSITY_PROBE % { + 'pdir': pdir, 'card': card, + 'momenta': repr([list(mom) for mom in momenta]), + 'flav_idx': iflav, 'legs': repr(tuple(legs))} + script_path = pjoin(pdir, 'density_probe.py') + with open(script_path, 'w') as fsock: + fsock.write(script) + output = subprocess.Popen( + [sys.executable, script_path], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, cwd=pdir).communicate()[0].decode() + match = re.search(r'DENSITY_JSON (.*)', output) + self.assertTrue(match, 'No density from f2py probe in %s:\n%s' + % (pdir, output)) + raw = json.loads(match.group(1)) + return {int(leg): [complex(re_, im_) for re_, im_ in terms] + for leg, terms in raw.items()} + + def _run(self, pdir, momenta, iflav): + """Return the averaged matrix element SMATRIX gives for this IFLAV.""" + lines = ['3', '%d' % iflav] + for mom in momenta: + lines.append(' '.join('%.17e' % component for component in mom)) + output = self._probe(pdir, lines) + ans = re.search(r'ANS=\s*(?P[\d\.eEdD\+-]+)', output) + self.assertTrue(ans, + 'Could not read the matrix element from %s, got:\n%s' + % (pdir, output)) + return float(ans.group('value').replace('D', 'E').replace('d', 'e')) + + def _phase_space(self, cos_theta): + """A massless 2->2 point: (leg1_in, leg2_in, leg3_out, leg4_out). + + Every parton here (u, u~, g) is massless, so one point serves both + processes; only the interpretation of each slot differs. + """ + return _massless_2to2(self.energy, cos_theta) + + def _read_nflav(self, pdir): + """NFLAV of a generated process, needed to encode the extended IFLAV. + + IFLAV = cross*NFLAV + flav, so the crossing code cannot be turned into + an index without it. Read it rather than assume 1: if flavor grouping + ever merges several flavors here, a hardcoded 1 would silently probe + the wrong flavor instead of failing. + """ + with open(pjoin(pdir, 'matrix.f')) as fsock: + match = re.search(r'PARAMETER\s*\(NFLAV=(\d+)\)', fsock.read()) + self.assertTrue(match, 'Could not read NFLAV from %s' % pdir) + return int(match.group(1)) + + @staticmethod + def _solve3(matrix, rhs): + """Solve a 3x3 system by Cramer's rule (avoids a numpy dependency).""" + def det(m): + return (m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) + - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])) + base = det(matrix) + solution = [] + for col in range(3): + replaced = [[rhs[row] if c == col else matrix[row][c] + for c in range(3)] for row in range(3)] + solution.append(det(replaced) / base) + return solution + + def _phase_space_2to3(self, phis_deg=(0.0, 130.0, 245.0), alpha_deg=35.0): + """A massless 2->3 point: (leg1_in, leg2_in, leg3_out, .., leg5_out). + + Three massless momenta summing to zero are always coplanar, so the + final state is built in a plane as a closed triangle -- the direction + angles fix the energies up to the overall scale -- and then rotated out + of the beam-transverse plane by alpha so the point is not degenerate + with respect to the beam axis. + """ + phis = [math.radians(phi) for phi in phis_deg] + cosines = [math.cos(phi) for phi in phis] + sines = [math.sin(phi) for phi in phis] + # sum E*cos = 0, sum E*sin = 0, sum E = energy + energies = self._solve3([cosines, sines, [1.0, 1.0, 1.0]], + [0.0, 0.0, self.energy]) + for energy in energies: + self.assertGreater(energy, 0.0, + 'Unphysical phase-space point: energies=%s' + % energies) + alpha = math.radians(alpha_deg) + halfe = 0.5 * self.energy + momenta = [(halfe, 0.0, 0.0, halfe), (halfe, 0.0, 0.0, -halfe)] + for index, energy in enumerate(energies): + momenta.append((energy, + energy * cosines[index], + energy * sines[index] * math.cos(alpha), + energy * sines[index] * math.sin(alpha))) + return momenta + + def _assert_crossing(self, crossed_dir, crossed_iflav, reference_dir, label, + reference_perm=None): + """The crossed call on one process must match the other one, plain. + + reference_perm reorders the momenta for the reference code when the + crossing lands the legs in a different order than the reference + process expects; None means both take the very same array. + """ + for cos_theta in self.cos_thetas: + momenta = self._phase_space(cos_theta) + crossed = self._run(crossed_dir, momenta, crossed_iflav) + if reference_perm is None: + reference_momenta = momenta + else: + reference_momenta = [momenta[index] for index in reference_perm] + reference = self._run(reference_dir, reference_momenta, + IFLAV_IDENTITY) + scale = max(abs(crossed), abs(reference), 1e-99) + self.assertLessEqual( + abs(crossed - reference) / scale, self.tolerance, + '%s disagrees at cos(theta)=%s: crossed=%r reference=%r' + % (label, cos_theta, crossed, reference)) + + # ------------------------------------------------------------------ + # tests + # ------------------------------------------------------------------ + def test_crossing_gives_back_identity(self): + """cross=0 must leave the existing behaviour untouched.""" + qq_gg = self._generate(PROC_QQ_GG, 'Proc_qq_gg') + momenta = self._phase_space(self.cos_thetas[0]) + plain = self._run(qq_gg, momenta, IFLAV_IDENTITY) + self.assertNotEqual(plain, 0.0, + 'Sanity check failed: %s gives a null matrix element' + % PROC_QQ_GG) + # IFLAV = cross*NFLAV + flav with cross=0 is just flav: same answer. + self.assertEqual(plain, self._run(qq_gg, momenta, + _iflav(0, 1, nflav=1))) + + def test_qq_gg_crossed_gives_qg_qg(self): + """u u~ > g g with particle 2 <-> 3 crossed must give u g > u g.""" + qq_gg = self._generate(PROC_QQ_GG, 'Proc_qq_gg') + qg_qg = self._generate(PROC_QG_QG, 'Proc_qg_qg') + self._assert_crossing( + crossed_dir=qq_gg, crossed_iflav=_iflav(CROSS_2_3, 1, nflav=1), + reference_dir=qg_qg, label='%s crossed (I=0,J=3) vs %s' + % (PROC_QQ_GG, PROC_QG_QG)) + + def test_qq_gg_crossed_with_last_particle(self): + """Particle 2 must be crossable with the last particle (J=NEXTERNAL). + + This is the case the NEXTERNAL+1 base exists for: with base NEXTERNAL, + J could only reach NEXTERNAL-1 and this crossing was unreachable. + Swapping particle 2 with particle 4 in u u~ > g g turns the incoming u~ + into an outgoing u sitting in slot 4 and the outgoing g of slot 4 into + an incoming one, so the legs come out ordered as u g > g u: the same + physics as u g > u g with the two final legs exchanged. + """ + qq_gg = self._generate(PROC_QQ_GG, 'Proc_qq_gg') + qg_qg = self._generate(PROC_QG_QG, 'Proc_qg_qg') + self._assert_crossing( + crossed_dir=qq_gg, crossed_iflav=_iflav(CROSS_2_LAST, 1, nflav=1), + reference_dir=qg_qg, reference_perm=(0, 1, 3, 2), + label='%s crossed (I=0,J=4) vs %s with final legs swapped' + % (PROC_QQ_GG, PROC_QG_QG)) + + def test_qqx_gqqx_crossed_gives_qg_qqqx(self): + """q q~ > g q q~ crossed (2<->3) must give q g > q q q~, for each q. + + A 2->3 pair, so the crossing has to survive a real flavor table (each + leg carries its own flavor-group position) and a BROKEN_SYM / + identical-particle factor that only exists on the crossed side: the + crossed final state has two identical quarks, which the uncrossed + q q~ > g q q~ does not. That shows up as IDEN 36 -> 192. + + Repeated over u/d/s/c: up- and down-type quarks sit in different + flavor groups, so their flavor tables and masks differ. + """ + for quark in QUARK_FLAVORS: + with self.subTest(quark=quark): + qqx_gqqx = self._generate(PROC_QQX_GQQX % {'q': quark}, + 'Proc_qqx_gqqx_%s' % quark) + qg_qqqx = self._generate(PROC_QG_QQQX % {'q': quark}, + 'Proc_qg_qqqx_%s' % quark) + nflav = self._read_nflav(qqx_gqqx) + momenta = self._phase_space_2to3() + + crossed = self._run(qqx_gqqx, momenta, + _iflav(CROSS_2_3_5, 1, nflav=nflav)) + reference = self._run(qg_qqqx, momenta, IFLAV_IDENTITY) + + self.assertNotEqual( + reference, 0.0, + 'Sanity check failed: %s gives a null matrix element' + % (PROC_QG_QQQX % {'q': quark})) + scale = max(abs(crossed), abs(reference), 1e-99) + self.assertLessEqual( + abs(crossed - reference) / scale, self.tolerance, + '%s crossed (I=0,J=3) disagrees with %s: ' + 'crossed=%r reference=%r' + % (PROC_QQX_GQQX % {'q': quark}, + PROC_QG_QQQX % {'q': quark}, crossed, reference)) + + def test_merged_flavor_crossing_every_flavor(self): + """Every flavor of the merged q q~ > g q q~ must cross onto q g > q q q~. + + The single-flavor tests above only ever exercise the rows where all + quarks share one flavor, and those are exactly the rows for which the + denominator happens to be flavor independent. This one sweeps the whole + merged table (NFLAV=28 against NFLAV=16), which is what catches a + denominator built from the process's representative flavor instead of + the actual one: d d~ > g u u~ crosses to d g > d u u~ (nothing + identical) while d d~ > g d d~ crosses to d g > d d d~ (two identical + d), and getting that wrong shows up as a clean factor 2. + + Flavors are matched through the generated GET_FLAVOR / + GET_FLAVOR_INDEX rather than by index: the two processes do not have + the same NFLAV, so equal indices mean nothing. + """ + merged_a = self._generate(PROC_MERGED_QQX_GQQX, 'Proc_merged_a') + merged_b = self._generate(PROC_MERGED_QG_QQQX, 'Proc_merged_b') + nflav_a = self._read_nflav(merged_a) + self.assertGreater(nflav_a, 1, + 'Expected a merged multi-flavor matrix element, got ' + 'NFLAV=%s: this test would not probe the flavor ' + 'dependence of the denominator' % nflav_a) + momenta = self._phase_space_2to3() + + unmapped = [] + for flav in range(1, nflav_a + 1): + positions = self._flavor_positions(merged_a, flav) + # Caller slot 2 holds leg 3 (the gluon) and slot 3 holds leg 2. + crossed = (positions[0], positions[2], positions[1], + positions[3], positions[4]) + reference_perm = None + target = self._flavor_index(merged_b, crossed) + if target < 1: + # Slots 3 and 4 are both _quark, so the target keeps only one + # ordering of each unordered pair. Try the other one, swapping + # the momenta along with the flavors. + swapped = (crossed[0], crossed[1], crossed[3], + crossed[2], crossed[4]) + target = self._flavor_index(merged_b, swapped) + reference_perm = (0, 1, 3, 2, 4) + if target < 1: + unmapped.append((flav, positions, crossed)) + continue + + with self.subTest(flav=flav, positions=positions): + crossed_value = self._run(merged_a, momenta, + _iflav(CROSS_2_3_5, flav, + nflav=nflav_a)) + reference_momenta = momenta if reference_perm is None else \ + [momenta[index] for index in reference_perm] + reference = self._run(merged_b, reference_momenta, target) + scale = max(abs(crossed_value), abs(reference), 1e-99) + self.assertLessEqual( + abs(crossed_value - reference) / scale, self.tolerance, + 'flavor %s (positions %s) crossed disagrees: crossed=%r ' + 'reference=%r (ratio %r)' + % (flav, positions, crossed_value, reference, + reference / crossed_value if crossed_value else None)) + + self.assertFalse(unmapped, + 'Crossed flavors with no counterpart in %s: %s' + % (PROC_MERGED_QG_QQQX, unmapped)) + + def test_merged_flavor_reverse_crossing_covers_every_flavor(self): + """The reverse crossing must reach every flavor of q q~ > g q q~. + + q g > q q q~ has fewer flavors (16) than q q~ > g q q~ (28), which + looks like the reverse mapping cannot be onto. It is: the crossing + partner J is the missing degree of freedom. J=3 and J=4 cross particle + 2 with one or the other of the two final quarks, and those land on + different flavors of the target. The two coincide only when the two + final quarks already share a flavor, so the count works out exactly: + + 16 flavors x 2 crossings - 4 degenerate = 28 + + J=4 leaves the legs ordered (q, q~, q, g, q~) instead of the target's + (q, q~, g, q, q~), hence the momentum swap of slots 3 and 4. + """ + merged_a = self._generate(PROC_MERGED_QQX_GQQX, 'Proc_merged_a') + merged_b = self._generate(PROC_MERGED_QG_QQQX, 'Proc_merged_b') + nflav_a = self._read_nflav(merged_a) + nflav_b = self._read_nflav(merged_b) + momenta = self._phase_space_2to3() + + covered = {} + for flav_b in range(1, nflav_b + 1): + positions = self._flavor_positions(merged_b, flav_b) + variants = ( + # J=3: legs already come out in the target's order. + (3, (positions[0], positions[2], positions[1], + positions[3], positions[4]), None), + # J=4: cross the other final quark, then reorder slots 3/4. + (4, (positions[0], positions[3], positions[1], + positions[2], positions[4]), (0, 1, 3, 2, 4)), + ) + for j_part, target_positions, perm in variants: + flav_a = self._flavor_index(merged_a, target_positions) + self.assertGreaterEqual( + flav_a, 1, + 'Crossed flavor %s (from %s flavor %s, J=%s) has no ' + 'counterpart in %s' + % (target_positions, PROC_MERGED_QG_QQQX, flav_b, j_part, + PROC_MERGED_QQX_GQQX)) + covered.setdefault(flav_a, []).append((flav_b, j_part)) + + with self.subTest(flav_b=flav_b, j_part=j_part): + cross = 0 * (NEXTERNAL_5 + 1) + j_part + crossed_momenta = momenta if perm is None else \ + [momenta[index] for index in perm] + crossed_value = self._run(merged_b, crossed_momenta, + _iflav(cross, flav_b, + nflav=nflav_b)) + reference = self._run(merged_a, momenta, flav_a) + scale = max(abs(crossed_value), abs(reference), 1e-99) + self.assertLessEqual( + abs(crossed_value - reference) / scale, self.tolerance, + '%s flavor %s crossed (J=%s) disagrees with %s flavor ' + '%s: crossed=%r reference=%r' + % (PROC_MERGED_QG_QQQX, flav_b, j_part, + PROC_MERGED_QQX_GQQX, flav_a, crossed_value, + reference)) + + self.assertEqual( + len(covered), nflav_a, + 'The reverse crossing covers %s of the %s flavors of %s; missing ' + '%s' % (len(covered), nflav_a, PROC_MERGED_QQX_GQQX, + sorted(set(range(1, nflav_a + 1)) - set(covered)))) + + def test_crossed_density_matrix(self): + """The density matrix must survive the crossing, helicity by helicity. + + Every other test here sums over helicities, which makes them blind to + how a crossed leg's helicity is labelled: a spurious flip would just + permute the terms of the sum and cancel out. The density matrix is + resolved per helicity, so it is the one probe that pins that down. + + The expectation is that NO extra flip is needed. Helas builds the + wavefunction with nh=nhel*nsf, so flipping the NSF flag of a crossed + leg already flips its effective helicity; the caller's label therefore + carries over unchanged through the slot permutation. If a flip were + missing (or applied twice) the diagonal terms would swap and the + off-diagonal one would conjugate, which this comparison would catch. + + Probed on the gluon of u g > u g, which is leg 2 there and comes from + the crossing on the u u~ > g g side. + """ + qq_gg = self._generate(PROC_QQ_GG, 'Proc_qq_gg') + qg_qg = self._generate(PROC_QG_QG, 'Proc_qg_qg') + for cos_theta in self.cos_thetas: + momenta = self._phase_space(cos_theta) + with self.subTest(cos_theta=cos_theta): + crossed = self._density(qq_gg, momenta, + _iflav(CROSS_2_3, 1, nflav=1), leg=2) + reference = self._density(qg_qg, momenta, IFLAV_IDENTITY, + leg=2) + self.assertTrue(any(abs(term) > 1e-99 for term in reference), + 'Sanity check failed: null density matrix for ' + '%s' % PROC_QG_QG) + for index, (got, want) in enumerate(zip(crossed, reference)): + scale = max(abs(got), abs(want), 1e-99) + self.assertLessEqual( + abs(got - want) / scale, self.tolerance, + 'Density matrix term %s disagrees at cos(theta)=%s: ' + 'crossed=%r reference=%r' % (index, cos_theta, got, want)) + + def test_density_matrix_diagonal_matches_smatrix(self): + """Summing the density matrix diagonal must reproduce SMATRIX. + + The diagonal terms are |M|^2 for each helicity of the probed leg, so + summing them has to give back what SMATRIX returns for that flavor. + This pins the normalisation of the density path, which GET_INTER cannot + get right on its own: it only sees JAMPs, so it divides by the bare + static IDEN and can apply neither BROKEN_SYM nor a crossed denominator. + + Probed on the merged q g > q q q~, whose two final quarks live in the + same flavor group: BROKEN_SYM is 2 exactly when they differ, and those + are the rows that were coming out a factor 2 low. A single-flavor + process would have BROKEN_SYM=1 throughout and prove nothing. + """ + merged_b = self._generate(PROC_MERGED_QG_QQQX, 'Proc_merged_b') + nflav_b = self._read_nflav(merged_b) + momenta = self._phase_space_2to3() + for flav in range(1, nflav_b + 1): + with self.subTest(flav=flav): + density = self._density(merged_b, momenta, flav, leg=1) + diagonal = density[0] + density[2] + reference = self._run(merged_b, momenta, flav) + self.assertNotEqual(reference, 0.0, + 'Sanity check failed: null matrix element ' + 'for flavor %s' % flav) + scale = max(abs(diagonal), abs(reference), 1e-99) + self.assertLessEqual( + abs(diagonal.real - reference) / scale, self.tolerance, + 'Density diagonal does not sum to SMATRIX for flavor %s: ' + 'diagonal=%r smatrix=%r (ratio %r)' + % (flav, diagonal.real, reference, + reference / diagonal.real if diagonal.real else None)) + + def _assert_chiral_crossed_density(self, crossed, reference): + """Every leg's crossed density matrix must match the native one, AND the + crossed fermion (last leg) must be fully polarized so the check actually + discriminates a helicity flip. + + `crossed` / `reference` are {leg: [c++, c+-, c--]} for legs 1..4 of + u g > w+ d. Leg 4 is the d that swapped initial<->final on the + u d~ > w+ g side; the W+ makes it 100% one-handed, so (++) and (--) are + one full / one empty. A missing or doubled crossing flip would swap them, + which the term-by-term comparison then catches. + """ + pol_pp, pol_mm = abs(reference[4][0]), abs(reference[4][2]) + self.assertGreater(max(pol_pp, pol_mm), 1e-3, + 'Reference crossed-fermion density is null; the probe ' + 'is broken (%r)' % reference[4]) + self.assertLess(min(pol_pp, pol_mm), 1e-9 * max(pol_pp, pol_mm), + 'Crossed fermion is not fully polarized, so a helicity ' + 'flip would NOT be discriminated: (++)=%r (--)=%r' + % (reference[4][0], reference[4][2])) + for leg in (1, 2, 3, 4): + self.assertTrue(any(abs(term) > 1e-99 for term in reference[leg]), + 'Null reference density for leg %s' % leg) + for index, (got, want) in enumerate(zip(crossed[leg], + reference[leg])): + scale = max(abs(got), abs(want), 1e-99) + self.assertLessEqual( + abs(got - want) / scale, self.tolerance, + 'Crossed density term %s of leg %s disagrees: crossed=%r ' + 'reference=%r' % (index, leg, got, want)) + + def test_crossed_density_matrix_chiral_fortran(self): + """The crossed spin-density matrix of a CHIRAL process, via the compiled + Fortran GET_DENSITY_IDX (no f2py). + + u d~ > w+ g crossed by (I=0, J=NEXTERNAL) is u g > w+ d; its outgoing d + is the incoming d~ that swapped sides, still 100% polarized by the W. The + density matrix is per helicity, so it is the probe that pins how that + crossed leg's helicity is LABELLED -- the same no-flip convention the + madevent cross-group event helicity (DSIG_XGHEL) depends on. Every leg, + crossed vs natively generated, must agree term by term. + """ + udx_wpg = self._generate(PROC_UDX_WPG, 'Proc_udx_wpg') + ug_wpd = self._generate(PROC_UG_WPD, 'Proc_ug_wpd') + crossed_iflav = _iflav(CROSS_2_LAST, 1, nflav=1) + for cos_theta in self.cos_thetas: + momenta = self._phase_space(cos_theta) + with self.subTest(cos_theta=cos_theta): + crossed = {leg: self._density(udx_wpg, momenta, crossed_iflav, + leg=leg) for leg in (1, 2, 3, 4)} + reference = {leg: self._density(ug_wpd, momenta, IFLAV_IDENTITY, + leg=leg) for leg in (1, 2, 3, 4)} + self._assert_chiral_crossed_density(crossed, reference) + + def test_crossed_density_matrix_chiral_f2py(self): + """Same chiral crossed-density-matrix check, but through the f2py + PY_GET_DENSITY_IDX wrapper -- the only way a python caller can ask for a + crossed density matrix. Skips if the f2py build backend is unavailable. + """ + udx_wpg = self._output_standalone(PROC_UDX_WPG, 'Proc_udx_wpg_f2py') + ug_wpd = self._output_standalone(PROC_UG_WPD, 'Proc_ug_wpd_f2py') + self._build_f2py(udx_wpg) + self._build_f2py(ug_wpd) + crossed_iflav = _iflav(CROSS_2_LAST, 1, nflav=1) + for cos_theta in self.cos_thetas: + momenta = self._phase_space(cos_theta) + with self.subTest(cos_theta=cos_theta): + crossed = self._density_f2py(udx_wpg, momenta, crossed_iflav, + (1, 2, 3, 4)) + reference = self._density_f2py(ug_wpd, momenta, IFLAV_IDENTITY, + (1, 2, 3, 4)) + self._assert_chiral_crossed_density(crossed, reference) + + def test_split_orders_density_diagonal_matches_smatrix(self): + """The same invariant on the split-orders template. + + matrix_standalone_splitOrders_v4.inc is a separate template with its own + copy of the density code, and it had the very same missing-BROKEN_SYM + bug as the default one: SMATRIX applies BROKEN_SYM(FLAVOR) while + GET_INTER normalises with the bare static IDEN and cannot, so the + diagonal came out a factor BROKEN_SYM low. Fixing one template does not + fix the other, hence this test next to + test_density_matrix_diagonal_matches_smatrix. + + Uses the merged q g > q q q~ for the same reason: its two final quarks + share a flavor group, so BROKEN_SYM=2 on the rows where they differ. A + single-flavor process has BROKEN_SYM=1 everywhere and would pass even + with the rescaling removed entirely. + """ + merged = self._generate(PROC_MERGED_QG_QQQX_SO, 'Proc_merged_so', + split_orders=True) + # Guard the premise: if the squared-order syntax ever stopped setting + # split_orders, this would silently retest the default template. + self.assertIn('SMATRIX_SPLITORDERS', self._matrix_code(merged), + 'Expected %s to be written with the split-orders ' + 'template; this test would otherwise just retest the ' + 'default one' % PROC_MERGED_QG_QQQX_SO) + nflav = self._read_nflav(merged) + self.assertGreater(nflav, 1, + 'Expected a merged multi-flavor matrix element, got ' + 'NFLAV=%s: BROKEN_SYM would be 1 throughout and this ' + 'test could not fail' % nflav) + momenta = self._phase_space_2to3() + for flav in range(1, nflav + 1): + with self.subTest(flav=flav): + density = self._density(merged, momenta, flav, leg=1) + diagonal = density[0] + density[2] + reference = self._run(merged, momenta, flav) + self.assertNotEqual(reference, 0.0, + 'Sanity check failed: null matrix element ' + 'for flavor %s' % flav) + scale = max(abs(diagonal), abs(reference), 1e-99) + self.assertLessEqual( + abs(diagonal.real - reference) / scale, self.tolerance, + 'Split-orders density diagonal does not sum to SMATRIX for ' + 'flavor %s: diagonal=%r smatrix=%r (ratio %r)' + % (flav, diagonal.real, reference, + reference / diagonal.real if diagonal.real else None)) + + def test_use_crossing_false_drops_the_machinery(self): + """--use_crossing=False must emit no crossing code, same ME otherwise. + + The extended FLAV_IDX only makes sense when the crossed subprocesses + are *not* generated separately, which is exactly what --use_crossing + drives. With it off, none of the decoding routines nor the tables they + read may reach matrix.f (they would be dead code, and GET_AMP's IC + would carry a crossing that can never be requested), while the plain + uncrossed matrix element must be untouched: the crossing-off path goes + through ANS/IDEN*BROKEN_SYM instead of the per-crossing denominator, + and those two must agree for CROSS=0. + """ + default = self._generate(PROC_QQ_GG, 'Proc_qq_gg_default') + no_cross = self._generate(PROC_QQ_GG, 'Proc_qq_gg_nocross', + options='--use_crossing=False') + + code = self._matrix_code(no_cross) + for name in CROSSING_MACHINERY_NAMES: + self.assertNotIn(name, code, + '%s is still emitted with --use_crossing=False' + % name) + # Sanity: the very same assertion must fail on the default output, + # otherwise this test would pass on a matrix.f that never had any. + self.assertIn('GET_SPINCOL_CROSS', self._matrix_code(default), + 'Default output has no crossing machinery either: ' + 'this test proves nothing') + + for cos_theta in self.cos_thetas: + momenta = self._phase_space(cos_theta) + with self.subTest(cos_theta=cos_theta): + plain = self._run(no_cross, momenta, IFLAV_IDENTITY) + reference = self._run(default, momenta, IFLAV_IDENTITY) + self.assertNotEqual(reference, 0.0, + 'Sanity check failed: %s gives a null ' + 'matrix element' % PROC_QQ_GG) + self.assertEqual(plain, reference, + '--use_crossing=False changes the uncrossed ' + 'matrix element at cos(theta)=%s: %r vs %r' + % (cos_theta, plain, reference)) + + def _assert_machinery(self, process, name, expected): + """Assert the crossing machinery is (not) emitted for `process`.""" + code = self._matrix_code(self._output_standalone(process, name)) + if expected: + # One representative name is enough to prove the machinery is there; + # the full list matters only for the "must be absent" direction, + # where any single leftover would be dead code reading a crossing + # that can never be requested. + self.assertIn('GET_SPINCOL_CROSS', code, + 'Crossing machinery is missing for %s, which does ' + 'not constrain any s-channel' % process) + else: + for routine in CROSSING_MACHINERY_NAMES: + self.assertNotIn(routine, code, + '%s is emitted for %s, whose s-channel ' + 'constraint no crossing preserves' + % (routine, process)) + return code + + def test_required_s_channel_disables_crossing(self): + """`> z >` must drop the machinery; the same process without it keeps it. + + A required s-channel names a propagator that is only s-channel in this + arrangement of the legs, so it cannot survive a crossing and the + machinery must not be emitted. The unconstrained twin is generated too: + without it, the test would pass on any matrix.f that never had the + machinery at all (e.g. if e+e- output stopped emitting it for an + unrelated reason). + """ + self._assert_machinery(PROC_REQUIRED_S, 'Proc_required_s', + expected=False) + self._assert_machinery(PROC_UNCONSTRAINED, 'Proc_unconstrained_req', + expected=True) + + def test_forbidden_s_channel_disables_crossing(self): + """`$$ z` removes a diagram by s-channel, so it must drop the machinery. + + Paired with the unconstrained twin for the same anti-vacuity reason as + test_required_s_channel_disables_crossing. + """ + self._assert_machinery(PROC_FORBIDDEN_S, 'Proc_forbidden_s', + expected=False) + self._assert_machinery(PROC_UNCONSTRAINED, 'Proc_unconstrained_forb', + expected=True) + + def test_forbidden_onshell_s_channel_keeps_crossing(self): + """A single `$ z` must NOT disable crossing: the diagram is kept. + + `$` only forbids the on-shell region of a propagator, it does not pin + the topology, so the crossing machinery stays. This is the test that + stops the fix from being over-broad and disabling crossing for every + process carrying any `$`-like constraint. + """ + self._assert_machinery(PROC_FORBIDDEN_ONSH_S, 'Proc_forbidden_onsh_s', + expected=True) + + def test_f2py_flavor_index_accessors(self): + """GET_NHEL_IDX / GET_PDG_FOR_FLAVOR must describe the crossed process. + + These are the f2py-facing accessors that let a python caller work in + PDG codes: they turn an extended FLAV_IDX into (crossed denominator, + crossed+conjugated PDG list). Two failure modes they must not have, + both invisible to the |M|^2 tests: + * GET_NHEL_IDX returning the static uncrossed IDEN (the historical + GET_NHEL bug) rather than the crossed one, and + * GET_PDG_FOR_FLAVOR forgetting to conjugate a leg that swapped + between the initial and the final state. + For u u~ > g g the identity (IFLAV=1) is itself, and the (I=0,J=3) + crossing (IFLAV=4) is u g > u g: leg 2's u~ (pdg -2) becomes an + outgoing u (pdg +2) in slot 3, and IDEN goes 72 -> 96. + """ + qq_gg = self._generate(PROC_QQ_GG, 'Proc_qq_gg') + + iden_id, pdg_id = self._nhel_idx(qq_gg, IFLAV_IDENTITY) + self.assertEqual(iden_id, 72, + 'Identity IDEN wrong: %s' % iden_id) + self.assertEqual(pdg_id, (2, -2, 21, 21), + 'Identity PDG wrong: %s' % (pdg_id,)) + + iden_cr, pdg_cr = self._nhel_idx(qq_gg, _iflav(CROSS_2_3, 1, nflav=1)) + self.assertEqual(iden_cr, 96, + 'Crossed IDEN should be 96 (u g > u g), got %s. A 72 ' + 'here is the GET_NHEL static-IDEN bug.' % iden_cr) + self.assertEqual(pdg_cr, (2, 21, 2, 21), + 'Crossed PDG should be u g > u g with leg 2 conjugated,' + ' got %s' % (pdg_cr,)) + + def test_f2py_pdg_wrapper(self): + """The python PDG wrapper must find the crossing and call the right ME. + + End-to-end through the compiled f2py module: build it, then drive + flavor_dispatch.FlavorDispatch. A caller who knows only the physical + process as a signed-PDG list must get back the extended FLAV_IDX (via + find_pdg) and the correct crossed matrix element (via + matrix_element_pdg). For a u u~ > g g module the identity is itself and + the (I=0,J=3) crossing is u g > u g. Skips if f2py cannot build here. + """ + pdir = self._output_standalone(PROC_QQ_GG, 'Proc_qq_gg_f2py') + self._build_f2py(pdir) + + # Run in a subprocess: importing an f2py .so into the test interpreter + # would leak a compiled module and clash across tests. + script = ''' +import sys, math, numpy as np +sys.path.insert(0, %(pdir)r) +import matrix2py +from flavor_dispatch import FlavorDispatch +me = FlavorDispatch(matrix2py) +me.initialisemodel(%(card)r) +assert me.flavor_layout() == (1, 4, 25), me.flavor_layout() +assert me.pdg_for_index(1) == (2, -2, 21, 21), me.pdg_for_index(1) +assert me.pdg_for_index(4) == (2, 21, 2, 21), me.pdg_for_index(4) +assert me.find_pdg([2, -2, 21, 21]) == 1 +assert me.find_pdg([2, 21, 2, 21]) == 4 +assert me.find_pdg([6, -6, 21, 21]) is None # unreachable process +E = 500.0; c = 0.3; s = math.sqrt(1.0 - c * c) +P = np.asfortranarray(np.array([[E, 0, 0, E], [E, 0, 0, -E], + [E, E * s, 0, E * c], [E, -E * s, 0, -E * c]]).T) +direct = me.smatrix(P, 4) +via = me.matrix_element_pdg(P, [2, 21, 2, 21]) +assert abs(direct - via) <= 1e-11 * abs(direct), (direct, via) +assert direct > 0.0 +print("F2PY_PDG_OK") +''' % {'pdir': pdir, + 'card': pjoin(pdir, os.pardir, os.pardir, 'Cards', 'param_card.dat')} + script_path = pjoin(pdir, 'pdg_wrapper_probe.py') + with open(script_path, 'w') as fsock: + fsock.write(script) + proc = subprocess.Popen([sys.executable, script_path], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + cwd=pdir) + output = proc.communicate()[0].decode() + self.assertIn('F2PY_PDG_OK', output, + 'PDG wrapper probe failed:\n%s' % output) + + def _assert_goodhel_relation(self, process, name, ninitial, npts=16): + """Compiled-module check of the GHREMAP good-helicity relation. + + Builds the f2py module for `process` and, for every DERIVABLE crossing, + asserts the crossed good-helicity set equals the identity's mapped + through the crossing permutation sigma (the invariant GHREMAP encodes). + Skips if the f2py toolchain is unavailable, exactly like the other + compiled-module tests. + """ + pdir = self._output_standalone(process, name) + self._build_f2py(pdir) + card = pjoin(pdir, os.pardir, os.pardir, 'Cards', 'param_card.dat') + script = _GOODHEL_PROBE % {'pdir': pdir, 'card': card, + 'ninitial': ninitial, 'npts': npts} + script_path = pjoin(pdir, 'goodhel_relation_probe.py') + with open(script_path, 'w') as fsock: + fsock.write(script) + proc = subprocess.Popen([sys.executable, script_path], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + cwd=pdir) + output = proc.communicate()[0].decode() + self.assertIn('GHREMAP_RELATION_OK', output, + 'good-helicity relation probe failed for %s:\n%s' + % (process, output)) + + def test_goodhel_relation_qq_gg(self): + """The crossed good-helicity set of u u~ > g g must be the identity's + mapped through sigma, for every derivable crossing (>=12 points, so the + cross=23 accidental-zero undercount cannot mask a bug).""" + self._assert_goodhel_relation(PROC_QQ_GG, 'Proc_qq_gg_goodhel', + ninitial=2) + + def test_goodhel_relation_qq_ggg(self): + """Same relation on a 2->3 (u u~ > g g g): more crossings, and the + initial-initial swaps that break the relation are correctly excluded + from the derivable set the probe checks.""" + self._assert_goodhel_relation('u u~ > g g g', 'Proc_qq_ggg_goodhel', + ninitial=2) + + def test_qg_qg_crossed_gives_qq_gg(self): + """u g > u g with particle 2 <-> 3 crossed must give u u~ > g g.""" + qq_gg = self._generate(PROC_QQ_GG, 'Proc_qq_gg') + qg_qg = self._generate(PROC_QG_QG, 'Proc_qg_qg') + self._assert_crossing( + crossed_dir=qg_qg, crossed_iflav=_iflav(CROSS_2_3, 1, nflav=1), + reference_dir=qq_gg, label='%s crossed (I=0,J=3) vs %s' + % (PROC_QG_QG, PROC_QQ_GG)) + + # ------------------------------------------------------------------ + # decay chains: the crossing acts at the production level, the whole + # decay block riding along on its production leg + # ------------------------------------------------------------------ + def _read_masses(self, pdir): + """Signed-PDG -> mass, read from the process' generated param_card.""" + card = pjoin(pdir, os.pardir, os.pardir, 'Cards', 'param_card.dat') + masses = {} + in_mass = False + with open(card) as fsock: + for line in fsock: + low = line.lower().strip() + if low.startswith('block mass'): + in_mass = True + continue + if in_mass and low.startswith('block'): + in_mass = False + if in_mass: + fields = line.split('#')[0].split() + if len(fields) == 2: + try: + masses[int(fields[0])] = float(fields[1]) + except ValueError: + pass + return masses + + def _massive_2ton(self, pdir, pdgs, seed=7): + """A phase-space point for a 2->(len(pdgs)-2) process with the leaf + masses of `pdgs` (signed PDGs, initial two first). + + A decay chain's matrix element is not on any resonance pole at a rambo + point, so the propagators are finite and the crossed / reference values + can be compared directly; only the external masses have to be right. + """ + import madgraph.various.rambo as rambo + import random + random.seed(seed) + masses = self._read_masses(pdir) + finals = pdgs[2:] + fmass = rambo.FortranList(len(finals)) + for i, pdg in enumerate(finals): + fmass[i + 1] = abs(masses.get(abs(pdg), 0.0)) + p_rambo, _ = rambo.RAMBO(len(finals), self.energy, fmass) + momenta = [(0.5 * self.energy, 0.0, 0.0, 0.5 * self.energy), + (0.5 * self.energy, 0.0, 0.0, -0.5 * self.energy)] + for i in range(1, len(finals) + 1): + momenta.append((p_rambo[(4, i)], p_rambo[(1, i)], + p_rambo[(2, i)], p_rambo[(3, i)])) + return momenta + + def _assert_decay_crossing(self, base_dir, base_line, ref_line, cross, pdgs): + """The base decay-chain SMATRIX at a crossing must reproduce a + fully-generated (--use_crossing=False) build of the crossed decay chain. + + `pdgs` is the crossed leaf signature (from compute_crossing_pdg_entries, + the order the momenta must be supplied in); it is both the reference + process order and the momentum order fed to both builds. The base carries + the crossing through the extended IFLAV, the reference evaluates it as its + own identity -- the two must agree to machine precision. + """ + ref_dir = self._generate(ref_line, 'Proc_dc_ref_%d' % cross, + options='--use_crossing=False') + nflav = self._read_nflav(base_dir) + momenta = self._massive_2ton(ref_dir, pdgs) + crossed = self._run(base_dir, momenta, _iflav(cross, 1, nflav=nflav)) + reference = self._run(ref_dir, momenta, IFLAV_IDENTITY) + self.assertNotEqual(reference, 0.0, + 'Sanity check failed: %s gives a null matrix element' + % ref_line) + scale = max(abs(crossed), abs(reference), 1e-99) + self.assertLessEqual( + abs(crossed - reference) / scale, self.tolerance, + '%s crossed (cross=%d) disagrees with %s: crossed=%r reference=%r' + % (base_line, cross, ref_line, crossed, reference)) + + def test_decay_chain_crossing_ttbar_jet(self): + """g u > t t~ u, t > b w+ must reproduce its production crossings. + + The crossing permutes the light partons (a jet moving between the initial + and the final state); the t decay block (b w+) rides along on the top and + is never split, and the t~/jet legs move as whole single legs. The base's + crossing-aware SMATRIX at the crossed flavor index must equal a fully + generated build of each crossed decay chain. + """ + base_line = 'g u > t t~ u, t > b w+' + base = self._generate(base_line, 'Proc_dc_base') + # (cross code, reference line, crossed leaf signature); the base leaves + # are [g,u,b,w+,t~,u], NEXTERNAL=6 so CROSS = I*7 + J. + cases = [ + (6 * 7 + 0, 'u~ u > t t~ g, t > b w+', (-2, 2, 5, 24, -6, 21)), + (0 * 7 + 6, 'g u~ > t t~ u~, t > b w+', (21, -2, 5, 24, -6, -2)), + ] + for cross, ref_line, pdgs in cases: + with self.subTest(cross=cross): + self._assert_decay_crossing(base, base_line, ref_line, cross, + pdgs) + + def test_decay_chain_crossing_identical_resonances(self): + """u u~ > z z g, z > e+ e- exercises the resonance-level denominator. + + Both z decay the same way, so the crossed identical-particle factor is + NOT a plain count over the crossed leaves (that would double-count the + two e+/two e-): it is resonance level (the two identical z count once). + The crossing must rebuild that factor -- IDENT_RESONANCE times the + countable single legs -- so the crossed value matches a full build. + """ + base_line = 'u u~ > z z g, z > e+ e-' + base = self._generate(base_line, 'Proc_dc_zz_base') + # base leaves [u,u~,e+,e-,e+,e-,g], NEXTERNAL=7 so CROSS = I*8 + J. + cases = [ + (0 * 8 + 7, 'u g > z z u, z > e+ e-', (2, 21, -11, 11, -11, 11, 2)), + ] + for cross, ref_line, pdgs in cases: + with self.subTest(cross=cross): + self._assert_decay_crossing(base, base_line, ref_line, cross, + pdgs) + + +class TestGoodHelCParityDedup(unittest.TestCase): + """The C-parity de-duplication of the helicity sum must be transparent. + + SMATRIX pairs every helicity row IHEL with FLIP(IHEL), the row with every + helicity negated. For the first 20 unpolarized calls it evaluates both and + compares |M|^2 (the scan phase); from then on -- and ONLY if every pair + matched -- it evaluates the lower-index row once, counts it twice and skips + its partner, halving the loop (the fast phase). + + Both halves of that contract are checked directly rather than through a + golden number: + + (a) the premise, per row: for a parity-conserving process the paired rows + really do have the same |M|^2 at the same momenta, and for a + parity-violating one they do not. Probed row by row through + SMATRIXHEL, whose helicity CODE comes from the process' own + ENCODE_HEL, so this also pins the pairing to the canonical encoding + rather than to a row index the test guessed. + + (b) the consequence: the plain unpolarized sum is the same before and + after the fast phase switches on -- both where the reuse engages (the + halve-and-double arithmetic) and where it must refuse itself. The + second is the regression: the verdict used to default to "de-duplicate" + and the validating scan could be skipped entirely (read_good_hel forces + NTRY past MAXTRIES), so a flavor whose pairs nothing had verified + silently summed half of its helicities. + + Verified by instrumenting SMATRIX to print DEDUP while writing these: over 30 + successive calls u u~ > g g ends with CSYM true and the fast phase ON from + call 20, while d u~ > e- ve~ ends with CSYM false and never enters it. The + two processes really do cover the engage and the refuse branch, so neither + stability check passes merely because nothing ever happened. + """ + + energy = 1000.0 + cos_theta = 0.3 + # > 20 unpolarized calls, so the last ones are in the fast phase. + nrepeat = 30 + # The fast phase accumulates 2*|M|^2 at the representative instead of adding + # the partner separately, so the sum is reassociated: equal to the last bit + # is not guaranteed, agreement to ~1e-12 is. + tolerance = 1e-12 + + debugging = getattr(unittest, 'debug', False) + + def setUp(self): + self.cmd = cmd_interface.MasterCmd() + self.cmd.no_notification() + self.tmpdir = tempfile.mkdtemp( + prefix='cparity_debug_' if self.debugging else 'cparity_') + + def tearDown(self): + if not self.debugging and os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + # ------------------------------------------------------------------ + def _generate(self, process, name): + """Standalone-output `process`, build the C-parity driver, return its + P* dir.""" + outdir = pjoin(self.tmpdir, name) + self.cmd.exec_cmd('set automatic_html_opening False') + self.cmd.exec_cmd('set group_subprocesses False') + self.cmd.exec_cmd('set apply_flavor_grouping True') + self.cmd.exec_cmd('import model sm') + self.cmd.exec_cmd('generate %s' % process) + self.cmd.exec_cmd('output standalone %s -f' % outdir) + + subproc_root = pjoin(outdir, 'SubProcesses') + pdirs = [pjoin(subproc_root, entry) + for entry in sorted(os.listdir(subproc_root)) + if entry.startswith('P') + and os.path.isdir(pjoin(subproc_root, entry))] + self.assertEqual(len(pdirs), 1, + 'Expected a single subprocess directory for %s, got %s' + % (process, pdirs)) + pdir = pdirs[0] + source = open(pjoin(pdir, 'matrix.f')).read() + # The probe drives flavor 1 directly, so the process must not have been + # merged into a multi-flavor matrix element behind our back. + nflav = re.search(r'PARAMETER\s*\(NFLAV=(\d+)\)', source) + self.assertTrue(nflav, 'Could not read NFLAV from %s' % pdir) + self.assertEqual(int(nflav.group(1)), 1, + '%s came out with NFLAV=%s; the probe assumes a single ' + 'flavor' % (process, nflav.group(1))) + ncomb = re.search(r'PARAMETER\s*\(\s*NCOMB=(\d+)\)', source) + self.assertTrue(ncomb, 'Could not read NCOMB from %s' % pdir) + self._write_driver(pdir, int(ncomb.group(1))) + retcode = self._call(['make', 'check'], pdir) + self.assertEqual(retcode, 0, 'Failed to compile the driver in %s' % pdir) + return pdir + + @staticmethod + def _call(command, cwd): + if logger.isEnabledFor(logging.INFO): + return subprocess.call(command, cwd=cwd) + with open(os.devnull, 'w') as devnull: + return subprocess.call(command, stdout=devnull, stderr=devnull, + cwd=cwd) + + def _write_driver(self, pdir, ncomb): + """Replace check_sa.f by a driver with the two probes this needs. + + MODE 1 walks the helicity table and reports (|M(h)|^2, |M(-h)|^2) for + every row, going through ENCODE_HEL so the codes are the process' own. + MODE 2 calls the plain unpolarized SMATRIX repeatedly at one point, so + the scan phase and the fast phase can be compared within a single run -- + the de-duplication state lives in SMATRIX and does not survive the + process. + """ + driver = ''' PROGRAM CPARITY_DRIVER + use model_object + IMPLICIT NONE + INCLUDE "coupl.inc" + INCLUDE "nexternal.inc" + INTEGER NCOMB + PARAMETER (NCOMB=%(ncomb)d) + REAL*8 P(0:3,NEXTERNAL), ANS, ANSFLIP + INTEGER I, J, MODE, NREP, IHEL, CODE, FCODE, IDEN_STAR + INTEGER NHEL_STAR(NEXTERNAL,NCOMB) + INTEGER THIS(NEXTERNAL), FLIPPED(NEXTERNAL) + call setpara('param_card.dat') + OPEN(UNIT=42,FILE='cparity_input.dat',STATUS='OLD') + READ(42,*) MODE + DO I=1,NEXTERNAL + READ(42,*) (P(J,I),J=0,3) + ENDDO + IF (MODE.EQ.1) THEN +C Per-row C-parity probe. SMATRIXHEL selects a single row by its +C canonical code and undoes the helicity average, the same on both +C rows of a pair, so the two values are directly comparable. + CALL GET_NHEL(IDEN_STAR,NHEL_STAR) + DO IHEL=1,NCOMB + DO J=1,NEXTERNAL + THIS(J) = NHEL_STAR(J,IHEL) + FLIPPED(J) = -NHEL_STAR(J,IHEL) + ENDDO + CALL ENCODE_HEL(THIS, CODE) + CALL ENCODE_HEL(FLIPPED, FCODE) + CALL SMATRIXHEL(P, CODE, 1, ANS) + CALL SMATRIXHEL(P, FCODE, 1, ANSFLIP) + WRITE(*,'(A,3(1X,I6),2(1X,ES25.17))') + & 'PAIR=', IHEL, CODE, FCODE, ANS, ANSFLIP + ENDDO + ELSE +C The plain unpolarized sum, repeatedly: NTRY_CSYM crosses its +C threshold part way through and the fast phase takes over. + READ(42,*) NREP + DO I=1,NREP + CALL SMATRIX(P,1,ANS) + WRITE(*,'(A,1X,I6,1X,ES25.17)') 'ANS=', I, ANS + ENDDO + ENDIF + CLOSE(42) + END +''' + with open(pjoin(pdir, 'check_sa.f'), 'w') as fsock: + fsock.write(driver % {'ncomb': ncomb}) + + def _probe(self, pdir, lines): + with open(pjoin(pdir, 'cparity_input.dat'), 'w') as fsock: + fsock.write('\n'.join(lines) + '\n') + return subprocess.Popen(['./check'], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=pdir).communicate()[0].decode() + + def _momentum_lines(self): + return [' '.join('%.17e' % component for component in mom) + for mom in _massless_2to2(self.energy, self.cos_theta)] + + def _pairs(self, pdir): + """[(row, |M(h)|^2, |M(-h)|^2)] over the whole helicity table.""" + output = self._probe(pdir, ['1'] + self._momentum_lines()) + pairs = [(int(row), float(direct), float(flipped)) + for row, _code, _fcode, direct, flipped + in re.findall(r'PAIR=\s+(\d+)\s+(\d+)\s+(\d+)\s+' + r'(\S+)\s+(\S+)', output)] + self.assertTrue(pairs, 'no C-parity pair read from %s, got:\n%s' + % (pdir, output)) + return pairs + + def _repeated_sums(self, pdir): + """The unpolarized SMATRIX value of each of `nrepeat` successive calls.""" + output = self._probe(pdir, ['2'] + self._momentum_lines() + + ['%d' % self.nrepeat]) + values = [float(value) + for _call, value in re.findall(r'ANS=\s+(\d+)\s+(\S+)', output)] + self.assertEqual(len(values), self.nrepeat, + 'expected %d matrix elements from %s, got %d:\n%s' + % (self.nrepeat, pdir, len(values), output)) + return values + + def _assert_sum_is_stable(self, pdir, label): + """Every repeated call must give the first call's value. + + Call 1 is in the scan phase (full helicity sum, both members of every + pair evaluated); the last calls are past the threshold. If the reuse is + wrong -- a missing factor of two, or a de-duplication applied to a + flavor whose pairs do not match -- the value steps part way through. + """ + values = self._repeated_sums(pdir) + reference = values[0] + self.assertNotEqual(reference, 0.0, + '%s gives a null matrix element' % label) + for index, value in enumerate(values, start=1): + self.assertLessEqual( + abs(value - reference), self.tolerance * abs(reference), + '%s: call %d gives %r but call 1 gave %r -- the C-parity ' + 'de-duplication changed the unpolarized sum' + % (label, index, value, reference)) + + # ------------------------------------------------------------------ + def test_cparity_pairs_match_for_qcd(self): + """Parity-conserving: every row equals its fully flipped partner. + + This is the premise the fast phase rests on. Checked row by row, so a + pairing built on the wrong encoding fails here rather than silently + halving the sum somewhere else. + """ + pdir = self._generate(PROC_CPARITY_PAIRED, 'Proc_cparity_qcd') + pairs = self._pairs(pdir) + nonzero = 0 + for row, direct, flipped in pairs: + scale = max(abs(direct), abs(flipped)) + if scale == 0.0: + continue + nonzero += 1 + self.assertLessEqual( + abs(direct - flipped), 1e-10 * scale, + '%s row %d: |M(h)|^2=%r but |M(-h)|^2=%r; the C-parity pairing ' + 'the de-duplication relies on does not hold' + % (PROC_CPARITY_PAIRED, row, direct, flipped)) + self.assertGreater(nonzero, 1, + 'only %d non-zero helicity row(s) in %s: the pairing ' + 'is not being exercised' + % (nonzero, PROC_CPARITY_PAIRED)) + + def test_cparity_pairs_broken_for_charged_current(self): + """Maximally parity-violating: at least one pair must NOT match. + + Without this the "all-or-nothing refusal" half of the rule would never + be exercised -- if every process in the suite happened to be + parity-conserving, a de-duplication that never refuses would pass. + """ + pdir = self._generate(PROC_CPARITY_BROKEN, 'Proc_cparity_cc') + pairs = self._pairs(pdir) + mismatched = [(row, direct, flipped) + for row, direct, flipped in pairs + if abs(direct - flipped) + > 1e-10 * max(abs(direct), abs(flipped), 1e-99)] + self.assertTrue( + mismatched, + '%s: every helicity row matched its flipped partner, so this ' + 'process does not test the refusal path any more' % PROC_CPARITY_BROKEN) + + def test_dedup_leaves_the_paired_sum_unchanged(self): + """The reuse engages here, and must not move the answer.""" + pdir = self._generate(PROC_CPARITY_PAIRED, 'Proc_cparity_qcd_sum') + self._assert_sum_is_stable(pdir, PROC_CPARITY_PAIRED) + + def test_refused_dedup_leaves_the_broken_sum_unchanged(self): + """The regression: the reuse must refuse itself here. + + If it does not, the fast phase drops every row whose partner is zero and + doubles the wrong ones, and the sum moves at call 21. + """ + pdir = self._generate(PROC_CPARITY_BROKEN, 'Proc_cparity_cc_sum') + self._assert_sum_is_stable(pdir, PROC_CPARITY_BROKEN) + + +class TestCheckCrossingCommand(unittest.TestCase): + """The `check crossing` MG5 subcommand end-to-end. + + Drives the same code path as ``check crossing ``: + ``process_checks.check_crossing`` regenerates the process to fortran + standalone twice (crossing on and off), builds the f2py ``matrix2py`` + module in every P* directory, and compares each subprocess evaluated + through the crossing-enabled build against its crossing-disabled value. + Skips (rather than fails) when the f2py/numpy build backend is missing. + """ + + # x = u u~, x x > x x is the smallest line that puts a subprocess of the + # crossing-disabled reference (u u > u u) behind a *genuine* crossing in the + # crossing-enabled build: the two modes pick different representatives, so + # u u > u u is reached there only by a non-identity FLAV_IDX. That makes the + # comparison exercise APPLY_CROSSING rather than a plain identity, and it is + # small enough (no external gluon) to build quickly. + def setUp(self): + import madgraph.interface.master_interface as cmd_interface + self.cmd = cmd_interface.MasterCmd() + self.cmd.no_notification() + self.cmd.exec_cmd('set automatic_html_opening False', printcmd=False) + self.cmd.exec_cmd('import model sm', printcmd=False) + self.cmd.exec_cmd('define xq = u u~', printcmd=False) + + def _run_check(self, proc_line, exporter='standalone'): + import madgraph.various.process_checks as process_checks + # The C++/mg7 backends need a working C++ compiler + build toolchain; + # the fortran one needs f2py. Skip (do not fail) when unavailable. + if exporter != 'standalone': + compiler = os.environ.get('CXX', 'g++') + if not shutil.which(compiler): + raise unittest.SkipTest('no C++ compiler (%s) available for ' + 'exporter %s' % (compiler, exporter)) + procdef = self.cmd.extract_process(proc_line) + results = process_checks.check_crossing( + procdef, param_card=None, + options={'energy': 1000.0, 'proc_line': proc_line, + 'exporter': exporter}, + cmd=self.cmd) + if any(r.get('status') == 'build_failed' for r in results): + raise unittest.SkipTest( + 'Could not build the %s crossing output (build backend ' + 'unavailable); skipping the check crossing test.' % exporter) + return results, process_checks + + def _assert_all_pass_with_crossing(self, results, process_checks, + require_crossing=True): + """Shared assertions: every subprocess agrees (Passed), at least one is + reached through a genuine (non-identity) crossing, and the rendered + report is failure-free.""" + self.assertTrue(results, 'check crossing returned no comparison') + checked = 0 + crossed = 0 + for res in results: + self.assertEqual(res['status'], 'ok', res) + vd = res['value_direct'] + vc = res['value_crossed'] + self.assertIsNotNone(vd, 'no direct value for %s' % res['process']) + self.assertIsNotNone(vc, 'no crossed value for %s' % res['process']) + self.assertGreater(abs(vd), 0.0, + 'null matrix element for %s' % res['process']) + scale = max(abs(vd), abs(vc), 1e-99) + self.assertLessEqual( + abs(vd - vc) / scale, 1e-6, + '%s disagrees between crossing on/off: direct=%r crossed=%r' + % (res['process'], vd, vc)) + checked += 1 + if res.get('cross_code'): + crossed += 1 + self.assertGreater(checked, 0, 'no subprocess was checked') + if require_crossing: + # Non-vacuity: the comparison must genuinely go through the crossing + # machinery for at least one subprocess, not only identity matches. + self.assertGreater( + crossed, 0, + 'No subprocess was reached through a non-identity crossing; the ' + 'test would then only compare the two builds at cross=0') + + # The rendered report must show the Passed verdict, as the other check + # subcommands do. + text = process_checks.output_crossing(results) + self.assertIn('Passed', text) + self.assertIn('Summary:', text) + self.assertEqual(process_checks.output_crossing(results, 'fail'), 0, + 'output_crossing reported a failure:\n%s' % text) + return crossed + + def test_check_crossing_command(self): + """standalone (fortran): every subprocess must agree between the two + modes, with a Passed verdict, and at least one must be reached through a + real crossing.""" + results, process_checks = self._run_check('xq xq > xq xq') + self._assert_all_pass_with_crossing(results, process_checks) + + def test_check_crossing_command_cpp(self): + """standalone_cpp backend: u u > u u reached through a genuine crossing + of a different subprocess must agree with its independent value.""" + results, process_checks = self._run_check( + 'xq xq > xq xq', exporter='standalone_cpp') + self._assert_all_pass_with_crossing(results, process_checks) + + def test_check_crossing_command_mg7(self): + """standalone_mg7 (cudacpp CPU-SIMD) backend: same genuine-crossing + agreement, evaluated at a prescribed phase-space point injected into the + SIMD momenta buffer.""" + results, process_checks = self._run_check( + 'xq xq > xq xq', exporter='standalone_mg7') + self._assert_all_pass_with_crossing(results, process_checks) + + def test_check_crossing_invalid_exporter(self): + """An unknown --exporter must raise a clear InvalidCmd, not run.""" + import madgraph + import madgraph.various.process_checks as process_checks + procdef = self.cmd.extract_process('g u > g u') + with self.assertRaises(madgraph.InvalidCmd) as ctx: + process_checks.check_crossing( + procdef, param_card=None, + options={'energy': 1000.0, 'proc_line': 'g u > g u', + 'exporter': 'not_a_backend'}, + cmd=self.cmd) + self.assertIn('not_a_backend', str(ctx.exception)) + + def test_check_crossing_invalid_simd(self): + """An unknown standalone_mg7 --simd must raise a clear InvalidCmd. + + No build: constructing the mg7 backend validates the choice up front. + """ + import madgraph + import madgraph.various.process_checks as process_checks + procdef = self.cmd.extract_process('g u > g u') + with self.assertRaises(madgraph.InvalidCmd) as ctx: + process_checks.check_crossing( + procdef, param_card=None, + options={'energy': 1000.0, 'proc_line': 'g u > g u', + 'exporter': 'standalone_mg7', 'simd': 'not_a_simd'}, + cmd=self.cmd) + self.assertIn('not_a_simd', str(ctx.exception)) + + def test_check_crossing_s_channel_graceful(self): + """A required s-channel disables crossing; the check must still pass. + + `u u~ > z > e+ e-` is only s-channel in this arrangement of the legs, so + no crossing preserves it and the crossing machinery is not emitted. The + command must handle this gracefully: every subprocess is matched at the + identity and passes (the crossing-enabled and crossing-disabled builds + agree), rather than erroring. + """ + results, process_checks = self._run_check('u u~ > z > e+ e-') + self.assertTrue(results, 'check crossing returned no comparison') + for res in results: + self.assertEqual(res['status'], 'ok', res) + self.assertIsNotNone(res['value_direct']) + self.assertIsNotNone(res['value_crossed']) + self.assertFalse(res.get('cross_code'), + 'a constrained-s-channel process should not be ' + 'reached by any non-identity crossing: %s' % res) + self.assertEqual(process_checks.output_crossing(results, 'fail'), 0) + + +class TestCrossingUnsupportedOutput(unittest.TestCase): + """Outputs that cannot cross must refuse a process generated with crossing. + + --use_crossing is on by default and tells the generation *not* to write the + crossed subprocesses out separately, because the matrix element is supposed + to reach them through an extended FLAV_IDX. The fortran standalone and the + (grouped) madevent output decode one; an output that cannot must not quietly + produce a matrix element missing those subprocesses -- it gets the recorded + crossings expanded back into explicit subprocesses instead, so the result is + the complete uncrossed output and no user flag is required. + """ + + # Outputs reached through ExportV4Factory that have no crossing machinery + # (madevent is no longer here: the grouped exporter shares a base matrix + # element through the crossing router, see TestCrossingPartition). + UNSUPPORTED_FORMATS = ['matchbox'] + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix='cross_unsupported_') + + def tearDown(self): + if os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + def _output(self, fmt, name, options='', process=PROC_QG_QG, setup=(), + out_options=''): + """Run generate+output for `fmt`; returns the output directory. + + `options` goes on the generate line, `out_options` on the output line. + """ + out = pjoin(self.tmpdir, name) + cmd = cmd_interface.MasterCmd() + cmd.no_notification() + cmd.exec_cmd('set automatic_html_opening False') + for line in setup: + cmd.exec_cmd(line) + cmd.exec_cmd('import model sm') + cmd.exec_cmd(('generate %s %s' % (process, options)).strip()) + cmd.exec_cmd(('output %s %s -f %s' % (fmt, out, out_options)).strip()) + return out + + @staticmethod + def _subprocesses(out_dir): + path = pjoin(out_dir, 'SubProcesses') + return sorted(name for name in os.listdir(path) + if name.startswith('P')) + + def test_unsupported_output_accepts_crossing(self): + """Crossing on must NOT be refused by an output that cannot read it. + + The crossed subprocesses are recorded as metadata at generation, so an + output with no crossing machinery gets them expanded back into explicit + subprocesses instead of erroring out. Refusing here used to force the + user to pass --use_crossing=False even for a process that folds no + crossing at all (u g > u g folds none), which is why the gate moved from + the flag to the data. + """ + for fmt in self.UNSUPPORTED_FORMATS: + with self.subTest(format=fmt): + with_crossing = self._output(fmt, 'on_%s' % fmt) + without = self._output(fmt, 'off_%s' % fmt, + options='--use_crossing=False') + self.assertEqual(self._subprocesses(with_crossing), + self._subprocesses(without), + '%s output differs with crossing on' % fmt) + + def test_ungrouped_madevent_expands_folded_crossings(self): + """A folding process must lose nothing on an output without crossing. + + p p > j j QCD=0 really does fold crossings, so this is the case where a + silently-missing subprocess would change the cross-section: the + ungrouped madevent output (no crossing machinery) must come out with the + very same subprocesses as an explicitly uncrossed generation. + """ + ungrouped = ('set group_subprocesses False',) + on = self._output('madevent', 'me_on', process='p p > j j QCD=0', + setup=ungrouped) + off = self._output('madevent', 'me_off', process='p p > j j QCD=0', + options='--use_crossing=False', setup=ungrouped) + subs_on = self._subprocesses(on) + self.assertEqual(subs_on, self._subprocesses(off)) + # Guard the guard: a build that collapsed everything into one directory + # would satisfy the equality above only if both sides were broken. + self.assertGreater(len(subs_on), 1, + 'expected several crossed subprocesses, got %s' + % subs_on) + + def test_unsupported_output_accepted_without_crossing(self): + """--use_crossing=False must let the very same output through. + + Without this the test above would be satisfied by an exporter that is + simply broken, rather than by one gating on the crossing request. + """ + for fmt in self.UNSUPPORTED_FORMATS: + with self.subTest(format=fmt): + self._output(fmt, 'ok_%s' % fmt, + options='--use_crossing=False') + + def test_folding_output_expands_when_crossing_turned_off(self): + """--use_crossing=False on the output line must stay a COMPLETE output. + + The generation folds the crossed subprocesses onto their base and the + standalone backends reach them through the base's crossing-aware + SMATRIX/sigmaKin. Dropping that machinery at output time therefore has to + put the folded subprocesses back, or the output silently loses those + partonic contributions -- the exact trap the flag is documented never to + spring. q q > q q (q = u d u~ d~) really does fold: it collapses to one + directory with crossing on. + """ + setup = ('define q = u d u~ d~',) + proc = 'q q > q q' + for fmt in ('standalone', 'standalone_mg7'): + with self.subTest(format=fmt): + on = self._output(fmt, 'fold_on_%s' % fmt, process=proc, + setup=setup) + gen_off = self._output(fmt, 'fold_gen_%s' % fmt, process=proc, + setup=setup, + options='--use_crossing=False') + out_off = self._output(fmt, 'fold_out_%s' % fmt, process=proc, + setup=setup, + out_options='--use_crossing=False') + self.assertEqual(self._subprocesses(gen_off), + self._subprocesses(out_off), + '%s: --use_crossing=False on the output line ' + 'kept the crossings folded' % fmt) + # Guard the guard: both sides would agree if nothing ever folded. + self.assertLess(len(self._subprocesses(on)), + len(self._subprocesses(out_off)), + '%s: expected %s to fold crossings with the ' + 'crossing on' % (fmt, proc)) + + def test_supported_outputs_accept_crossing(self): + """Outputs that DO implement crossing must not be caught. + + Anchors the gate against being over-broad: a check that refused every + output would pass both tests above. The fortran standalone decodes the + extended FLAV_IDX directly; the grouped madevent output reaches the + crossed subprocesses through the crossing router. + """ + for fmt in ('standalone', 'madevent'): + with self.subTest(format=fmt): + self._output(fmt, 'ok_%s' % fmt) + + +# The C++ standalone driver: take a fixed RAMBO phase space point once +# (all-massless, so the momenta are identical between the two P directories) and +# print sigmaKin at each flavor_id passed on the command line. Each flavor_id is +# evaluated in a FRESH CPPProcess so the good-helicity cache starts empty: that +# cache is indexed by the reduced flavor (flav_use), so different crossings of +# one flavor would otherwise share it and, once it kicks in, a later crossing +# would be filtered by an earlier one's non-zero-helicity pattern (the deferred +# open question of keying the cache on the full flavor_id). The momenta are +# generated once and reused so every process sees the very same point. +# The shipped check_sa.cpp only ever loops over its own maxflavor identities, so +# a purpose-built driver is needed to request a crossed flavor_id. +_CPP_DRIVER = r""" +#include +#include +#include +#include "CPPProcess.h" +#include "rambo.h" + +int main(int argc, char** argv){ + double energy = 1000.0; + double weight; + CPPProcess seed("../../Cards/param_card.dat"); + vector p = get_momenta(seed.ninitial, energy, + seed.getMasses(), weight); + std::cout << std::setprecision(17); + for(int a = 1; a < argc; a++){ + int fid = atoi(argv[a]); + CPPProcess process("../../Cards/param_card.dat"); + process.setMomenta(p); + double me = process.sigmaKin(fid); + std::cout << "sigmaKin(" << fid << ") = " << me << std::endl; + } + return 0; +} +""" + + +class TestStandaloneCppCrossSymmetry(unittest.TestCase): + """standalone_cpp must reproduce the crossing the fortran standalone does. + + Mirror of TestStandaloneCrossSymmetry for the C++ backend: u u~ > g g and + u g > u g are each other's crossing under (I=0, J=3). In the 0-based C++ + flavor_id encoding cross = flavor_id / nflav and flav = flavor_id % nflav, + so with NFLAV=1 the crossing (I=0, J=3) -> cross = 0*(NEXTERNAL+1)+3 = 3 is + reached by flavor_id = 3. sigmaKin already divides by the crossed + denominator, so a crossed call returns the properly averaged matrix element + of the process it crosses into and can be compared directly. + + Skipped when no C++ compiler is available (the whole check needs to build + and run real C++). + """ + + energy = 1000.0 + tolerance = 1e-9 + # cross = I*(NEXTERNAL+1)+J = 0*5+3 = 3, flavor_id = cross*NFLAV+flav (NFLAV=1) + CROSS_2_3 = 3 + IDENTITY = 0 + + debugging = getattr(unittest, 'debug', False) + + def setUp(self): + self.compiler = os.environ.get('CXX', 'g++') + if not shutil.which(self.compiler): + self.skipTest('no C++ compiler (%s) available' % self.compiler) + self.tmpdir = tempfile.mkdtemp(prefix='cross_cpp_') + + def tearDown(self): + if not self.debugging and os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + # ------------------------------------------------------------------ + def _output_standalone_cpp(self, process, name, options=''): + """Write the standalone_cpp output for `process`, return its P* dir.""" + outdir = pjoin(self.tmpdir, name) + cmd = cmd_interface.MasterCmd() + cmd.no_notification() + cmd.exec_cmd('set automatic_html_opening False') + cmd.exec_cmd('set group_subprocesses False') + cmd.exec_cmd('set apply_flavor_grouping True') + cmd.exec_cmd('import model sm') + cmd.exec_cmd(('generate %s %s' % (process, options)).strip()) + cmd.exec_cmd('output standalone_cpp %s -f' % outdir) + + subproc_root = pjoin(outdir, 'SubProcesses') + pdirs = [pjoin(subproc_root, d) for d in sorted(os.listdir(subproc_root)) + if d.startswith('P') and os.path.isdir(pjoin(subproc_root, d))] + self.assertEqual(len(pdirs), 1, + 'Expected a single subprocess directory for %s, got %s' + % (process, pdirs)) + return pdirs[0] + + def _cpp_source(self, pdir): + with open(pjoin(pdir, 'CPPProcess.cc')) as fsock: + return fsock.read() + + def _build_and_run(self, pdir, flavor_ids): + """Build the driver in `pdir` and return {flavor_id: sigmaKin}.""" + # 'make' compiles CPPProcess.o and links the shipped check; it also + # proves the generated code compiles. + with open(os.devnull, 'w') as devnull: + rc = subprocess.call(['make'], cwd=pdir, stdout=devnull, + stderr=subprocess.STDOUT) + self.assertEqual(rc, 0, 'make failed in %s' % pdir) + + with open(pjoin(pdir, 'driver_cross.cpp'), 'w') as fsock: + fsock.write(_CPP_DRIVER) + cxxflags = ['-O3', '-ffast-math', '-I../../src', '-I.', '-fPIC'] + libflags = ['-L../../lib', '-lmodel_sm'] + with open(os.devnull, 'w') as devnull: + rc = subprocess.call( + [self.compiler] + cxxflags + ['-c', '-o', 'driver_cross.o', + 'driver_cross.cpp'], + cwd=pdir, stdout=devnull, stderr=subprocess.STDOUT) + self.assertEqual(rc, 0, 'driver compile failed in %s' % pdir) + rc = subprocess.call( + [self.compiler, '-o', 'driver_cross', 'CPPProcess.o', + 'driver_cross.o'] + libflags, + cwd=pdir, stdout=devnull, stderr=subprocess.STDOUT) + self.assertEqual(rc, 0, 'driver link failed in %s' % pdir) + + out = subprocess.check_output( + ['./driver_cross'] + [str(f) for f in flavor_ids], + cwd=pdir).decode() + values = {} + for match in re.finditer(r'sigmaKin\((\d+)\)\s*=\s*([-\d.eE+]+)', out): + values[int(match.group(1))] = float(match.group(2)) + self.assertEqual(set(values), set(flavor_ids), + 'driver output did not cover every flavor_id: %s' % out) + return values + + # ------------------------------------------------------------------ + def test_qq_gg_crossed_gives_qg_qg(self): + """u u~ > g g crossed by (I=0,J=3) must equal u g > u g at the same + momenta, and the crossed value must differ from the identity one so the + check is non-vacuous.""" + crossed_dir = self._output_standalone_cpp(PROC_QQ_GG, 'qqgg') + reference_dir = self._output_standalone_cpp(PROC_QG_QG, 'qgqg') + + crossed = self._build_and_run(crossed_dir, + [self.IDENTITY, self.CROSS_2_3]) + reference = self._build_and_run(reference_dir, [self.IDENTITY]) + + self.assertAlmostEqual( + crossed[self.CROSS_2_3], reference[self.IDENTITY], + delta=self.tolerance * abs(reference[self.IDENTITY]), + msg='u u~ > g g crossed (%r) != u g > u g identity (%r)' + % (crossed[self.CROSS_2_3], reference[self.IDENTITY])) + # Non-vacuous: the crossing must move the answer, not return the + # identity value. + self.assertNotAlmostEqual( + crossed[self.CROSS_2_3], crossed[self.IDENTITY], places=6, + msg='crossed value equals the identity value; crossing had no ' + 'effect, so the test would pass trivially') + + def test_qg_qg_crossed_gives_qq_gg(self): + """The reverse: u g > u g crossed by (I=0,J=3) must equal u u~ > g g.""" + crossed_dir = self._output_standalone_cpp(PROC_QG_QG, 'qgqg_rev') + reference_dir = self._output_standalone_cpp(PROC_QQ_GG, 'qqgg_rev') + + crossed = self._build_and_run(crossed_dir, + [self.IDENTITY, self.CROSS_2_3]) + reference = self._build_and_run(reference_dir, [self.IDENTITY]) + + self.assertAlmostEqual( + crossed[self.CROSS_2_3], reference[self.IDENTITY], + delta=self.tolerance * abs(reference[self.IDENTITY]), + msg='u g > u g crossed (%r) != u u~ > g g identity (%r)' + % (crossed[self.CROSS_2_3], reference[self.IDENTITY])) + + def test_invalid_overlapping_swap_returns_zero(self): + """An overlapping-swap crossing code (I=2, J=1 here) is marked invalid; + sigmaKin must short-circuit to 0 for it.""" + pdir = self._output_standalone_cpp(PROC_QQ_GG, 'qqgg_inv') + # cross = I*(NEXTERNAL+1)+J = 2*5+1 = 11, flavor_id = 11 (NFLAV=1). + overlapping = 2 * (NEXTERNAL + 1) + 1 + values = self._build_and_run(pdir, [overlapping]) + self.assertEqual(values[overlapping], 0.0, + 'an overlapping-swap code must give a zero matrix ' + 'element, got %r' % values[overlapping]) + + def test_use_crossing_false_drops_the_machinery(self): + """--use_crossing=False must compile and emit no crossing machinery, + while still giving the same uncrossed matrix element.""" + with_dir = self._output_standalone_cpp(PROC_QQ_GG, 'qqgg_on') + without_dir = self._output_standalone_cpp(PROC_QQ_GG, 'qqgg_off', + options='--use_crossing=False') + + on_src = self._cpp_source(with_dir) + off_src = self._cpp_source(without_dir) + for token in ('spincol_cross', 'cross_perm_ic', 'spincol_part', + 'ident_cross', 'flav_use', 'const int ic[]'): + self.assertIn(token, on_src, + '%s should be emitted with crossing on' % token) + self.assertNotIn(token, off_src, + '%s must NOT be emitted with --use_crossing=False' + % token) + + on = self._build_and_run(with_dir, [self.IDENTITY]) + off = self._build_and_run(without_dir, [self.IDENTITY]) + self.assertAlmostEqual( + on[self.IDENTITY], off[self.IDENTITY], + delta=self.tolerance * abs(on[self.IDENTITY]), + msg='the uncrossed matrix element changed when the crossing ' + 'machinery was emitted: %r vs %r' + % (on[self.IDENTITY], off[self.IDENTITY])) + + +class TestStandaloneMg7CrossSymmetry(unittest.TestCase): + """standalone_mg7 (madmatrix / cudacpp CPU-SIMD) must reproduce the crossing. + + Mirror of TestStandaloneCppCrossSymmetry for the data-parallel madmatrix + backend. The extended flavor id encodes cross = id / nflav and flav = id % + nflav (0-based, NFLAV=1 here), so (I=0, J=3) -> cross = 3 -> id = 3. The key + extra check versus the scalar C++ backend is that DIFFERENT events in the + SAME SIMD page may carry DIFFERENT crossings while sharing the reduced + flavor: the per-event momentum permutation must not be vectorized. + + The whole check needs to build and run real C++/SIMD code; skipped (not + failed) if the compiler or the madmatrix build toolchain is unavailable. + """ + + CROSS_2_3 = 3 # cross = I*(NEXTERNAL+1)+J = 0*5+3 = 3, id = cross*NFLAV+flav + CROSS_TO_QQ_GG = 23 # the crossing taking g g > q q~ to q q~ > g g + IDENTITY = 0 + OVERLAP = 2 * (NEXTERNAL + 1) + 1 # cross=11 (I=2,J=1): overlapping swap -> invalid + tolerance = 1e-9 + + debugging = getattr(unittest, 'debug', False) + + def setUp(self): + self.compiler = os.environ.get('CXX', 'g++') + if not shutil.which(self.compiler): + self.skipTest('no C++ compiler (%s) available' % self.compiler) + self.tmpdir = tempfile.mkdtemp(prefix='cross_mg7_') + + def tearDown(self): + if not self.debugging and os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + # ------------------------------------------------------------------ + def _output_standalone_mg7(self, process, name, options='', + out_options='', color_basis=None): + """Write the standalone_mg7 output for `process`, return its P* dir. + + `options` goes on the generate line, `out_options` on the output line. + + color_basis is only passed when the caller compares this output against + another one number-by-number: the colour sum is accumulated in a + different order in each basis, so mixing bases moves the last few digits + (~1e-7 relative) and swamps the 1e-9 tolerance.""" + outdir = pjoin(self.tmpdir, name) + cmd = cmd_interface.MasterCmd() + cmd.no_notification() + cmd.exec_cmd('set automatic_html_opening False') + cmd.exec_cmd('set group_subprocesses False') + cmd.exec_cmd('set apply_flavor_grouping True') + if color_basis: + cmd.exec_cmd('set color_basis %s' % color_basis) + cmd.exec_cmd('import model sm') + cmd.exec_cmd(('generate %s %s' % (process, options)).strip()) + cmd.exec_cmd(('output standalone_mg7 %s -f %s' + % (outdir, out_options)).strip()) + + subproc_root = pjoin(outdir, 'SubProcesses') + pdirs = [pjoin(subproc_root, d) for d in sorted(os.listdir(subproc_root)) + if d.startswith('P') and os.path.isdir(pjoin(subproc_root, d))] + self.assertEqual(len(pdirs), 1, + 'Expected a single subprocess directory for %s, got %s' + % (process, pdirs)) + return pdirs[0] + + def _cpp_source(self, pdir): + with open(pjoin(pdir, 'CPPProcess.cc')) as fsock: + return fsock.read() + + def _patch_and_build(self, pdir): + """Patch the shipped check_sa.cc so it can (a) evaluate the EXTENDED + flavor ids the crossing needs (the shipped cap stops at nmaxflavor) and + (b) demonstrate a per-event mixed-crossing page (env MG_FLVMIX/MG_SAMEMOM), + then build check_sa.exe. Skip if the madmatrix toolchain cannot build.""" + check = pjoin(pdir, 'check_sa.cc') + with open(check) as fsock: + src = fsock.read() + src = src.replace( + 'if( flavorID >= CPPProcess::nmaxflavor )', + 'if( flavorID >= CPPProcess::nmaxflavor * ' + '(unsigned)((CPPProcess::npar+1)*(CPPProcess::npar+1)) )') + src = src.replace( + ' std::vector flvVec( nevt, flavorID );', + ' std::vector flvVec( nevt, flavorID );\n' + ' if( const char* mix = getenv("MG_FLVMIX") ) { unsigned int a=0,b=0; ' + 'sscanf(mix,"%u,%u",&a,&b); for(unsigned int i=0;igetMomentaFinal();', + ' prsk->getMomentaFinal();\n' + ' if( getenv("MG_SAMEMOM") ) for( unsigned int ie=1; ie q q~` is the FOLDED base of its + crossings, and return that P* dir. + + The good-helicity scan only visits the crossings this ME actually + records (cross_recorded / _scanned_crossings), so a crossed matrix + element can only be asked for on a base that folded it in. A bare + `generate u u~ > g g` records nothing, so the crossings below have to + come from a real multiparticle expansion: `pq pq > pq pq` with + pq = g u u~ folds `g u~ > g u~` (cross 3) and `u u~ > g g` (cross 23) + onto the `g g > q q~` base -- the same two directions the standalone + references below compute on their own. + + The trace basis is forced because the sibling all-gluon dir of this + multiprocess cannot be written with the DDM default (unrelated to + crossing: color_flow_decomposition has no single flow per DDM element). + """ + outdir = pjoin(self.tmpdir, name) + cmd = cmd_interface.MasterCmd() + cmd.no_notification() + cmd.exec_cmd('set automatic_html_opening False') + cmd.exec_cmd('set group_subprocesses False') + cmd.exec_cmd('set apply_flavor_grouping True') + cmd.exec_cmd('set color_basis trace') + cmd.exec_cmd('import model sm') + cmd.exec_cmd('define pq = g u u~') + cmd.exec_cmd('generate pq pq > pq pq') + cmd.exec_cmd('output standalone_mg7 %s -f' % outdir) + + subproc_root = pjoin(outdir, 'SubProcesses') + pdirs = [pjoin(subproc_root, d) for d in sorted(os.listdir(subproc_root)) + if d.startswith('P') and 'gg_QQx' in d + and os.path.isdir(pjoin(subproc_root, d))] + self.assertEqual(len(pdirs), 1, + 'expected exactly one folded g g > q q~ dir, got %s' + % pdirs) + demo = pjoin(pdirs[0], 'crossing_demo.dat') + self.assertTrue(os.path.exists(demo), + 'no crossing was folded onto %s' % pdirs[0]) + with open(demo) as fsock: + recorded = [int(tok) for tok in fsock.read().split()] + for wanted in (self.CROSS_2_3, self.CROSS_TO_QQ_GG): + self.assertIn(wanted, recorded, + 'crossing %d is not recorded in %s (got %s); the ' + 'good-hel scan would not have scanned it' + % (wanted, demo, recorded)) + return pdirs[0] + + # ------------------------------------------------------------------ + def test_gg_qqx_crossed_gives_qg_qg(self): + """g g > q q~ crossed by (I=0,J=3) equals g u~ > g u~ at the same momenta + (both 2->2 massless -> identical RAMBO momenta for the same seed). + + The base must be one that FOLDED this crossing in: the good-hel scan + only visits recorded crossings, so a bare `generate u u~ > g g` (which + records none) can no longer be driven with an arbitrary crossing code. + See _output_folded_gg_qqx.""" + crossed = self._output_folded_gg_qqx('ggqqx') + reference = self._output_standalone_mg7(PROC_GQX_GQX, 'gqxgqx', + color_basis='trace') + self._patch_and_build(crossed) + self._patch_and_build(reference) + + crossed_val = self._me(crossed, self.CROSS_2_3) + identity_val = self._me(crossed, self.IDENTITY) + reference_val = self._me(reference, self.IDENTITY) + + self.assertAlmostEqual( + crossed_val, reference_val, + delta=self.tolerance * abs(reference_val), + msg='g g > q q~ crossed (%r) != g u~ > g u~ identity (%r)' + % (crossed_val, reference_val)) + # Non-vacuous: the crossing must move the answer. + self.assertNotAlmostEqual( + crossed_val, identity_val, places=6, + msg='crossed value equals the identity value; crossing had no effect') + + def test_gg_qqx_crossed_gives_qq_gg(self): + """The other recorded direction: g g > q q~ crossed to u u~ > g g.""" + crossed = self._output_folded_gg_qqx('ggqqx_rev') + reference = self._output_standalone_mg7(PROC_QQ_GG, 'qqgg_rev', + color_basis='trace') + self._patch_and_build(crossed) + self._patch_and_build(reference) + reference_val = self._me(reference, self.IDENTITY) + self.assertAlmostEqual( + self._me(crossed, self.CROSS_TO_QQ_GG), reference_val, + delta=self.tolerance * abs(reference_val), + msg='g g > q q~ crossed != u u~ > g g identity') + + def test_per_event_different_cross(self): + """THE point of the SIMD port: within ONE SIMD page, events carrying + DIFFERENT crossings (but the same reduced flavor) each get their own + crossed matrix element. Feed identical momenta to every event, alternate + the crossing per event (even -> identity, odd -> cross 2<->3) and check + each lane independently. + + Both codes used here are RECORDED crossings of the folded base, which is + what the good-hel scan covers (see _output_folded_gg_qqx).""" + pdir = self._output_folded_gg_qqx('ggqqx_perevent') + self._patch_and_build(pdir) + identity_val = self._me(pdir, self.IDENTITY) + crossed_val = self._me(pdir, self.CROSS_2_3) + self.assertNotAlmostEqual(identity_val, crossed_val, places=6, + msg='degenerate: identity == crossed') + mixed = self._event_mes( + pdir, self.IDENTITY, + env={'MG_SAMEMOM': '1', + 'MG_FLVMIX': '%d,%d' % (self.IDENTITY, self.CROSS_2_3)}) + self.assertGreaterEqual(len(mixed), 4, + 'need several events to prove per-event crossing') + for i, me in enumerate(mixed): + expected = identity_val if i % 2 == 0 else crossed_val + self.assertAlmostEqual( + me, expected, delta=self.tolerance * abs(expected) + 1e-12, + msg='event %d (cross %s) got %r, expected %r' + % (i, 'id' if i % 2 == 0 else '2<->3', me, expected)) + + def test_invalid_overlapping_swap_returns_zero(self): + """An overlapping-swap crossing code (I=2, J=1 -> cross 11) is invalid; + the per-event denominator must short-circuit its matrix element to 0.""" + pdir = self._output_standalone_mg7(PROC_QQ_GG, 'qqgg_inv') + self._patch_and_build(pdir) + self.assertEqual(self._me(pdir, self.OVERLAP), 0.0, + 'an overlapping-swap code must give a zero ME') + + def test_use_crossing_false_byte_identical(self): + """--use_crossing=False must emit NO crossing machinery (every crossing + token absent from the generated source) and still give the same + uncrossed matrix element as the crossing-on build. (A full byte-identical + `diff -r` against the pre-feature output was checked by hand; here we + assert the token absence and the numerical invariance.)""" + on_dir = self._output_standalone_mg7(PROC_QQ_GG, 'qqgg_on') + off_dir = self._output_standalone_mg7(PROC_QQ_GG, 'qqgg_off', + options='--use_crossing=False') + on_src = self._cpp_source(on_dir) + off_src = self._cpp_source(off_dir) + for token in ('spincol_cross', 'cross_perm_ic', 'spincol_part', + 'ident_cross', 'xmom', 'ids_base'): + self.assertIn(token, on_src, + '%s should be emitted with crossing on' % token) + self.assertNotIn(token, off_src, + '%s must NOT be emitted with --use_crossing=False' + % token) + self._patch_and_build(on_dir) + self._patch_and_build(off_dir) + self.assertAlmostEqual( + self._me(on_dir, self.IDENTITY), self._me(off_dir, self.IDENTITY), + delta=self.tolerance * abs(self._me(off_dir, self.IDENTITY)), + msg='the uncrossed ME changed when the crossing machinery was emitted') + + def test_use_crossing_false_on_the_output_line(self): + """--use_crossing=False on the OUTPUT line must reach the exporter. + + The flag used to be read by the generate command only, so passing it to + `output` was silently a no-op: the whole crossing machinery (preamble, + per-crossing good-helicity tables, NSF-blended external calls, the + cNGoodMaxCross loop bound) was emitted anyway. Writing the same source + as the generate-time flag is the sharpest statement of the fix, since + that build is the one covered by the tests above. + """ + gen_dir = self._output_standalone_mg7(PROC_QQ_GG, 'qqgg_genoff', + options='--use_crossing=False') + out_dir = self._output_standalone_mg7(PROC_QQ_GG, 'qqgg_outoff', + out_options='--use_crossing=False') + out_src = self._cpp_source(out_dir) + self.assertEqual(self._cpp_source(gen_dir), out_src, + '--use_crossing=False writes a different source on the ' + 'output line than on the generate line') + # Guard the guard: an exporter that never emits the machinery would + # satisfy the equality above with both sides broken. + on_src = self._cpp_source( + self._output_standalone_mg7(PROC_QQ_GG, 'qqgg_defaulton')) + for token in ('spincol_cross', 'cross_perm_ic', 'ident_cross', + 'cNGoodMaxCross'): + self.assertIn(token, on_src, + '%s should be emitted with crossing on' % token) + self.assertNotIn(token, out_src, + '%s must NOT survive --use_crossing=False on the ' + 'output line' % token) + + +class TestCrossingPartition(unittest.TestCase): + """partition_crossing_classes routes each subprocess flavor to a base matrix + element via crossing. A module drops its own matrix.f only when every one + of its flavors is a crossing of a base module's flavor; the basis for sharing + one matrix.f across a base and its crossings in the madevent output.""" + + def _groups(self, proc): + import madgraph.iolibs.group_subprocs as group_subprocs + import madgraph.iolibs.export_v4 as export_v4 + cmd = cmd_interface.MasterCmd() + cmd.run_cmd('import model sm') + cmd.run_cmd('define j = g u u~') + # partition_crossing_classes operates on the FULL (unmerged) matrix-element + # list -- exactly what the madevent output reconstructs from the recorded + # crossings before grouping. Generate unmerged here so the routing has the + # crossed modules to eliminate (the default merge_crossing='record' would + # fold them away at generation, leaving nothing to route). + old = os.environ.get('MG_MERGE_CROSSING') + os.environ['MG_MERGE_CROSSING'] = 'off' + try: + cmd.run_cmd('generate %s' % proc) + finally: + if old is None: + os.environ.pop('MG_MERGE_CROSSING', None) + else: + os.environ['MG_MERGE_CROSSING'] = old + groups = group_subprocs.SubProcessGroup.group_amplitudes( + cmd._curr_amps, 'madevent') + for g in groups: + g.generate_matrix_elements() + return groups, export_v4.ProcessExporterFortran() + + def test_partition_pp_jj(self): + groups, exp = self._groups('p p > j j') + eliminated_any = False + for g in groups: + mes = g.get('matrix_elements') + bases, routing = exp.partition_crossing_classes(mes) + self.assertEqual(len(routing), len(mes)) + for i in range(len(mes)): + self.assertTrue(routing[i], 'a module with no flavors') + for (b, iflav) in routing[i]: + self.assertIn(b, bases) # routes to a real base + self.assertGreaterEqual(iflav, 1) # 1-based FLAV_IDX + if i not in bases: + # an eliminated module never routes back to itself + self.assertNotEqual(b, i) + if len(bases) < len(mes): + eliminated_any = True + self.assertTrue(eliminated_any, + 'no module was eliminated by crossing in p p > j j') + + +class TestCrossingRecycledHelicityUnion(unittest.TestCase): + """crossgroup_helunion.dat must carry the helicity map the RECYCLED optim can + actually realise: the crossing's NSF SIGN flips, with NO slot permutation. + + A crossing base's matrix_optim.f is entered by every member of its class, + so gen_ximprove has to bake it over a helicity set that covers them all. The + trap is that there are two different base->base helicity maps and only one of + them applies here. matrix_optim.f bakes its configs into the HELAS calls + and receives only (PUSE, IC): IC carries the crossing's sign flips, and + NOTHING carries its slot permutation. So the transform it realises is + tau[h][k] = base_row[h][k]*SGN[k], and optim row h is non-zero for the + crossing iff tau[h] is good for the base -- the union to bake is + G_base U tau(G_base). + + Feeding it the other map instead -- the GHREMAP sigma[h][k] = + base_row[h][PERM[k]]*SGN[k], which matrix_orig.f does realise because it + takes NHEL at run time -- looks equally plausible and is silently wrong. It + cost -28.5% on the q q~ > q q~ cross section (5.19e6 -> 3.71e6 pb): the + routed t-channel subprocess needs 4 of the base's 16 rows and the sigma union + supplied 2 of them. Both maps are permutations, both are involutions here, + and both give a set that is invariant under themselves, so nothing about the + set's shape gives the mistake away -- hence this test on the map itself. + + Run-free (no integration): it checks the generation-time map directly, on the + same q q~ > q q~ class whose cross section paid for it. + """ + + PROCESS = 'q q~ > q q~' + + def _class(self, proc): + """(exporter, base matrix element, cross) for a routed crossing of `proc` + that moves at least one leg between the initial and the final state.""" + import madgraph.iolibs.group_subprocs as group_subprocs + import madgraph.iolibs.export_v4 as export_v4 + cmd = cmd_interface.MasterCmd() + # apply_flavor_grouping False is the setting that puts q q~ > q q~ in ONE + # group of three matrix elements with a crossing router -- and the one + # whose cross section the sigma union broke. --no_save keeps it out of the + # user's configuration. + cmd.run_cmd('set apply_flavor_grouping False --no_save') + cmd.run_cmd('import model sm') + cmd.run_cmd('define q = u d s c') + cmd.run_cmd('define q~ = u~ d~ s~ c~') + # As in TestCrossingPartition: route the UNMERGED list, which is what the + # madevent output reconstructs before grouping. + old = os.environ.get('MG_MERGE_CROSSING') + os.environ['MG_MERGE_CROSSING'] = 'off' + try: + cmd.run_cmd('generate %s' % proc) + finally: + if old is None: + os.environ.pop('MG_MERGE_CROSSING', None) + else: + os.environ['MG_MERGE_CROSSING'] = old + groups = group_subprocs.SubProcessGroup.group_amplitudes( + cmd._curr_amps, 'madevent') + exp = export_v4.ProcessExporterFortranMEGroup() + out = [] + for g in groups: + g.generate_matrix_elements() + mes = g.get('matrix_elements') + bases, routing = exp.partition_crossing_classes(mes) + for idep, route in enumerate(routing or []): + if route is None or idep in bases: + continue + for (base_index, iflav) in route: + base_me = mes[base_index] + nflav = len(base_me.get_external_flavors_with_iden()) + out.append((exp, base_me, (iflav - 1) // nflav)) + return out + + def test_helunion_map_is_the_sign_flip_not_the_permutation(self): + classes = self._class(self.PROCESS) + self.assertTrue(classes, + 'no subprocess of %s is routed through a crossing, so ' + 'this test checks nothing' % self.PROCESS) + differs = 0 + for exp, base_me, cross in classes: + bh = [tuple(x) for x in base_me.get_helicity_matrix()] + tables = exp.compute_crossing_tables(base_me) + nx = tables['nexternal'] + perm = [tables['perm'][cross * nx + k] for k in range(nx)] + sgn = [tables['ic'][cross * nx + k] for k in range(nx)] + + tau = exp._crossgroup_base_helsignmap(base_me, cross) + self.assertIsNotNone( + tau, 'tau is not a permutation for cross %d: the helicity states ' + 'of the crossed legs must be closed under negation' % cross) + + # The defining property: tau moves NO helicity between slots. Row + # tau[h] is row h with the crossed legs' helicity negated in place. + # Baking sigma instead breaks exactly this. + for h, row in enumerate(bh, 1): + self.assertEqual( + bh[tau[h - 1] - 1], + tuple(row[k] * sgn[k] for k in range(nx)), + 'crossgroup_helunion row %d of cross %d is not the pure ' + 'sign flip: a recycled optim cannot apply a slot ' + 'permutation' % (h, cross)) + + # ... and for a crossing that does move legs across, sigma is a + # genuinely different map, so getting this wrong is not academic. + sigma = exp._helicity_row_permutation( + *exp._crossed_helicity_configs(base_me, cross)) + if perm != list(range(nx)) and sigma is not None and sigma != tau: + differs += 1 + self.assertTrue( + differs, + 'sigma and tau coincide for every crossing of %s, so this process ' + 'cannot tell the two apart -- pick one that can' % self.PROCESS) + + +class TestCrossingConfigMap(unittest.TestCase): + """_crossgroup_configmap must send a crossed subprocess's multi-channel + CONFIG to the base diagram of the same topology under the crossing. + + The dependent's genps samples its OWN config's poles, but the shared base + SMATRIX enhances AMP2(channel) in the BASE's diagram numbering, so `channel` + has to be translated on the way in. Any bijective pairing still sums to the + right integral -- what a wrong pairing wrecks is the importance sampling: + each channel's weight ends up on the wrong amplitude, so the variance blows + up and the error madevent quotes stops meaning anything. + + That failure is invisible from the outside. The function returns the + IDENTITY when it cannot match the diagrams, which is indistinguishable from + the common and perfectly legitimate case of a crossing-covariant numbering; + the matrix elements still agree to every digit, and only the stability of + the cross section suffers. So it is checked here, on the map itself: + whatever base diagram a config is routed to must carry the same internal + propagators as the dependent's own diagram, once the crossing has relabelled + the legs. + + Both crossing paths call it -- the within-group router (Track A, + write_matrix_router_file) and the cross-group auto_dsig fill (Track B, + _dsig_crossgroup_fills) -- so both are covered. + """ + + @staticmethod + def _canon(sub, allset): + return min(sub, allset - sub, key=lambda x: (len(x), sorted(x))) + + @classmethod + def _propagators(cls, me): + """Per diagram number, its internal propagators as a frozenset of + (canonical external-leg subset, |PDG|). + + Recomputed here rather than taken from the exporter's own topology + helper on purpose: this is the reference the map is judged against, so + it must not move when that helper does. A propagator is pinned down by + the external legs whose momenta flow through it -- a subset and its + complement being the same propagator, hence the canonical choice -- plus + the particle running in it. |PDG| and not PDG, because crossing a leg + reverses the flow through every propagator on its path and so conjugates + them; the magnitude is what survives the relabelling. + """ + nx, nini = me.get_nexternal_ninitial() + model = me.get('processes')[0].get('model') + npdg = model.get_first_non_pdg() + allset = frozenset(range(1, nx + 1)) + out = {} + for diag in me.get('diagrams'): + sch, tch = diag.get('amplitudes')[0].get_s_and_t_channels( + nini, model, npdg) + ext = {i: frozenset([i]) for i in range(1, nx + 1)} + props = set() + for vert in list(sch) + list(tch): + legs = vert.get('legs') + daughters = [l.get('number') for l in legs[:-1]] + sub = frozenset().union(*[ext.get(d, frozenset([d])) + for d in daughters]) if daughters \ + else frozenset() + ext[legs[-1].get('number')] = sub + # the last t-channel 'propagator' is a single external leg + if len(cls._canon(sub, allset)) >= 2: + props.add((cls._canon(sub, allset), + abs(legs[-1].get('id')))) + out[diag.get('number')] = frozenset(props) + return out, nx + + def _routed_pairs(self, procs, defs=(), unfold=False): + """Every (track, dep_me, base_me, crossing) a generation routes through + a shared matrix element, collected from BOTH crossing paths. + + unfold=True sets MG_MERGE_CROSSING=off so the crossed modules are kept + instead of folded away at generation -- that is what leaves within-group + (Track A) routers to find. With the default 'record' the same processes + come back as whole crossed GROUPS and go through Track B instead, so the + two settings exercise different code and neither subsumes the other. + """ + import madgraph.iolibs.group_subprocs as group_subprocs + import madgraph.iolibs.export_v4 as export_v4 + cmd = cmd_interface.MasterCmd() + cmd.run_cmd('import model sm') + for definition in defs: + cmd.run_cmd(definition) + old = os.environ.get('MG_MERGE_CROSSING') + if unfold: + os.environ['MG_MERGE_CROSSING'] = 'off' + try: + for i, proc in enumerate(procs): + cmd.run_cmd('%s %s' % ('generate' if i == 0 else 'add process', + proc)) + finally: + if unfold: + if old is None: + os.environ.pop('MG_MERGE_CROSSING', None) + else: + os.environ['MG_MERGE_CROSSING'] = old + groups = group_subprocs.SubProcessGroup.group_amplitudes( + cmd._curr_amps, 'madevent') + for group in groups: + group.generate_matrix_elements() + exp = export_v4.ProcessExporterFortranMEGroup() + exp.opt['use_crossing'] = True + + pairs = [] + + def add(track, dep, base, iflav): + nflav_base = len(base.get_external_flavors_with_iden()) + pairs.append((track, dep, base, (iflav - 1) // nflav_base)) + + for group in groups: # Track A, within-group + mes = group.get('matrix_elements') + bases, routing = exp.partition_crossing_classes(mes) + for i, route in enumerate(routing): + if i in bases: + continue + for (b, iflav) in route: + add('A', mes[i], mes[b], iflav) + for (gi, mi), cg in exp.compute_crossgroup_routing(groups).items(): + dep = groups[gi].get('matrix_elements')[mi] + for iflav in cg['flav_idx']: # Track B, cross-group + add('B', dep, cg['base_me'], iflav) + + # the flavors of one module usually share a (base, crossing) + seen, out = set(), [] + for pair in pairs: + key = (pair[0], id(pair[1]), id(pair[2]), pair[3]) + if key not in seen: + seen.add(key) + out.append(pair) + return exp, out + + def _check(self, procs, defs=(), unfold=False, min_pairs=1): + exp, pairs = self._routed_pairs(procs, defs=defs, unfold=unfold) + checked = 0 + for (track, dep, base, cross) in pairs: + ngraphs = len(dep.get('diagrams')) + if len(base.get('diagrams')) != ngraphs: + # both call sites leave a mismatched diagram count alone + continue + label = 'Track %s: %s <- %s (crossing %d)' % ( + track, dep.get('processes')[0].shell_string(), + base.get('processes')[0].shell_string(), cross) + cmap = exp._crossgroup_configmap(dep, base, cross) + self.assertEqual( + sorted(cmap), list(range(1, ngraphs + 1)), + '%s: the config map is not a permutation of the %d diagrams' + % (label, ngraphs)) + dprops, nx = self._propagators(dep) + bprops, _ = self._propagators(base) + perm = exp.get_crossing_permutation(cross, nx)[0] + d2b = {k + 1: perm[k] + 1 for k in range(nx)} + allset = frozenset(range(1, nx + 1)) + fmt = lambda ps: sorted((sorted(s), pdg) for (s, pdg) in ps) + for d in range(1, ngraphs + 1): + want = frozenset( + (self._canon(frozenset(d2b[l] for l in sub), allset), pdg) + for (sub, pdg) in dprops[d]) + self.assertEqual( + bprops[cmap[d - 1]], want, + '%s:\n config %d is routed to base diagram %d, but that ' + 'diagram is not this one crossed.\n' + ' base diagram %d propagators: %s\n' + ' dependent diagram %d crossed: %s\n' + ' (a silent fallback to the identity map looks exactly ' + 'like this; it costs cross-section stability, not the ' + 'cross section itself)' + % (label, d, cmap[d - 1], cmap[d - 1], + fmt(bprops[cmap[d - 1]]), d, fmt(want))) + checked += 1 + self.assertGreaterEqual( + checked, min_pairs, + 'expected at least %d routed subprocess(es) to check, got %d -- ' + 'the generation no longer exercises the crossing router' + % (min_pairs, checked)) + + def test_configmap_cross_group(self): + """Track B. g g > t t~ u u~ and its crossing u u~ > t t~ g g land in two + separate groups, so the second routes to the first's matrix element + through the cross-group path. Both have 36 diagrams, two of which share + a pure leg-subset topology and are told apart only by the particle in + the propagator: a gluon, versus the auxiliary field that carries the + four-gluon vertex. Matching on the leg subsets alone collapses those two + into one signature, the pairing stops being a bijection, and the whole + map silently degrades to the identity -- which mis-pairs EVERY channel, + not just the ambiguous two. + """ + self._check(['g g > t t~ u u~', 'u u~ > t t~ g g']) + + def test_configmap_within_group(self): + """Track A. The same ambiguity reaches the within-group router: with the + crossed modules kept rather than folded away, p p > t t~ j j routes + g Q~ > t t~ g Q~ to g Q > t t~ g Q, again 36 diagrams with the same + gluon / four-gluon-auxiliary pair among them. + """ + self._check(['p p > t t~ j j'], defs=['define j = g u u~'], + unfold=True) + + def test_configmap_stays_correct_where_it_already_worked(self): + """Control: p p > j j routes two modules and its diagrams have always + been matched cleanly. Sharpening the topology signature enough to split + the ambiguous pair above must not start REJECTING these -- an invariant + that is not crossing-covariant would fail here. + """ + self._check(['p p > j j'], defs=['define j = g u u~'], unfold=True, + min_pairs=2) + + def test_unmatchable_diagrams_are_reported(self): + """The fallback must say so. Nothing downstream can detect a degraded + config map -- it is a legal bijection that merely samples badly -- so the + one chance to notice is at generation. + + Fed two processes that are not crossings of each other (a synthetic + stand-in for any pair the topology signature cannot match, since the + physical pairs are all matched again now), the map must come back as the + identity AND name both matrix elements. + """ + import madgraph.core.helas_objects as helas_objects + import madgraph.iolibs.export_v4 as export_v4 + mes = [] + for proc in ('u u~ > t t~ g g', 'u u~ > t t~ u u~'): + cmd = cmd_interface.MasterCmd() + cmd.exec_cmd('import model sm', printcmd=False) + cmd.exec_cmd('generate %s' % proc, printcmd=False) + mes.append(helas_objects.HelasMultiProcess( + cmd._curr_amps).get_matrix_elements()[0]) + dep, base = mes + exp = export_v4.ProcessExporterFortranMEGroup() + with self.assertLogs('madgraph.export_v4', level='WARNING') as caught: + cmap = exp._crossgroup_configmap(dep, base, 0) + self.assertEqual(cmap, list(range(1, len(dep.get('diagrams')) + 1)), + 'an unmatchable pair must fall back to the identity') + said = '\n'.join(caught.output) + for name in ('uux_ttxgg', 'uux_ttxuux'): + self.assertIn(name, said, + 'the fallback warning does not name %s:\n%s' + % (name, said)) + + +class TestCrossingFlavorRepresentative(unittest.TestCase): + """The PDG signature reported for a flavor index must be the signature of the + flavor class that index actually selects. + + compute_crossing_pdg_entries reads the PDG table of _build_flav_pdg_tables, + which has ONE ROW PER PHYSICAL FLAVOR COMBINATION, while its flavor index + counts the coupling-equivalence classes of get_external_flavors_with_iden() + -- the FLAVOR table the backends read is built from each class's + representative flav[0]. Row f is the representative of class f only while the + leading rows happen to BE the representatives. ``p p > j j`` with the + crossings unfolded has ``Q Q~ > Q Q~``, whose three classes sit at rows 0, 1 + and 4: taking the ordinal names ``q q~ > q'' q~''`` (a member of class 1) for + the class that is really ``q q~' > q q~'``. The routing decision, the + recorded-crossing intersection behind crossed_flavors.dat and the C++ + demo_pdg table all match on exactly this signature. + + ``allowed_flavors_with_iden_pdgs`` is the independent oracle here: it carries + the class representative's PDGs directly and shares no code with the table + indexing under test.""" + + def _mes(self, proc): + import madgraph.iolibs.group_subprocs as group_subprocs + import madgraph.iolibs.export_v4 as export_v4 + cmd = cmd_interface.MasterCmd() + cmd.run_cmd('import model sm') + # The default multi-flavor j is the point: with a single quark flavor + # every class is a single row and the misalignment cannot appear. + # Unfolded (MG_MERGE_CROSSING=off) so the crossed modules still exist, + # exactly as TestCrossingPartition does. + old = os.environ.get('MG_MERGE_CROSSING') + os.environ['MG_MERGE_CROSSING'] = 'off' + try: + cmd.run_cmd('generate %s --use_crossing=True' % proc) + finally: + if old is None: + os.environ.pop('MG_MERGE_CROSSING', None) + else: + os.environ['MG_MERGE_CROSSING'] = old + groups = group_subprocs.SubProcessGroup.group_amplitudes( + cmd._curr_amps, 'madevent') + mes = [] + for g in groups: + g.generate_matrix_elements() + mes.extend(g.get('matrix_elements')) + return mes, export_v4.ProcessExporterFortran() + + def test_identity_signature_is_the_class_representative(self): + mes, exp = self._mes('p p > j j') + self.assertTrue(mes) + for me in mes: + _classes, class_pdgs = \ + me.get_external_flavors_with_iden(return_pdgs=True) + expected = [tuple(members[0]) for members in class_pdgs] + got = [pdg for (_idx, cross, _flav, pdg) in + exp.compute_crossing_pdg_entries(me) if cross == 0] + self.assertEqual( + got, expected, + 'identity signatures of %s do not name its flavor classes' + % me.get('processes')[0].shell_string()) + + def test_fixture_exercises_a_misaligned_matrix_element(self): + """Guard the test above from going toothless: if grouping ever stops + producing a matrix element whose classes are NOT the leading rows, the + assertion holds trivially and no longer covers the defect.""" + mes, exp = self._mes('p p > j j') + misaligned = [me for me in mes + if exp._flavor_rep_rows(me) + != list(range(len(exp._flavor_rep_rows(me))))] + self.assertTrue( + misaligned, + 'no matrix element with a non-ordinal class representative; ' + 'the representative test no longer covers the ordinal bug') + + +class TestCrossingReorderCandidates(unittest.TestCase): + """find_reorder_candidates names the modules that keep their own matrix.f + only because one flavor class is listed with its final legs the other way + round -- the modules a generation-time split could free. + + ``p p > j j`` unfolded has the canonical example: ``Q Q~ > Q Q~`` routes two + of its three classes to ``Q Q > Q Q`` as generated, and is held back by the + flavor-changing annihilation ``q q~ > q' q~'``, which the crossing delivers + with the two light legs swapped. The module cannot relabel itself out of it + (its leg pattern is shared by every row) and no single ordering suits all + three classes, so the class has to be peeled into its own subprocess. + + This is analysis only: the second test pins that calling it does not move the + routing, so it can be trusted not to change any output.""" + + def _mes(self, proc): + import madgraph.iolibs.group_subprocs as group_subprocs + import madgraph.iolibs.export_v4 as export_v4 + cmd = cmd_interface.MasterCmd() + cmd.run_cmd('import model sm') + old = os.environ.get('MG_MERGE_CROSSING') + os.environ['MG_MERGE_CROSSING'] = 'off' + try: + cmd.run_cmd('generate %s --use_crossing=True' % proc) + finally: + if old is None: + os.environ.pop('MG_MERGE_CROSSING', None) + else: + os.environ['MG_MERGE_CROSSING'] = old + groups = group_subprocs.SubProcessGroup.group_amplitudes( + cmd._curr_amps, 'madevent') + out = [] + for g in groups: + g.generate_matrix_elements() + mes = g.get('matrix_elements') + if len(mes) > 1: + out.append(mes) + return out, export_v4.ProcessExporterFortran() + + def test_qqx_is_held_back_by_one_class(self): + groups, exp = self._mes('p p > j j') + found = [] + for mes in groups: + names = [m.get('processes')[0].shell_string() for m in mes] + bases, _routing = exp.partition_crossing_classes(mes) + for i, peel in exp.find_reorder_candidates(mes).items(): + found.append((names[i], len(peel), peel)) + # a candidate must be a module that currently keeps its own ME + self.assertIn(i, bases, + '%s is not a base; nothing to free' % names[i]) + nx, nini = mes[i].get_nexternal_ninitial() + for _flav0, sigma, base_index, iflav in peel: + # sigma permutes FINAL legs only -- the beams are not + # interchangeable for the PDF + self.assertEqual(sorted(sigma), list(range(nx))) + self.assertEqual(list(sigma[:nini]), list(range(nini))) + self.assertNotEqual(tuple(sigma), tuple(range(nx)), + 'a candidate needs a real reorder') + self.assertIn(base_index, bases) + self.assertGreaterEqual(iflav, 1) + self.assertTrue(found, 'no reorder candidate found in p p > j j; the ' + 'fixture no longer covers the split case') + self.assertTrue(any(n.endswith('QQx_QQx') for n, _c, _p in found), + 'expected Q Q~ > Q Q~ among the candidates: %s' % found) + + def test_detection_does_not_move_the_routing(self): + """It is analysis: asking must not change what routing decides.""" + groups, exp = self._mes('p p > j j') + for mes in groups: + before = exp.partition_crossing_classes(mes) + exp.find_reorder_candidates(mes) + after = exp.partition_crossing_classes(mes) + self.assertEqual(before, after) + + +class TestMadeventCrossingHelicity(unittest.TestCase): + """End-to-end regression for the crossed-helicity label written to the LHE. + + The madevent helicity path is the phase-4 GET_NHEL decoder plus the phase-5 + runtime crossing encode (the base-selected helicity code is relabelled into + the dependent's canonical code by permuting its mixed-radix digits with the + crossing permutation, replacing the old DSIG_XGHEL / router HELMAP tables). + p p > w+ j is the sharp test: its crossed subprocesses (u g > w+ d, ...) put + the W+ -- a massive vector with THREE helicity states -- in a leg the + crossing moved, so a bug in the relabel scrambles the W+ helicity. The W+ + polarisation is physically CHIRAL (asymmetric transverse states) with a + populated longitudinal (0) state; a scrambled relabel typically reads a + quark leg's +-1 into the W+ slot and destroys that structure. + + This runs a full (small) madevent generation, so it is a slow test. + """ + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix='cross_mev_hel_') + + def tearDown(self): + if os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + def test_w_helicity_asymmetry_ppwj(self): + from madgraph import MG5DIR + from madgraph.various import lhe_parser + outdir = pjoin(self.tmpdir, 'ppwj') + card = pjoin(self.tmpdir, 'cmd.txt') + with open(card, 'w') as f: + f.write('generate p p > w+ j\n' + 'output madevent %s -f\n' + 'launch\n' + 'set nevents 1000\n' + 'set iseed 777\n' % outdir) + subprocess.call([sys.executable, pjoin(MG5DIR, 'bin', 'mg5_aMC'), card]) + + lhe = pjoin(outdir, 'Events', 'run_01', 'unweighted_events.lhe.gz') + self.assertTrue(os.path.isfile(lhe), + 'madevent produced no LHE file (%s)' % lhe) + + counts = {-1: 0, 0: 0, 1: 0} + nevt = 0 + for event in lhe_parser.EventFile(lhe): + for part in event: + if part.pid == 24 and part.status == 1: # the final-state W+ + hel = int(round(part.helicity)) + self.assertIn(hel, (-1, 0, 1), + 'W+ has undefined/non-physical helicity %r -- ' + 'helicity output off or scrambled' + % part.helicity) + counts[hel] += 1 + nevt += 1 + total = sum(counts.values()) + self.assertGreater(nevt, 100, 'too few events generated (%d)' % nevt) + self.assertEqual(total, nevt, 'expected exactly one final-state W+ per ' + 'event (got %d W+ in %d events)' % (total, nevt)) + + fm, f0, fp = (counts[-1] / total, counts[0] / total, counts[1] / total) + # All three W+ helicity states populated, incl. the longitudinal 0. + for hel in (-1, 0, 1): + self.assertGreater(counts[hel], 0, + 'W+ helicity %d not populated: %s' % (hel, counts)) + # The two transverse states are chirally asymmetric. + self.assertGreater(abs(fm - fp), 0.05, + 'W+ transverse helicities not chirally asymmetric: %s' + % counts) + # The longitudinal fraction sits in a physical window (a scrambled + # relabel collapses or inflates it out of this range). + self.assertTrue(0.02 < f0 < 0.45, + 'W+ longitudinal fraction unphysical: %.3f (%s)' + % (f0, counts)) + + +class TestMadeventDecayChainCrossing(unittest.TestCase): + """End-to-end: a decay-chain crossing routed through the base's SMATRIX in + madevent gives the same cross section as an independent build. + + ``p p > w+ j, w+ > j j`` crosses the light partons of the production while + the ``w+ > j j`` decay block rides along on the top-level W+; the crossed + subprocesses (``g q~ > w+ q~``, ...) reuse the base matrix element through + the crossing-aware SMATRIX (matrix2_router dispatches to SMATRIX1 with a + crossed FLAV_IDX and rebuilds the crossed, resonance-level denominator). A + ``--use_crossing=False`` build computes every subprocess independently + instead. With the same seed the routed and the independent integration must + agree -- a wrong crossed denominator, a split decay block, or a mis-routed + flavor would move the cross section. + + Runs two full (small) madevent generations, so it is slow. + """ + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix='cross_mev_dc_') + + def tearDown(self): + if os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + def _xsec(self, options, name): + from madgraph import MG5DIR + outdir = pjoin(self.tmpdir, name) + card = pjoin(self.tmpdir, 'cmd_%s.txt' % name) + with open(card, 'w') as fsock: + fsock.write('generate p p > w+ j, w+ > j j %s\n' + 'output madevent %s -f\n' + 'launch\n' + 'set nevents 1000\n' + 'set iseed 424242\n' % (options, outdir)) + subprocess.call([sys.executable, pjoin(MG5DIR, 'bin', 'mg5_aMC'), card]) + results = pjoin(outdir, 'SubProcesses', 'results.dat') + self.assertTrue(os.path.isfile(results), + 'madevent produced no results (%s)' % results) + with open(results) as fsock: + # results.dat: cross-section, abs error, ... (in pb). + fields = fsock.readline().split() + return float(fields[0]), float(fields[1]) + + def test_decay_chain_crossing_xsec_matches(self): + crossed, err_c = self._xsec('', 'on') + independent, err_i = self._xsec('--use_crossing=False', 'off') + self.assertGreater(independent, 0.0, + 'independent build gives a null cross section') + scale = max(abs(crossed), abs(independent), 1e-99) + self.assertLessEqual( + abs(crossed - independent) / scale, 1e-2, + 'p p > w+ j, w+ > j j crossing-routed xsec %r +- %r disagrees with ' + 'the independent build %r +- %r' + % (crossed, err_c, independent, err_i)) + + +class TestMadeventInclusiveCrossingXsec(unittest.TestCase): + """End-to-end: routing crossed subprocesses through a shared base matrix + element must not move the INCLUSIVE cross section. + + The plain (no decay chain) counterpart of TestMadeventDecayChainCrossing, + and the configuration where the crossing router has the most to get wrong. + With flavor grouping ``p p > t t~ j j`` collapses to five subprocess groups, + and two of them -- gq_ttxgq and qq_ttxqq -- are served by a cross-GROUP + router: they carry a ``matrix_router.f`` (plus ``crossgroup_helunion.dat`` + and ``crossgroup.mk``) instead of their own matrix element, i.e. their + flavors are evaluated by ANOTHER group's matrix element under a crossing, + over the helicity union of the two groups. Nothing else in the suite + integrates that path -- Track B is exercised at the matrix-element level + only. + + The summed cross section is what catches it. A wrong crossed averaging + denominator, multi-channel row or good-helicity union leaves the per-flavor + matrix elements agreeing (those are compared in + TestStandaloneMadeventMatrixElementConsistency) while moving the integral, + which is exactly how the routed groups lost ~29% before the helicity union + was fed to the Track-A routers. + + Runs two full madevent integrations, but the flavor grouping keeps them + small: ~40s each. Reference numbers at the time of writing -- + 416.6 +- 2.4 pb routed vs 413.7 +- 2.6 pb independent, i.e. 0.8 sigma apart. + """ + + PROCESS = 'p p > t t~ j j' + NEVENTS = 1000 + SEED = 191919 + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix='cross_mev_ttjj_') + + def tearDown(self): + if os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + def _generate_and_integrate(self, options, name): + """Generate + integrate the process; return (outdir, xsec, error) in pb.""" + from madgraph import MG5DIR + outdir = pjoin(self.tmpdir, name) + card = pjoin(self.tmpdir, 'cmd_%s.txt' % name) + with open(card, 'w') as fsock: + fsock.write('generate %s %s\n' + 'output madevent %s -f\n' + 'launch\n' + 'set nevents %d\n' + 'set iseed %d\n' + % (self.PROCESS, options, outdir, + self.NEVENTS, self.SEED)) + subprocess.call([sys.executable, pjoin(MG5DIR, 'bin', 'mg5_aMC'), card]) + results = pjoin(outdir, 'SubProcesses', 'results.dat') + self.assertTrue( + os.path.isfile(results), + 'madevent produced no results for %s (%s)' + % (options or 'the default (crossing on) build', results)) + with open(results) as fsock: + # results.dat: cross-section, abs error, ... (in pb). + fields = fsock.readline().split() + return outdir, float(fields[0]), float(fields[1]) + + @staticmethod + def _routed_groups(outdir): + """The subprocess groups served by a cross-group crossing router.""" + subproc = pjoin(outdir, 'SubProcesses') + routed = [] + for name in sorted(os.listdir(subproc)): + pdir = pjoin(subproc, name) + if not name.startswith('P') or not os.path.isdir(pdir): + continue + if any(re.match(r'matrix\d+_router\.f$', entry) + for entry in os.listdir(pdir)): + routed.append(name) + return routed + + def test_inclusive_crossing_xsec_matches(self): + crossed_dir, crossed, err_c = self._generate_and_integrate('', 'on') + independent_dir, independent, err_i = self._generate_and_integrate( + '--use_crossing=False', 'off') + + # Guard the premise: the default build must really evaluate some group + # through another group's matrix element, and the reference build must + # not -- otherwise this compares two identical builds and can never fail. + routed = self._routed_groups(crossed_dir) + self.assertTrue( + routed, 'no subprocess group is served by a crossing router, so the ' + 'comparison would be between two identical builds') + self.assertEqual( + self._routed_groups(independent_dir), [], + '--use_crossing=False still emitted a crossing router') + + self.assertGreater(independent, 0.0, + 'the independent build gives a null cross section') + # Same seed and the same channels, so the two runs must agree well + # inside their combined statistical error; the 1% floor absorbs the grid + # noise the different routing can introduce. + tolerance = max(1e-2 * independent, 3.0 * math.hypot(err_c, err_i)) + self.assertLessEqual( + abs(crossed - independent), tolerance, + '%s crossing-routed xsec %r +- %r disagrees with the independent ' + 'build %r +- %r (groups routed through a crossing: %s)' + % (self.PROCESS, crossed, err_c, independent, err_i, + ', '.join(routed))) + + +class TestColorFlowCode(unittest.TestCase): + """The canonical COLOUR-FLOW code, the colour analogue of the canonical + helicity code. + + A colour flow is labelled by its connectivity once the INITIAL-state legs + swap their colour/anticolour roles (the LHE convention runs initial-state + colour lines 'through', so without that flip a label sits in the same slot + on two legs and the flow is not a colour<->anticolour bijection). Ordering + the colour and anticolour slots by leg, digit i is the anticolour slot that + colour slot i connects to and code = sum_i digit_i * N^i. + + Two properties make it usable as an event label and make crossing + transparent (both verified here): + (a) every basis flow is a clean bijection, i.e. it encodes at all; + (b) the code is INJECTIVE over a process's colour basis, so the code + identifies the flow and no per-process flow table is needed. + Crossing-covariance (relabelling legs by the crossing permutation carries a + base flow's code onto the crossed process's own flow code) is exercised by + the crossing machinery itself: _router_colmap matches flows through the + same _color_flow_canon helper. + """ + + # (process, expected number of colour flows) -- includes g g > g g g, whose + # 24 flows over 5 colour slots is the widest case that stays quick. + PROCS = [('u u~ > g g', 2), ('g g > g g', 6), ('u u~ > u u~', 2), + ('g g > t t~', 2), ('u u~ > g g g', 6), ('g g > g g g', 24)] + + def test_color_flow_code_bijective_and_injective(self): + import madgraph.core.helas_objects as helas_objects + import madgraph.iolibs.export_v4 as export_v4 + exp = export_v4.ProcessExporterFortranMEGroup.__new__( + export_v4.ProcessExporterFortranMEGroup) + checked = 0 + for proc, nflow_exp in self.PROCS: + cmd = cmd_interface.MasterCmd() + cmd.exec_cmd('generate %s' % proc, printcmd=False) + me = helas_objects.HelasMultiProcess(cmd._curr_amps) + for m in me.get('matrix_elements'): + if not m.get('color_basis'): + continue + codes = exp._color_flow_codes(m) + # (a) every flow is a clean colour<->anticolour bijection + self.assertIsNotNone( + codes, '%s: a colour flow is not a clean bijection -- the ' + 'initial-state colour/anticolour flip is required' % proc) + self.assertEqual(len(codes), nflow_exp, + '%s: expected %d colour flows, got %d' + % (proc, nflow_exp, len(codes))) + # (b) the code identifies the flow + self.assertEqual(len(set(codes)), len(codes), + '%s: colour-flow codes collide: %s' + % (proc, codes)) + checked += 1 + self.assertTrue(checked, 'no coloured matrix element was checked') + + def test_color_flow_code_round_trip(self): + """decode(code(flow)) reproduces the flow's canonical connectivity, and + the slot structure is FLOW-INDEPENDENT (it is process data, fixed by the + colour representations). Together these are what allow the colour tags + to be rebuilt from the code alone instead of read out of the generated + ICOLUP table -- the step this encoding is aiming at. + """ + import madgraph.core.helas_objects as helas_objects + import madgraph.iolibs.export_v4 as export_v4 + exp = export_v4.ProcessExporterFortranMEGroup.__new__( + export_v4.ProcessExporterFortranMEGroup) + checked = 0 + for proc, _nflow in self.PROCS: + cmd = cmd_interface.MasterCmd() + cmd.exec_cmd('generate %s' % proc, printcmd=False) + me = helas_objects.HelasMultiProcess(cmd._curr_amps) + for m in me.get('matrix_elements'): + if not m.get('color_basis'): + continue + states = [l.get('state') for l in + m.get('processes')[0].get_legs_with_decays()] + slots = None + for flow in exp._module_color_flows(m): + conns = exp._color_flow_canon(flow, states) + this = exp._color_flow_slots(conns) + if slots is None: + slots = this + # the slot structure must not depend on the flow + self.assertEqual(this, slots, + '%s: slot structure varies between flows ' + '(%s vs %s)' % (proc, this, slots)) + code = exp._color_flow_code(conns) + self.assertIsNotNone(code, '%s: flow did not encode' % proc) + back = exp._color_flow_decode(code, slots[0], slots[1]) + self.assertEqual(back, conns, + '%s: code %d does not round-trip\n got %s' + '\n want %s' + % (proc, code, sorted(back), sorted(conns))) + checked += 1 + self.assertTrue(checked, 'no colour flow was round-tripped') + + +class TestMadeventColorFlowRatio(unittest.TestCase): + """End-to-end guard on the COLOUR written to the LHE, for u u~ > u u~. + + Every event's colour tags must form a clean colour<->anticolour bijection + once the initial-state legs swap roles (the canonical form the colour-flow + code is built on): each colour label is matched by exactly one anticolour + label. That is the colour analogue of "the helicity is one of the physical + states", and it is what breaks first if the colour flow written to the event + is ever rebuilt wrongly -- e.g. when the tags start being decoded from the + canonical colour-flow code instead of read from the ICOLUP table. + + u u~ > u u~ is chosen deliberately: its two colour flows are STRONGLY + asymmetric (~98/2), so the test can pin down WHICH flow is which. A process + whose flows are related by a symmetry -- g g > t t~ splits 50/50 -- would + pass just as happily with the two flow labels SWAPPED, which is exactly the + bug this is meant to catch. Here a swap inverts 98/2 into 2/98. + + The dominant flow is identified topologically (do the colour connections + stay inside the initial/final groups, or cross between them?) rather than by + raw leg indices, so the check does not depend on leg ordering. Runs a small + madevent generation, so it is a slow test. + """ + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix='cross_col_ratio_') + + def tearDown(self): + if os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + @staticmethod + def _canon(parts): + """{colour label: [legs]}, {anticolour label: [legs]} with initial-state + legs swapping the two roles.""" + col, anti = {}, {} + for i, p in enumerate(parts): + c, a = int(p.color1), int(p.color2) + if p.status == -1: + c, a = a, c + if c: + col.setdefault(c, []).append(i) + if a: + anti.setdefault(a, []).append(i) + return col, anti + + def test_color_flow_ratio_uux_uux(self): + from madgraph import MG5DIR + from madgraph.various import lhe_parser + outdir = pjoin(self.tmpdir, 'uux') + card = pjoin(self.tmpdir, 'cmd.txt') + with open(card, 'w') as f: + f.write('generate u u~ > u u~\n' + 'output madevent %s -f\n' + 'launch\n' + 'set nevents 2000\n' + 'set iseed 909\n' % outdir) + subprocess.call([sys.executable, pjoin(MG5DIR, 'bin', 'mg5_aMC'), card]) + + lhe = pjoin(outdir, 'Events', 'run_01', 'unweighted_events.lhe.gz') + self.assertTrue(os.path.isfile(lhe), + 'madevent produced no LHE file (%s)' % lhe) + + nevt = 0 + sigs = {} + for event in lhe_parser.EventFile(lhe): + parts = [p for p in event] + nevt += 1 + col, anti = self._canon(parts) + # (1) structure: a perfect colour <-> anticolour matching + self.assertEqual(set(col), set(anti), + 'colour labels do not pair with anticolour labels ' + '(event %d): %s vs %s' % (nevt, sorted(col), + sorted(anti))) + self.assertTrue(col, 'event %d carries no colour at all' % nevt) + for lbl, legs in col.items(): + self.assertEqual(len(legs), 1, + 'colour label %s appears on %d legs (event %d)' + % (lbl, len(legs), nevt)) + self.assertEqual(len(anti[lbl]), 1, + 'anticolour label %s appears on %d legs ' + '(event %d)' % (lbl, len(anti[lbl]), nevt)) + # topological signature of the flow: does each colour connection + # stay inside the initial / final group, or cross between them? + ini = set(i for i, p in enumerate(parts) if p.status == -1) + sig = tuple(sorted(('I' if c in ini else 'F') + + ('I' if a in ini else 'F') + for c, a in ((col[l][0], anti[l][0]) + for l in col))) + sigs[sig] = sigs.get(sig, 0) + 1 + + self.assertGreater(nevt, 100, 'too few events generated (%d)' % nevt) + # (2) exactly the two expected colour topologies + self.assertEqual(set(sigs), {('FF', 'II'), ('FI', 'IF')}, + 'unexpected colour-flow topologies: %s' % sigs) + same = sigs[('FF', 'II')] / nevt # connections inside each group + cross = sigs[('FI', 'IF')] / nevt # connections crossing the groups + # (3) the asymmetry, and crucially WHICH topology dominates: swapping + # the two flow labels would invert this and fail here. + self.assertGreater(same, 0.9, + 'the initial-initial / final-final colour topology ' + 'should dominate u u~ > u u~ (measured ~0.98), got ' + '%.3f (cross=%.3f)' % (same, cross)) + self.assertTrue(0.002 < cross < 0.1, + 'the crossing colour topology should be present but ' + 'strongly suppressed (measured ~0.02), got %.4f' + % cross) + + +class TestMadeventRouterColorSelection(unittest.TestCase): + """A within-group (Track A) router must RESELECT the colour flow, with its + OWN colour-config mask -- not relabel the flow its base picked. + + A router has no matrix element of its own: it calls the base SMATRIX with a + crossed FLAV_IDX. That base runs SELECT_COLOR before it returns, masking its + JAMP2 with the BASE's ICOLAMP row. ICOLAMP is indexed by (flow, config, + SUBPROCESS), and two subprocesses of one group do not have the same row: in + ``g u > g u`` / ``g u~ > g u~`` the rows for configs 2 and 3 are swapped, so + at the same live ICONFIG the base allows exactly the flow the router's own + SELECT_COLOR forbids. Whatever the router then does with that index -- even + the identity, which is what a crossing-covariant flow ORDER gives -- the + event carries a colour topology the module would never have chosen. + + The fix is to discard the base's choice and reselect: permute the base's + published per-flow JAMP2 (COMMON/TO_XG_JAMP2) into this subprocess's flow + order and call SELECT_COLOR with the ROUTER's proc_id (XG_SELCOL). This + is the same thing the cross-group path (Track B) already does. + + Checked twice over. test_router_reselects_colour_with_its_own_mask is the + structural half: it reads the generated fortran, and -- crucially -- asserts + that some router really does have a different ICOLAMP row from its base, so + the guard cannot go vacuous if the diagram numbering ever becomes + crossing-covariant. test_router_colour_topology_matches_no_crossing is the + behavioural half, and the only kind of check that catches this class of bug: + the cross section agreed to 0.02% while ~10% of the affected class carried + the wrong flow, and per-point SMATRIX probes run before the good-helicity + state warms up, a regime production never reaches. So it compares the + COLOUR TOPOLOGY DISTRIBUTION of two full event samples, one routed and one + built with --use_crossing=False. + + That comparison is run over two canonical forms, because the colour-only one + has a structural blind spot. Canonicalising a topology means minimising it + over every relabelling of the legs, and legs may only be exchanged when they + have the same TYPE. With the type (status, pid) the two gluons of + g g > q q~ are interchangeable, so the minimisation swaps them freely and + maps that class's two colour flows onto each other: both collapse into ONE + category, and NO redistribution between them can ever be detected. Adding + the helicity to the type -- (status, pid, helicity) -- pins the permutation + whenever the gluons differ in helicity and separates the flows again. That + refinement is what exposed a crossing build assigning ~10% of g g > q q~ a + colour flow drawn ~50/50 instead of from JAMP2: the recycled optim of a + crossing BASE kept every helicity config instead of the good-hel union, and + the configs with |M|^2 == 0 still carry non-zero individual diagrams and + JAMPs, which silently reweighted the AMP2 channel weights and the JAMP2 + colour weights. Marginal helicity, marginal colour and the cross section + were all correct while that was happening; only the correlation moved. + + ``g u u~`` dijets rather than ``p p > j j``: same subprocess groups, same + routers, one quark flavour instead of four, so a generation takes seconds. + """ + + DEFINE = 'define q1 = g u u~' + PROCESS = 'q1 q1 > q1 q1' + NEVENTS = 400000 + SEED = 777 + # A flavour class needs this many reference events before its topology + # fractions are compared. At 5000 the statistical error on a fraction is + # 0.7%, an order of magnitude below the shift being looked for. + MIN_CLASS = 5000 + # The class the within-group router serves here: u~ g > u~ g, evaluated by + # the u g > u g matrix element under a crossing. Named explicitly because it + # is the only class in this process whose colour selection the router + # decides, and g g > g g outnumbers it many times over -- a comparison that + # quietly stopped reaching it would pass no matter what the router did. + ROUTED_CLASS = ((-2, 21), (-2, 21)) + # Tolerated shift of a topology fraction, on top of a 4 sigma statistical + # allowance. The defect this guards moves it by ~3 points (0.403 -> 0.435 on + # u~ g > u~ g at these beams, 8 sigma); the fix leaves it inside 1 sigma. + MAX_SHIFT = 0.015 + # Significance of the homogeneity chi-square (see _homogeneity), used by + # TestMadeventCrossingBaseColorFlow rather than by this class. 4 sigma + # (p ~ 3e-5) keeps a spurious failure rare while leaving a wide margin on + # the defect it guards: measured 0.4 on 3 dof fixed, 24.5 critical. + CHI2_Z = 4.0 + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix='cross_router_col_') + + def tearDown(self): + if os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + def _generate(self, options, name, launch=False): + """Generate (and optionally integrate) the process; return its outdir.""" + from madgraph import MG5DIR + outdir = pjoin(self.tmpdir, name) + card = pjoin(self.tmpdir, 'cmd_%s.txt' % name) + lines = ['%s\n' % self.DEFINE, + 'generate %s %s\n' % (self.PROCESS, options), + 'output madevent %s -f -nojpeg\n' % outdir] + if launch: + lines += ['launch\n', + 'set nevents %d\n' % self.NEVENTS, + 'set iseed %d\n' % self.SEED, + # a broken local lhapdf kills the systematics step, and + # this test has no use for the reweighting anyway + 'set use_syst False\n', + # Beam 2 an ANTIproton: the routed subprocess is + # g u~ > g u~, so on p p it is a sea channel and gets ~4% + # of the events. Against an antiproton the u~ is valence + # and the class doubles, which is what buys the routed + # class the statistics to resolve the shift without + # doubling the runtime. Nothing else about the test + # depends on the beams. + 'set lpp2 -1\n'] + with open(card, 'w') as fsock: + fsock.writelines(lines) + subprocess.call([sys.executable, pjoin(MG5DIR, 'bin', 'mg5_aMC'), card]) + self.assertTrue(os.path.isdir(pjoin(outdir, 'SubProcesses')), + 'madevent produced no output for %r' % (options or + 'the default')) + return outdir + + @staticmethod + def _flat(path): + """The file's code with comments, continuations and all whitespace gone, + so a pattern can be matched without caring how the writer wrapped it.""" + out = [] + with open(path) as fsock: + for line in fsock: + if not line.strip() or line[0] in 'Cc*!': + continue + body = line[6:] if len(line) > 6 else '' + if len(line) > 5 and line[5] not in ' \t': + out.append(body) # continuation of the previous line + else: + out.append('\n' + body) + return re.sub(r'[ \t]', '', ''.join(out)) + + @classmethod + def _routers(cls, outdir): + """{P directory: {router proc_id: base proc_id}} for every Track A router.""" + subproc = pjoin(outdir, 'SubProcesses') + found = {} + for name in sorted(os.listdir(subproc)): + pdir = pjoin(subproc, name) + if not name.startswith('P') or not os.path.isdir(pdir): + continue + for entry in sorted(os.listdir(pdir)): + match = re.match(r'matrix(\d+)_router\.f$', entry) + if not match: + continue + bases = set(re.findall(r'CALLSMATRIX(\d+)\(', + cls._flat(pjoin(pdir, entry)))) + # partition_crossing_classes only ever routes a module to a + # single base, so this is one entry per router. + found.setdefault(name, {})[int(match.group(1))] = \ + int(bases.pop()) if len(bases) == 1 else None + return found + + @staticmethod + def _icolamp(pdir): + """{proc_id: {config: (flow allowed, ...)}} out of coloramps.inc. + + Configs the file does not list are forbidden for every flow, which is + exactly how the fortran DATA leaves them. + """ + rows = {} + text = '' + with open(pjoin(pdir, 'coloramps.inc')) as fsock: + for line in fsock: + if len(line) > 5 and line[5] not in ' \t': + text += line[6:] + else: + text += '\n' + line[6:] if len(line) > 6 else '\n' + for stmt in text.split('\n'): + match = re.match(r'\s*DATA\s*\(\s*ICOLAMP\(I,(\d+),(\d+)\)\s*,' + r'\s*I\s*=\s*1\s*,\s*(\d+)\s*\)\s*/(.*)/\s*$', + stmt.replace(' ', '')) + if not match: + continue + iconfig, iproc = int(match.group(1)), int(match.group(2)) + vals = tuple(v.strip().upper().startswith('.T') + for v in match.group(4).split(',')) + rows.setdefault(iproc, {})[iconfig] = vals + return rows + + @classmethod + def _mismatched_masks(cls, outdir): + """(P dir, router, base) for every router whose ICOLAMP row differs from + its base's -- i.e. every router the base's colour choice would mislead.""" + out = [] + for pname, pairs in cls._routers(outdir).items(): + rows = cls._icolamp(pjoin(outdir, 'SubProcesses', pname)) + for router, base in sorted(pairs.items()): + if base is None: + continue + if rows.get(router, {}) != rows.get(base, {}): + out.append((pname, router, base)) + return out + + def test_router_reselects_colour_with_its_own_mask(self): + outdir = self._generate('', 'struct') + routers = self._routers(outdir) + self.assertTrue(routers, + '%s produced no within-group crossing router, so this ' + 'test would check nothing' % self.PROCESS) + + # The premise: at least one router really is masked differently from its + # base. Without this the whole comparison is between two ways of writing + # the same answer and could never fail. + mismatched = self._mismatched_masks(outdir) + self.assertTrue( + mismatched, + 'no router has an ICOLAMP row different from its base\'s, so ' + 'reselecting colour could not change any event -- the guard below ' + 'has become vacuous and needs a process where it bites (routers ' + 'found: %s)' % routers) + + for pname, pairs in sorted(routers.items()): + pdir = pjoin(outdir, 'SubProcesses', pname) + for router, base in sorted(pairs.items()): + self.assertIsNotNone( + base, 'matrix%d_router.f in %s dispatches to more than one ' + 'base SMATRIX' % (router, pname)) + code = self._flat(pjoin(pdir, 'matrix%d_router.f' % router)) + # (1) the helper exists and masks with the ROUTER's own proc_id + self.assertIn('SUBROUTINEXG_SELCOL%d(RCOL,IFLAV,IVEC,ICOL)' + % router, code, + 'matrix%d_router.f (%s) has no colour-reselection ' + 'helper' % (router, pname)) + self.assertIn('CALLSELECT_COLOR(RCOL,JD,ICONFIG,%d,ICOL,IVEC)' + % router, code, + 'XG_SELCOL%d (%s) does not run SELECT_COLOR with ' + 'its own subprocess index as IPROC, so it masks ' + 'the flows with another subprocess\'s ICOLAMP row' + % (router, pname)) + # (2) every dispatched flavour goes through it -- an identity + # flow order is NOT a reason to keep the base's pick + ncall = len(re.findall(r'CALLSMATRIX%d\(' % base, code)) + nsel = len(re.findall(r'CALLXG_SELCOL%d\(' % router, code)) + self.assertEqual( + nsel, ncall, + 'matrix%d_router.f (%s) reselects colour for %d of its %d ' + 'routed flavours' % (router, pname, nsel, ncall)) + # (3) nothing relabels the base's own selection any more + self.assertNotIn('ICOL=COLMAP_', code, + 'matrix%d_router.f (%s) still relabels the ' + 'base\'s colour index' % (router, pname)) + self.assertNotIn('IF(XDCD(XCK).EQ.XCNEW)ICOL=XCK', code, + 'matrix%d_router.f (%s) still translates the ' + 'base\'s colour index through the flow code' + % (router, pname)) + # (4) the base has to publish the per-flow JAMP2 the helper reads + candidates = [pjoin(pdir, 'matrix%d_orig.f' % base), + pjoin(pdir, 'matrix%d.f' % base)] + bfile = [c for c in candidates if os.path.isfile(c)] + self.assertTrue(bfile, 'no source for base SMATRIX%d in %s' + % (base, pname)) + bcode = self._flat(bfile[0]) + self.assertIn('COMMON/TO_XG_JAMP2/XG_JAMP2', bcode, + '%s does not publish its per-flow JAMP2, so ' + 'XG_SELCOL%d has nothing to reselect from' + % (os.path.basename(bfile[0]), router)) + self.assertIn('XG_JAMP2(I,IVEC)=JAMP2(I)', bcode, + '%s declares TO_XG_JAMP2 but never fills it' + % os.path.basename(bfile[0])) + + def test_router_colour_topology_matches_no_crossing(self): + from madgraph.various import lhe_parser + + routed = self._generate('', 'on', launch=True) + plain = self._generate('--use_crossing=False', 'off', launch=True) + + self.assertTrue( + self._mismatched_masks(routed), + 'the routed build has no router masked differently from its base, ' + 'so this comparison cannot fail') + self.assertEqual(self._routers(plain), {}, + '--use_crossing=False still emitted a crossing router') + + ref = self._topologies(plain, lhe_parser) + got = self._topologies(routed, lhe_parser) + nall = sum(sum(c['colour'].values()) for c in ref.values()) + self.assertGreater(nall, 0, + 'the --use_crossing=False build produced no events') + # The launch has to have honoured `set nevents`: at the run_card default + # the routed class falls below MIN_CLASS, every class but g g > g g is + # skipped and the comparison silently checks nothing. + self.assertGreaterEqual( + nall, 0.9 * self.NEVENTS, + 'the --use_crossing=False build wrote %d events, not the %d asked ' + 'for -- the per-class statistics this test needs are not there' + % (nall, self.NEVENTS)) + + compared = [] + for flav in sorted(ref): + nref = sum(ref[flav]['colour'].values()) + ngot = sum(got.get(flav, {}).get('colour', {}).values()) + logger.info(' %-18s %7d ref %7d routed %s', self._fmt(flav), + nref, ngot, + ' '.join('%.4f/%.4f' % ( + got.get(flav, {}).get('colour', {}).get(t, 0) + / float(ngot or 1), + ref[flav]['colour'][t] / float(nref)) + for t in sorted(ref[flav]['colour']))) + if nref < self.MIN_CLASS or not ngot: + continue + compared.append(flav) + # Both observables, weakest first. 'colour' is what a wrong ICOLAMP + # row moves; 'joint' additionally catches anything that moves the + # flow WITHIN a helicity configuration, which for a class with two + # identical gluons is the only thing there is to see. + for obs in ('colour', 'joint'): + rbin, gbin = ref[flav][obs], got[flav][obs] + # (a) as specified: no category the reference never produces + extra = [t for t in gbin + if t not in rbin + and nref * gbin[t] / float(ngot) >= 5.0] + self.assertFalse( + extra, + '%s: the routed build writes %d %s category(ies) the ' + '--use_crossing=False build never produces (%s)' + % (self._fmt(flav), len(extra), obs, + ', '.join('%d events' % gbin[t] for t in extra))) + # (b) and, strictly stronger, the same MIX of them: a wrong + # ICOLAMP row moves weight between categories both builds can + # produce, so (a) alone does not see it. + for topo in set(list(rbin) + list(gbin)): + pref = rbin.get(topo, 0) / float(nref) + pgot = gbin.get(topo, 0) / float(ngot) + sigma = math.sqrt(pref * (1 - pref) / nref + + pgot * (1 - pgot) / ngot) + self.assertLessEqual( + abs(pgot - pref), max(self.MAX_SHIFT, 4.0 * sigma), + '%s: %s category %s carries %.4f of the class in the ' + 'routed build but %.4f in the --use_crossing=False ' + 'build (%d vs %d events, %.1f sigma) -- the crossing ' + 'build is not choosing the flow the module itself would' + % (self._fmt(flav), obs, topo, pgot, pref, + gbin.get(topo, 0), rbin.get(topo, 0), + abs(pgot - pref) / sigma if sigma else 0.0)) + # Deliberately NOT the homogeneity chi-square here, though + # _homogeneity is what TestMadeventCrossingBaseColorFlow uses. + # g g > g g carries 325k of the 400k events in this process, and + # at that size a chi-square resolves differences far below the + # MAX_SHIFT floor this test was calibrated around -- it would be + # a much tighter bar than intended on the classes it was never + # meant to police. The sharp statistic belongs on the class it + # was measured on. + # The comparison is only worth anything if it reached the class the + # router actually serves; without this it degrades to g g > g g, which + # no router touches, and passes whatever the routers do. + self.assertIn( + self.ROUTED_CLASS, compared, + '%s -- the class the within-group router serves -- was not among ' + 'the %d compared (%s), so this test checked nothing about the ' + 'router' % (self._fmt(self.ROUTED_CLASS), len(compared), + ', '.join(self._fmt(f) for f in compared))) + # The identical-gluon class g g > u u~ is deliberately NOT required + # here: g g > g g takes 81% of this process and starves it to 0.5% + # (2139 events in 400k), which is an order of magnitude short of what + # it takes to resolve a flow shift inside it. TestMadeventCrossingBase- + # ColorFlow covers that class on a process where it is not starved. + + @staticmethod + def _fmt(flav): + return '%s > %s' % (' '.join(str(p) for p in flav[0]), + ' '.join(str(p) for p in flav[1])) + + @staticmethod + def _homogeneity(ref, got): + """(chi2, dof, critical value) for 'both samples share one category mix'. + + The per-category threshold above asks each category on its own to move by + more than max(MAX_SHIFT, 4 sigma). That is the right shape for a flow + that lands in the wrong bucket outright, but it has little power against + a COHERENT redistribution: the shift is divided among the categories and + each piece stays under the bar while the pattern as a whole is far from + chance. This is the standard 2 x K homogeneity chi-square on the raw + counts, which aggregates exactly that pattern. + + Critical value is the Wilson-Hilferty quantile at CHI2_Z, so no scipy. + """ + cats = set(list(ref) + list(got)) + nref, ngot = sum(ref.values()), sum(got.values()) + tot = float(nref + ngot) + chi2, nbin = 0.0, 0 + for cat in cats: + oref, ogot = ref.get(cat, 0), got.get(cat, 0) + row = oref + ogot + if not row: + continue + nbin += 1 + eref, egot = row * nref / tot, row * ngot / tot + chi2 += (oref - eref) ** 2 / eref + (ogot - egot) ** 2 / egot + dof = max(nbin - 1, 1) + crit = dof * (1 - 2.0 / (9 * dof) + + TestMadeventRouterColorSelection.CHI2_Z + * math.sqrt(2.0 / (9 * dof))) ** 3 + return chi2, dof, crit + + @classmethod + def _topologies(cls, outdir, lhe_parser): + """{flavour class: {observable: {canonical category: events}}}. + + Two observables per event, both canonicalised the same way (see + _canon_topology): 'colour' is the colour topology alone, 'joint' is the + colour topology with each leg additionally typed by its HELICITY. + 'joint' is strictly finer, and for a class with two identical gluons it + is the only one that separates the flows at all -- see the class + docstring. + """ + lhe = pjoin(outdir, 'Events', 'run_01', 'unweighted_events.lhe.gz') + out = {} + cache = {} + for event in lhe_parser.EventFile(lhe): + parts = [(int(p.status), int(p.pid), int(p.color1), int(p.color2), + int(p.helicity)) for p in event] + key = tuple(parts) + if key not in cache: + flav = (tuple(sorted(p[1] for p in parts if p[0] == -1)), + tuple(sorted(p[1] for p in parts if p[0] == 1))) + cache[key] = (flav, cls._canon_topology(parts), + cls._canon_topology(parts, helicity=True)) + flav, topo, joint = cache[key] + bucket = out.setdefault(flav, {'colour': {}, 'joint': {}}) + bucket['colour'][topo] = bucket['colour'].get(topo, 0) + 1 + bucket['joint'][joint] = bucket['joint'].get(joint, 0) + 1 + return out + + @staticmethod + def _canon_topology(parts, helicity=False): + """Colour topology of one event, free of the leg-ordering convention. + + The connections are (leg holding a colour, leg holding the matching + anticolour) with initial-state legs swapping the two roles -- the LHE + runs an initial colour line 'through' the event, so without the swap a + label sits in the same slot on two legs and the flow is not a bijection + (the same canonical form _color_flow_canon uses in the exporter). The + result is then minimised over every relabelling of the legs, so two + modules that write the same physical flow in a different leg order give + the same answer. + + The minimisation is only allowed to move legs of the same TYPE, and the + type is what decides how much the canonical form can still see. With + helicity=False the type is (status, pid), so two identical gluons are + interchangeable and the minimisation is free to swap them -- which maps + the two colour flows of g g > q q~ onto each other and collapses them + into a single category, making any redistribution between them + invisible. With helicity=True the type is (status, pid, helicity), + which pins the permutation whenever the two gluons differ in helicity + and keeps the flows apart. + """ + col, anti = {}, {} + for i, (status, _pid, c, a, _h) in enumerate(parts): + if status == -1: + c, a = a, c + if c: + col.setdefault(c, []).append(i) + if a: + anti.setdefault(a, []).append(i) + conns = set() + for label in set(list(col) + list(anti)): + for cc, aa in zip(sorted(col.get(label, [])), + sorted(anti.get(label, []))): + conns.add((cc, aa)) + if helicity: + types = [(p[0], p[1], p[4]) for p in parts] + else: + types = [(p[0], p[1]) for p in parts] + nleg = len(parts) + best = None + for perm in itertools.permutations(range(nleg)): + inv = [0] * nleg + for new, old in enumerate(perm): + inv[old] = new + cand = (tuple(types[old] for old in perm), + tuple(sorted((inv[i], inv[j]) for (i, j) in conns))) + if best is None or cand < best: + best = cand + return best + + +class TestMadeventCrossingFinalLegSplit(unittest.TestCase): + """MG_SPLIT_CROSSING peels the one flavor class that keeps a merged module + compiled, into a sibling GENERATED with its final legs the other way round. + + ``Q Q~ > Q Q~`` bundles three coupling classes and drops its own matrix + element only if EVERY one of them routes. Two do; the flavour-changing + annihilation ``q q~ > q' q~'`` does not, because the crossing that reaches + it off ``Q Q > Q Q`` (I=0/J=5) delivers the two light legs as ``(q~', q')`` + while the module lists ``(q', q~')``. A module cannot list one class + differently -- its leg pattern is shared by every row -- so the class is + peeled into a sibling with the swapped pattern and the two modules are given + COMPLEMENTARY halves of the flavors. + + ``q q > q q`` with ``q = u d u~ d~`` rather than ``p p > j j``: same group, + same peel, no gluon subprocesses, so a generation takes seconds. + + What is pinned here is what fails SILENTLY: + + * the halves must partition the flavors -- no combination covered twice (a + double count, wrong by a factor 2) and none dropped. This is the assertion + that catches IdentifyMETag re-merging the two modules: that tag identifies + processes agreeing up to a LEG PERMUTATION, which is exactly what the two + halves are, and merging them relabels one into the other's leg order and + undoes the split with nothing to show for it. + * the peel must actually eliminate a compiled matrix element, or the whole + feature is cost without benefit. + * it must not fire for an exporter that cannot consume a split pattern; mg7 + builds one module per leg pattern and dies with "no valid flavor + configurations found for diagram 2" on the half that no longer has them. + + The colour/helicity correctness of the routed events is NOT checked here -- + that needs event samples, and TestMadeventRouterColorSelection is where that + kind of comparison lives. + """ + + DEFINE = 'define q = u d u~ d~' + PROCESS = 'q q > q q' + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix='cross_split_') + + def tearDown(self): + if os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + def _generate(self, name, split, fmt='madevent', options=''): + from madgraph import MG5DIR + outdir = pjoin(self.tmpdir, name) + card = pjoin(self.tmpdir, 'cmd_%s.txt' % name) + with open(card, 'w') as fsock: + fsock.writelines(['%s\n' % self.DEFINE, + 'generate %s %s\n' % (self.PROCESS, options), + 'output %s %s -f -nojpeg\n' % (fmt, outdir)]) + env = dict(os.environ) + env['MG_SPLIT_CROSSING'] = 'on' if split else '' + subprocess.call([sys.executable, pjoin(MG5DIR, 'bin', 'mg5_aMC'), card], + env=env) + return outdir + + @staticmethod + def _counts(pdir): + """(compiled matrix elements, crossing routers) in a P directory.""" + entries = os.listdir(pdir) + return (len([e for e in entries + if re.match(r'matrix\d+(_orig)?\.f$', e)]), + len([e for e in entries if re.match(r'matrix\d+_router\.f$', e)])) + + @staticmethod + def _leshouche(pdir): + """{subprocess: [IDUP row, ...]} out of leshouche.inc.""" + rows = {} + with open(pjoin(pdir, 'leshouche.inc')) as fsock: + for line in fsock: + match = re.match(r'\s*DATA\s*\(IDUP\(I,(\d+),(\d+)\)\s*,' + r'\s*I\s*=\s*1\s*,\s*(\d+)\s*\)\s*/([^/]*)/', + line.replace(' ', '')) + if match: + rows.setdefault(int(match.group(2)), []).append( + tuple(int(v) for v in match.group(4).split(','))) + return rows + + @classmethod + def _physical(cls, pdir, nini=2): + """Counter of the PHYSICAL (initial, final) flavor combinations the + directory covers, blind to the order the legs are listed in -- which is + precisely what the two halves disagree about on purpose.""" + seen = {} + for rows in cls._leshouche(pdir).values(): + for row in rows: + key = (tuple(sorted(row[:nini])), tuple(sorted(row[nini:]))) + seen[key] = seen.get(key, 0) + 1 + return seen + + def test_split_partitions_the_flavors_and_frees_a_matrix_element(self): + plain = self._generate('plain', split=False, + options='--use_crossing=False') + split = self._generate('split', split=True) + + pdir_plain = pjoin(plain, 'SubProcesses', 'P1_qq_qq') + pdir_split = pjoin(split, 'SubProcesses', 'P1_qq_qq') + # Generation has to have COMPLETED for both, not merely made the + # directory: a split the exporter cannot digest leaves the P directory + # behind without its flavor tables, and every assertion below would + # then fail on a missing file rather than on what it means to check. + for pdir in (pdir_plain, pdir_split): + self.assertTrue(os.path.isdir(pdir), + '%s was not generated' % pdir) + self.assertTrue( + os.path.isfile(pjoin(pdir, 'leshouche.inc')), + '%s has no leshouche.inc -- the generation did not finish' + % pdir) + + # (1) the peel really happened: an extra subprocess, and it is a ROUTER + sub_plain = self._leshouche(pdir_plain) + sub_split = self._leshouche(pdir_split) + self.assertEqual(len(sub_split), len(sub_plain) + 1, + 'the split did not add a subprocess to the group ' + '(%d vs %d) -- MG_SPLIT_CROSSING did not fire' + % (len(sub_split), len(sub_plain))) + + # (2) and it PAYS: fewer compiled matrix elements than crossing-off + n_plain, r_plain = self._counts(pdir_plain) + n_split, r_split = self._counts(pdir_split) + self.assertEqual(r_plain, 0, + '--use_crossing=False emitted %d router(s)' % r_plain) + self.assertLess(n_split, n_plain, + 'the split compiles %d matrix element(s), no better ' + 'than the %d of --use_crossing=False -- the peel costs ' + 'a subprocess and buys nothing' % (n_split, n_plain)) + self.assertEqual(r_split, len(sub_split) - n_split, + 'every subprocess of the split group that is not a ' + 'compiled matrix element should be a router') + + # (3) the halves PARTITION the flavors. Both directions matter: a + # combination covered twice is double counted, one covered by neither + # is silently missing from the cross section. + want = self._physical(pdir_plain) + got = self._physical(pdir_split) + self.assertEqual( + sorted(got), sorted(want), + 'the split changed which physical flavor combinations the group ' + 'covers (%d missing, %d new)' + % (len(set(want) - set(got)), len(set(got) - set(want)))) + doubled = sorted(k for k, v in got.items() if v > 1) + self.assertFalse( + doubled, + 'the split covers %d flavor combination(s) TWICE, so they are ' + 'double counted -- the two halves were re-identified into one ' + 'pattern instead of staying complementary (e.g. %s)' + % (len(doubled), doubled[:3])) + + # (4) the peeled sibling really is listed the OTHER way round -- that is + # the whole reason it exists. Its rows are the flavour-changing + # annihilation, and where the crossing-off build lists that class as + # (q', q~') the sibling lists it as (q~', q'). Without this the test + # would still pass if the peel produced a sibling identical to the + # module it came from. + peeled = sub_split[max(sub_split)] + self.assertTrue( + all(row[2] < 0 < row[3] for row in peeled), + 'the peeled subprocess does not list its final legs as ' + '(antiparticle, particle): %s' % (peeled[:3],)) + native = [row for rows in self._leshouche(pdir_plain).values() + for row in rows + if (tuple(sorted(row[:2])), tuple(sorted(row[2:]))) + in set((tuple(sorted(r[:2])), tuple(sorted(r[2:]))) + for r in peeled)] + self.assertTrue(native, 'the crossing-off build has no counterpart for ' + 'the peeled class') + self.assertTrue( + all(row[3] < 0 < row[2] for row in native), + 'the crossing-off build already lists that class as ' + '(antiparticle, particle), so the peel swapped nothing: %s' + % (native[:3],)) + + def test_split_does_not_fire_for_an_exporter_that_cannot_take_it(self): + """mg7 builds one module per leg pattern; handed a pattern split across + two modules it raises "no valid flavor configurations found". The peel + is a grouped-madevent optimisation and must stay off elsewhere.""" + outdir = self._generate('mg7', split=True, fmt='') + self.assertTrue( + os.path.isdir(outdir), + 'the default (mg7) export produced nothing with ' + 'MG_SPLIT_CROSSING=on -- the split fired for a backend that ' + 'cannot consume it') + + +class TestMadeventCrossingBaseColorFlow(unittest.TestCase): + """A crossing BASE must pick the colour flow the same way with the crossing + machinery on as with it off. + + Different code path from TestMadeventRouterColorSelection. There is no + router here: ``u u~ > g g`` is a cross-GROUP (Track B) dependent and simply + reuses the compiled matrix element of ``g g > u u~``, which is the base. + What the base has to get right is not a mask but its own recycled optim -- + and that is generated at RUN time by gen_ximprove, over the good-helicity + set. Keeping every helicity config there instead of the good-hel union + looks harmless, because the |M|^2 sum is unchanged, but the same loop also + accumulates AMP2 (the single-diagram multi-channel weights) and JAMP2 (the + colour-flow weights), and a config whose |M|^2 vanishes still has non-zero + individual diagrams and JAMPs. For g g > q q~ that gave the s-channel + config -- whose AMP2 is exactly zero over the good helicities -- about 10% + of the subprocess, and SELECT_COLOR masks JAMP2 by ICONFIG, so those events + took their flow from a polluted JAMP2 rather than the real one. + + Only the CORRELATION moves. The cross section stayed right to 4 digits + (the multi-channel weights are self-normalising), and so did the marginal + helicity and the marginal colour distributions. Seeing it needs the joint + (helicity, colour) observable -- and for a class with two identical gluons + the colour-only canonical form is not merely weak but structurally blind: + it puts every event of g g > u u~ in ONE category, so its chi-square is + identically 0 no matter what the code does. + + ``g g > u u~`` plus ``u u~ > g g`` rather than the dijet process the router + test uses: same base/dependent crossing pair, but g g > g g is not there to + take 81% of the events and starve the class being measured to 0.5%. + """ + + NEVENTS = 200000 + SEED = 777 + CLASS = ((21, 21), (-2, 2)) # g g > u u~ + # It takes roughly 10k events in the class to resolve the shift; the point + # of this process is that essentially the whole sample lands there. + MIN_CLASS = 20000 + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix='cross_base_col_') + + def tearDown(self): + if os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + def _generate(self, options, name): + from madgraph import MG5DIR + outdir = pjoin(self.tmpdir, name) + card = pjoin(self.tmpdir, 'cmd_%s.txt' % name) + with open(card, 'w') as fsock: + fsock.writelines( + ['generate g g > u u~ %s\n' % options, + 'add process u u~ > g g %s\n' % options, + 'output madevent %s -f -nojpeg\n' % outdir, + 'launch\n', + 'set nevents %d\n' % self.NEVENTS, + 'set iseed %d\n' % self.SEED, + # a broken local lhapdf kills the systematics step + 'set use_syst False\n', + 'set lpp2 -1\n']) + subprocess.call([sys.executable, pjoin(MG5DIR, 'bin', 'mg5_aMC'), card]) + self.assertTrue(os.path.isdir(pjoin(outdir, 'SubProcesses')), + 'madevent produced no output for %r' % (options or + 'the default')) + return outdir + + def test_crossing_base_colour_flow_matches_no_crossing(self): + from madgraph.various import lhe_parser + helper = TestMadeventRouterColorSelection + + crossed = self._generate('', 'on') + plain = self._generate('--use_crossing=False', 'off') + + # The crossing really has to be in play, or this compares two identical + # builds and passes on anything. + base = pjoin(crossed, 'SubProcesses', 'P1_gg_qq', + 'crossgroup_helunion.dat') + self.assertTrue( + os.path.exists(base), + 'the default build has no crossing base for g g > u u~ (no %s), so ' + 'this test exercises no crossing at all' % os.path.basename(base)) + self.assertFalse( + os.path.exists(pjoin(plain, 'SubProcesses', 'P1_gg_qq', + 'crossgroup_helunion.dat')), + '--use_crossing=False still emitted a crossing base') + + ref = helper._topologies(plain, lhe_parser) + got = helper._topologies(crossed, lhe_parser) + self.assertIn(self.CLASS, ref, + 'the --use_crossing=False build produced no %s events' + % helper._fmt(self.CLASS)) + self.assertIn(self.CLASS, got, + 'the crossing build produced no %s events' + % helper._fmt(self.CLASS)) + + rall, gall = ref[self.CLASS], got[self.CLASS] + nref = sum(rall['colour'].values()) + ngot = sum(gall['colour'].values()) + self.assertGreaterEqual( + min(nref, ngot), self.MIN_CLASS, + '%s got %d/%d events, below the %d this comparison needs to ' + 'resolve a colour-flow shift' + % (helper._fmt(self.CLASS), nref, ngot, self.MIN_CLASS)) + + # The colour-only form cannot see anything here -- assert that, so the + # reason the joint form is required stays documented in the suite and a + # future 'simplification' back to it fails loudly instead of quietly + # testing nothing. + self.assertEqual( + len(set(list(rall['colour']) + list(gall['colour']))), 1, + 'the colour-only canonical form no longer merges the two flows of ' + '%s into one category; the blind spot this test exists for may ' + 'have moved' % helper._fmt(self.CLASS)) + + chi2, dof, crit = helper._homogeneity(rall['joint'], gall['joint']) + logger.info(' %s: %d ref / %d crossed events, joint chi2 %.1f on %d ' + 'dof (critical %.1f)', helper._fmt(self.CLASS), nref, ngot, + chi2, dof, crit) + self.assertGreater(dof, 1, + 'the helicity-refined form separated only %d ' + 'category(ies), so it is no finer than the ' + 'colour-only one' % (dof + 1)) + self.assertLessEqual( + chi2, crit, + '%s: the (helicity, colour) mix differs between the crossing build ' + 'and the --use_crossing=False build (chi2 = %.1f on %d dof, ' + 'critical %.1f) -- the crossing base is not choosing the colour ' + 'flow the module itself would' + % (helper._fmt(self.CLASS), chi2, dof, crit)) diff --git a/tests/acceptance_tests/test_standalone_hel_recycling.py b/tests/acceptance_tests/test_standalone_hel_recycling.py new file mode 100644 index 0000000000..42262c3984 --- /dev/null +++ b/tests/acceptance_tests/test_standalone_hel_recycling.py @@ -0,0 +1,264 @@ +################################################################################ +# +# Copyright (c) 2009 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 +# +################################################################################ +"""Consistency of the helicity-recycled standalone output +(`output standalone --hel_recycling=True`) against the standard one. + +With --hel_recycling the exporter writes matrix_orig.f (the plain per-helicity +MATRIX), template_matrix.f (the SMATRIX/MATRIX driver) and hel_warmup.f (a +probe), then runs the madevent DAG rewriter (madgraph/madevent/hel_recycle.py) +over them: the helicity loop is unrolled, wavefunctions that do not depend on a +given external helicity are computed once and shared, each amplitude is split +into a P1N current plus a contraction, and the helicity rows the warm-up found +to be dead are dropped. The warm-up also measures the good rows of every +crossing (the recycled table is their union, since a crossed call reuses the +baked rows) and the C-parity pairing (the partner's |M|^2 is copied from its +representative instead of being recomputed). + +None of that may change a number: for every phase-space point and flavor the +printed |M|^2 must agree with the standard standalone to round-off. Each process +below is therefore generated twice, both outputs are compiled (`make check`) and +run (`./check`), and the printed values are compared entry by entry. For a +process whose crossings are folded into a single directory, ./check also prints +the crossed matrix elements, so those are covered by the same comparison. +""" + +from __future__ import absolute_import + +import logging +import os +import re +import shutil +import subprocess +import tempfile +import unittest + +import madgraph.interface.master_interface as cmd_interface +import madgraph.various.misc as misc + +logger = logging.getLogger('madgraph.acceptance') +pjoin = os.path.join + + +def _sanitize(process): + return re.sub(r'[^A-Za-z0-9]+', '_', process).strip('_').lower() + + +def hel_recycling_test_factory(process, model='sm', tolerance=1e-9, options=''): + def test(self): + self.check_process(process, model=model, tolerance=tolerance, + options=options) + test.__name__ = 'test_%s' % _sanitize(process) + test.__doc__ = ('Check --hel_recycling and the standard standalone agree ' + 'on |M|^2 for %s.' % process) + return test + + +class StandaloneHelRecyclingConsistency(unittest.TestCase): + + debugging = getattr(unittest, 'debug', False) + + @classmethod + def setUpClass(cls): + # everything here needs a working fortran compiler (make check). + if not misc.which('gfortran') and not misc.which('f77'): + raise unittest.SkipTest('no fortran compiler available') + + def setUp(self): + self.cmd = cmd_interface.MasterCmd() + self.cmd.no_notification() + self.tmpdir = tempfile.mkdtemp(prefix='amc_helrecycling_') + self.std_dir = pjoin(self.tmpdir, 'Standard') + self.hr_dir = pjoin(self.tmpdir, 'Recycled') + + def tearDown(self): + if not self.debugging and os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + def do(self, line): + self.cmd.exec_cmd(line) + + # ------------------------------------------------------------------ + # generation helpers + # ------------------------------------------------------------------ + def _generate_pair(self, process, model='sm', options=''): + """Write both outputs and return their (sorted) subprocess dir lists.""" + self.do('set automatic_html_opening False') + self.do('set group_subprocesses False') + self.do('import model %s' % model) + self.do(('generate %s %s' % (process, options)).strip()) + self.do('output standalone %s -f' % self.std_dir) + self.do('output standalone %s --hel_recycling=True -f' % self.hr_dir) + + std_subdirs = self._subprocess_dirs(self.std_dir) + hr_subdirs = self._subprocess_dirs(self.hr_dir) + self.assertEqual([os.path.basename(d) for d in std_subdirs], + [os.path.basename(d) for d in hr_subdirs], + 'Different subprocess structure for %s' % process) + return std_subdirs, hr_subdirs + + def _subprocess_dirs(self, outdir): + root = pjoin(outdir, 'SubProcesses') + dirs = [pjoin(root, name) for name in sorted(os.listdir(root)) + if name.startswith('P') and os.path.isdir(pjoin(root, name))] + self.assertTrue(dirs, 'No subprocess directory found in %s' % root) + return dirs + + def _run_standalone(self, subproc_dir): + """Compile and run ./check, returning the printed |M|^2 values.""" + retcode = self._call(['make', 'check'], subproc_dir) + self.assertEqual(retcode, 0, + 'Failed to compile the standalone check in %s' + % subproc_dir) + output = subprocess.Popen(['./check'], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=subproc_dir).communicate()[0].decode() + values = [float(m.group('value')) for m in re.finditer( + r'Matrix element\s*=\s*(?P[\d\.eEdD\+-]+)', + output.replace('D', 'E').replace('d', 'e'))] + self.assertTrue(values, 'No matrix element printed by ./check in %s:\n%s' + % (subproc_dir, output)) + return values + + @staticmethod + def _call(command, cwd): + if logger.isEnabledFor(logging.INFO): + return subprocess.call(command, cwd=cwd) + with open(os.devnull, 'w') as devnull: + return subprocess.call(command, stdout=devnull, stderr=devnull, + cwd=cwd) + + # ------------------------------------------------------------------ + # the actual check + # ------------------------------------------------------------------ + def check_process(self, process, model='sm', tolerance=1e-9, options=''): + std_subdirs, hr_subdirs = self._generate_pair(process, model, options) + + for std_sub, hr_sub in zip(std_subdirs, hr_subdirs): + # the rewriter really ran: it tags every emitted call with its + # reuse count, which no other standalone template carries. + with open(pjoin(hr_sub, 'matrix.f')) as fsock: + recycled = fsock.read() + self.assertTrue(re.search(r'!\s+count\s+\d', recycled), + 'matrix.f in %s was not produced by the DAG rewriter' + % hr_sub) + + std_me = self._run_standalone(std_sub) + hr_me = self._run_standalone(hr_sub) + self.assertEqual( + len(std_me), len(hr_me), + 'Different number of matrix elements for %s (%s): ' + 'standard=%s recycled=%s' + % (process, os.path.basename(std_sub), len(std_me), len(hr_me))) + for i, (std_val, hr_val) in enumerate(zip(std_me, hr_me)): + scale = max(abs(std_val), abs(hr_val), 1e-99) + self.assertLessEqual( + abs(std_val - hr_val) / scale, tolerance, + 'Incompatible |M|^2 for %s (%s, entry %s): standard=%s ' + 'recycled=%s' % (process, os.path.basename(std_sub), i, + std_val, hr_val)) + + +class TestStandaloneHelRecyclingConsistency(StandaloneHelRecyclingConsistency): + + # single topology, combined (gamma + Z = FFV6_2) routines + test_helrec_ee_mumu = hel_recycling_test_factory('e+ e- > mu+ mu-') + + # cross-topology + non-trivial color + crossings folded into one directory + test_helrec_uux_ddxg = hel_recycling_test_factory('u u~ > d d~ g') + + # 1 > 2 decay with a scalar external + test_helrec_h_bbx = hel_recycling_test_factory('h > b b~') + + # identical final state particles (BROKEN_SYM must survive the rewrite) + test_helrec_uux_uux = hel_recycling_test_factory('u u~ > u u~') + + # merged flavor: several coupling groups behind one matrix element + test_helrec_pp_epem = hel_recycling_test_factory('p p > e+ e-') + + # massive (3-state) external vectors: no C-parity pairing is possible here + test_helrec_uux_wpwm = hel_recycling_test_factory('u u~ > w+ w-') + + # polarization restriction: NCOMB is a non-contiguous subset of the + # canonical helicity codes + test_helrec_uux_wp0wm = hel_recycling_test_factory('u u~ > w+{0} w-') + + def test_c_parity_pairs_are_reused(self): + """g g > t t~ has no 0-helicity state, so every row is paired with a + distinct C-parity partner and the recycled file must copy the partner's + |M|^2 rather than recompute it.""" + _, hr_subdirs = self._generate_pair('g g > t t~') + with open(pjoin(hr_subdirs[0], 'matrix.f')) as fsock: + recycled = fsock.read() + reuse = re.findall(r'TS\((\d+)\)\s*=\s*TS\((\d+)\)', recycled) + self.assertTrue(reuse, + 'No C-parity reuse emitted for g g > t t~:\n%s' + % recycled) + # the pairing is an involution on distinct rows + for flip, rep in reuse: + self.assertNotEqual(flip, rep) + + def test_full_entry_point_api_is_emitted(self): + """Only SMATRIX/SMATRIXHEL/MATRIX take the recycled path; every other + entry point is appended from the standard template, so the recycled + output must expose the same API (the density stack in particular, which + evaluates arbitrary helicity rows the recycled table cannot serve).""" + _, hr_subdirs = self._generate_pair('p p > e+ e-') + with open(pjoin(hr_subdirs[0], 'matrix.f')) as fsock: + recycled = fsock.read().upper() + for routine in ('GET_AMP', 'GET_JAMP', 'GET_MATRIX', 'GET_INTER', + 'GET_DENSITY', 'GET_DENSITY_IDX', 'GET_ALL_INTER', + 'GET_ALL_INTER_IDX', 'GET_VALUE', 'GET_VALUE_IDX', + 'GET_NHEL', 'GET_NHEL_IDX', 'FILL_NHEL', + 'DECODE_HEL', 'ENCODE_HEL'): + self.assertTrue( + re.search(r'SUBROUTINE\s+%s\s*\(' % routine, recycled), + '%s missing from the recycled output' % routine) + # ... and no leftover stub refusing to compute the density + self.assertNotIn('NOT AVAILABLE WITH --HEL_RECYCLING', recycled) + + def test_density_does_not_use_the_c_parity_reuse(self): + """The C-parity de-duplication is only valid for the helicity-summed + |M|^2: it asserts |M(h)|^2 == |M(-h)|^2, which says nothing about the + interference terms JAMP_i JAMP_j* the density matrix is built from. + + The reuse therefore has to stay confined to TS, which only SMATRIX and + SMATRIXHEL read; the density stack must keep going through the plain + per-helicity GET_AMP/GET_JAMP/GET_INTER. + """ + _, hr_subdirs = self._generate_pair('g g > t t~') + with open(pjoin(hr_subdirs[0], 'matrix.f')) as fsock: + source = fsock.read() + # the reuse is emitted for this process (no 0-helicity state) + self.assertTrue(re.findall(r'TS\((\d+)\)\s*=\s*TS\((\d+)\)', source), + 'expected C-parity reuse for g g > t t~') + # every routine of the density stack must be TS-free + routines = re.split(r'\n(?=\s*(?:SUBROUTINE|DOUBLE PRECISION FUNCTION' + r'|INTEGER FUNCTION|REAL\*8 FUNCTION))', source) + for body in routines: + head = body.strip().split('\n')[0].upper() + if re.search(r'\b(GET_DENSITY|GET_ALL_INTER|GET_INTER|GET_JAMP' + r'|GET_AMP|GET_MATRIX)\w*\s*\(', head): + self.assertNotIn('TS(', body.upper(), + 'the C-parity reuse must not reach %s' % head) + + def test_zero_helicity_state_disables_the_reuse(self): + """u u~ > w+ w- has 0-helicity rows that are their own C-parity + partner, which makes the all-or-nothing reuse inapplicable.""" + _, hr_subdirs = self._generate_pair('u u~ > w+ w-') + with open(pjoin(hr_subdirs[0], 'matrix.f')) as fsock: + recycled = fsock.read() + self.assertFalse(re.findall(r'TS\((\d+)\)\s*=\s*TS\((\d+)\)', recycled), + 'C-parity reuse must not be applied to a process with ' + 'a self-paired helicity row') diff --git a/tests/acceptance_tests/test_standalone_madevent_consistency.py b/tests/acceptance_tests/test_standalone_madevent_consistency.py index 0c9dd01983..906320aeda 100644 --- a/tests/acceptance_tests/test_standalone_madevent_consistency.py +++ b/tests/acceptance_tests/test_standalone_madevent_consistency.py @@ -25,7 +25,6 @@ logger = logging.getLogger('madgraph.madevent') import madgraph.interface.master_interface as cmd_interface -import madgraph.various.misc as misc import madgraph.various.process_checks as process_checks @@ -66,44 +65,112 @@ def do(self, line): self.cmd.exec_cmd(line) def check_process(self, process, model='sm', tolerance=1e-6): + """Every backend must return the same matrix element per flavor. + + The reference is the plain (--use_crossing=False) fortran standalone. + Every other backend is compared to it flavor by flavor, matched by the + PDG tuple it prints (not by index -- the flavor ordering differs between + backends, and a crossing-folded backend may expose extra flavors): + + - fortran madevent, ungrouped, --use_crossing=False (the original + check; only the ungrouped ME exporter does not support crossing); + - fortran standalone WITH crossing (the crossing-aware SMATRIX must + reproduce the plain per-flavor matrix element); + - fortran madevent, grouped, WITH crossing + (ProcessExporterFortranMEGroup, which does support crossing); + - standalone_mg7 (the madmatrix / cudacpp CPU-SIMD backend). + """ self.do('set automatic_html_opening False') self.do('set group_subprocesses False') self.do('set apply_flavor_grouping True') self.do('set zerowidth_tchannel False') self.do('import model %s' % model) - self.do('generate %s' % process) - generated_process = self.cmd._curr_amps[0].get('process') - self.do('output standalone %s -f' % self.standalone_dir) - self.do('output madevent %s -f' % self.madevent_dir) - - standalone_dir = self._get_single_subprocess_dir( - pjoin(self.standalone_dir, 'SubProcesses')) - madevent_dir = self._get_single_subprocess_dir( - pjoin(self.madevent_dir, 'SubProcesses')) - standalone_rows, printed_phase_space = self._run_standalone(standalone_dir) + # -- Reference: plain fortran standalone (crossing machinery off) ------- + self.do('generate %s --use_crossing=False' % process) + generated_process = self.cmd._curr_amps[0].get('process') seeded_phase_space = self._get_seeded_phase_space(generated_process) + + ref_root = pjoin(self.tmpdir, 'standalone_plain') + self.do('output standalone %s -f' % ref_root) + ref_sub = self._get_single_subprocess_dir(pjoin(ref_root, 'SubProcesses')) + ref_rows, printed_phase_space = self._run_standalone(ref_sub) self._assert_phase_space_reasonable( - printed_phase_space, seeded_phase_space, standalone_dir) - madevent_by_iflav = self._run_hacked_madevent(madevent_dir, seeded_phase_space) - - self.assertTrue(len(standalone_rows) <= len(madevent_by_iflav), - 'Flavor-count mismatch for %s: standalone=%s madevent=%s' - % (process, len(standalone_rows), len(madevent_by_iflav))) - - for iflav, standalone_row in enumerate(standalone_rows, start=1): - self.assertIn(iflav, madevent_by_iflav, - 'Missing madevent flavor index %s for %s' % (iflav, process)) - standalone_me = standalone_row['value'] - madevent_me = madevent_by_iflav[iflav] - scale = max(abs(standalone_me), abs(madevent_me), 1e-99) - misc.sprint('flavor=%s: diff=%f%%'%( - standalone_row['pdg'], 100 * abs(standalone_me - madevent_me) / scale if scale != 0 else 0)) + printed_phase_space, seeded_phase_space, ref_sub) + reference = self._rows_by_pdg(ref_rows, ref_sub) + + # -- (1) fortran madevent, ungrouped, crossing off (the original check) - + # madevent enumerates flavors in the same order as the standalone check + # (its GET_FLAVOR returns group indices, not PDGs, so it is matched to + # the reference by that shared IFLAV order rather than by PDG). + me_root = pjoin(self.tmpdir, 'madevent_plain') + self.do('output madevent %s -f' % me_root) + me_sub = self._get_single_subprocess_dir(pjoin(me_root, 'SubProcesses')) + me_by_iflav = self._run_hacked_madevent(me_root, me_sub, seeded_phase_space) + self._compare_by_iflav( + process, 'madevent (ungrouped, crossing off)', + ref_rows, me_by_iflav, tolerance) + + # -- (2) fortran standalone WITH crossing ------------------------------- + self.do('generate %s' % process) + sacross_root = pjoin(self.tmpdir, 'standalone_crossing') + self.do('output standalone %s -f' % sacross_root) + sacross_sub = self._get_single_subprocess_dir( + pjoin(sacross_root, 'SubProcesses')) + sacross_rows, _ = self._run_standalone(sacross_sub) + self._compare_to_reference( + process, 'standalone (crossing on)', + reference, self._rows_by_pdg(sacross_rows, sacross_sub), tolerance) + + # -- (3) fortran madevent, grouped, WITH crossing (MEGroup) ------------- + self.do('set group_subprocesses True') + self.do('generate %s' % process) + meg_root = pjoin(self.tmpdir, 'madevent_group_crossing') + self.do('output madevent %s -f' % meg_root) + self.do('set group_subprocesses False') + meg_sub = self._get_single_subprocess_dir(pjoin(meg_root, 'SubProcesses')) + meg_by_iflav = self._run_hacked_madevent( + meg_root, meg_sub, seeded_phase_space, + smatrix_name='SMATRIX1', make_target='madevent_forhel') + self._compare_by_iflav( + process, 'madevent (grouped, crossing on)', + ref_rows, meg_by_iflav, tolerance) + + # -- (4) standalone_mg7 (madmatrix / cudacpp CPU-SIMD) ------------------ + # Skipped (not failed) if no C++ compiler or the madmatrix build + # toolchain is unavailable. Matched by flavor order like madevent: the + # extended flavor id is cross*nflav+flav, so the base flavors are ids + # 0..nflav-1, in the same order as the standalone check. + mg7_by_iflav = self._run_standalone_mg7(process, seeded_phase_space, ref_rows) + if mg7_by_iflav is not None: + self._compare_by_iflav( + process, 'standalone_mg7', ref_rows, mg7_by_iflav, tolerance) + + def _rows_by_pdg(self, rows, subproc_dir): + """{PDG tuple -> matrix element} from _extract_standalone_flavors rows.""" + by_pdg = {} + for row in rows: + by_pdg[tuple(row['pdg'])] = row['value'] + self.assertEqual(len(by_pdg), len(rows), + 'Duplicate PDG flavor rows in %s' % subproc_dir) + return by_pdg + + def _compare_to_reference(self, process, label, reference, other, tolerance): + """Assert `other` reproduces every reference flavor (matched by PDG).""" + self.assertTrue(other, 'No matrix elements produced by %s for %s' + % (label, process)) + for pdg, ref_me in reference.items(): + self.assertIn(pdg, other, + 'Flavor %s missing from %s for %s' % (pdg, label, process)) + other_me = other[pdg] + scale = max(abs(ref_me), abs(other_me), 1e-99) + rel = abs(ref_me - other_me) / scale + logger.debug('%s flavor=%s: diff=%f%%', label, pdg, 100 * rel) self.assertLessEqual( - abs(standalone_me - madevent_me) / scale, - tolerance, - 'Incompatible matrix elements for %s flavor=%s iflav=%s: standalone=%s madevent=%s' - % (process, standalone_row['pdg'], iflav, standalone_me, madevent_me)) + rel, tolerance, + 'Incompatible matrix elements for %s flavor=%s (%s): ' + 'reference=%s %s=%s' + % (process, pdg, label, ref_me, label, other_me)) def _get_single_subprocess_dir(self, root_dir): subproc_dirs = [pjoin(root_dir, name) for name in sorted(os.listdir(root_dir)) @@ -157,22 +224,129 @@ def _assert_phase_space_reasonable(self, printed, seeded, subproc_dir): 'printed=%s seeded=%s' % (subproc_dir, ipart, icomp, printed_val, seeded_val)) - def _run_hacked_madevent(self, subproc_dir, phase_space): - source_dir = pjoin(self.madevent_dir, 'Source') + def _compare_by_iflav(self, process, label, ref_rows, by_iflav, tolerance): + """Assert a madevent backend reproduces the reference, matched by IFLAV. + + The standalone check loops flavors in the same order that the madevent + driver loops IFLAV, so reference row i (1-based) is madevent IFLAV i. + A grouped/crossing madevent may expose extra flavors past the reference + count; only the reference flavors are required to agree. + """ + self.assertTrue(by_iflav, 'No matrix elements produced by %s for %s' + % (label, process)) + for iflav, row in enumerate(ref_rows, start=1): + self.assertIn(iflav, by_iflav, + 'Missing IFLAV=%s (flavor %s) from %s for %s' + % (iflav, row['pdg'], label, process)) + ref_me = row['value'] + other_me = by_iflav[iflav] + scale = max(abs(ref_me), abs(other_me), 1e-99) + rel = abs(ref_me - other_me) / scale + logger.debug('%s flavor=%s: diff=%f%%', label, row['pdg'], 100 * rel) + self.assertLessEqual( + rel, tolerance, + 'Incompatible matrix elements for %s flavor=%s iflav=%s (%s): ' + 'reference=%s %s=%s' + % (process, row['pdg'], iflav, label, ref_me, label, other_me)) + + def _run_hacked_madevent(self, madevent_root, subproc_dir, phase_space, + smatrix_name='SMATRIX', make_target='madevent'): + # The grouped exporter names its per-subprocess routine SMATRIX1 and + # hides it behind helicity recycling (SMATRIX1 lives only in + # matrix1_orig.f -> the 'madevent_forhel' target). The test processes + # all group into a single subprocess (MAXSPROC=1), required by the + # single-SMATRIX driver below. + maxamps = pjoin(subproc_dir, 'maxamps.inc') + if os.path.isfile(maxamps): + match = re.search(r'MAXSPROC\s*=\s*(\d+)', open(maxamps).read()) + if match: + self.assertEqual(int(match.group(1)), 1, + 'Driver assumes MAXSPROC=1 in %s' % subproc_dir) + source_dir = pjoin(madevent_root, 'Source') retcode = self._call_with_optional_redirection(['make'], source_dir) self.assertEqual(retcode, 0, 'Failed to compile MadEvent source in %s' % source_dir) - self._write_hacked_driver(pjoin(subproc_dir, 'driver.f'), phase_space) + self._write_hacked_driver(pjoin(subproc_dir, 'driver.f'), phase_space, + smatrix_name) - retcode = self._call_with_optional_redirection(['make', 'madevent'], subproc_dir) - self.assertEqual(retcode, 0, 'Failed to compile hacked madevent in %s' % subproc_dir) + retcode = self._call_with_optional_redirection(['make', make_target], subproc_dir) + self.assertEqual(retcode, 0, + 'Failed to compile hacked madevent (%s) in %s' + % (make_target, subproc_dir)) - output = subprocess.Popen(['./madevent'], + output = subprocess.Popen(['./' + make_target], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=subproc_dir).communicate()[0].decode() return self._extract_madevent_by_iflav(output, subproc_dir) + def _run_standalone_mg7(self, process, phase_space, ref_rows): + """{IFLAV -> matrix element} for standalone_mg7 at the seeded momenta. + + Returns None (skip) if there is no C++ compiler or the madmatrix build + toolchain cannot build check_sa.exe. check_sa.exe reads the external + momenta from an LHE file (-e), so the same seeded point is used as for + the fortran backends; the base flavors are the extended ids 0..nflav-1. + """ + if not shutil.which(os.environ.get('CXX', 'g++')): + return None + outdir = pjoin(self.tmpdir, 'standalone_mg7') + self.do('generate %s' % process) + try: + self.do('output standalone_mg7 %s -f' % outdir) + except Exception: + return None + pdir = self._get_single_subprocess_dir(pjoin(outdir, 'SubProcesses')) + + nevt = 8 + lhe = pjoin(pdir, 'seeded.lhe') + self._write_lhe_events(lhe, phase_space, nevt) + + rc = self._call_with_optional_redirection( + ['make', '-j2', 'check_sa.exe'], pdir) + if rc != 0: + return None + + by_iflav = {} + for iflav in range(1, len(ref_rows) + 1): + flavor_id = iflav - 1 # extended id, cross=0 -> id = flavor (0-based) + output = subprocess.Popen( + ['./check_sa.exe', 'perf', '-v', '-f', str(flavor_id), + '-e', lhe, '1', str(nevt), '1'], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + cwd=pdir).communicate()[0].decode() + values = re.findall(r'Matrix element =\s*([-\d.eE+]+)', output) + self.assertTrue(values, + 'No matrix element from standalone_mg7 flavor id %s ' + 'for %s:\n%s' % (flavor_id, process, output)) + by_iflav[iflav] = float(values[0]) + return by_iflav + + def _write_lhe_events(self, path, phase_space, nevents): + """Write `nevents` identical minimal LHE events at `phase_space`. + + check_sa.exe only reads (E, px, py, pz) from each particle line; the + pdg/status/colour columns are placeholders. The momenta are replicated + across the SIMD page so every lane evaluates the seeded point. + """ + def as_float(value): + if isinstance(value, str): + return float(value.replace('d', 'e').replace('D', 'E')) + return float(value) + + npar = len(phase_space) + lines = [] + for _ in range(nevents): + lines.append('') + lines.append('%d 0 0.0 0.0 0.0 0.0' % npar) + for momentum in phase_space: + e, px, py, pz = (as_float(v) for v in momentum) + lines.append('1 1 0 0 0 0 %.17E %.17E %.17E %.17E 0.0' + % (px, py, pz, e)) + lines.append('') + with open(path, 'w') as fsock: + fsock.write('\n'.join(lines) + '\n') + def _call_with_optional_redirection(self, command, cwd): if logger.isEnabledFor(logging.INFO): return subprocess.call(command, cwd=cwd) @@ -181,6 +355,14 @@ def _call_with_optional_redirection(self, command, cwd): def _extract_standalone_flavors(self, output, subproc_dir): lines = output.splitlines() + # The standalone driver may append a crossing-symmetry demonstration + # (its own 'PDG ... / Matrix element = ...' lines for crossed + # processes). Those are not the primary per-flavor output this test + # compares against madevent, so stop at that section's header. + for cut, line in enumerate(lines): + if 'Crossing-symmetry example' in line: + lines = lines[:cut] + break standalone_rows = [] for index, line in enumerate(lines): stripped = line.strip() @@ -217,7 +399,7 @@ def _extract_madevent_by_iflav(self, output, subproc_dir): self.assertTrue(by_iflav, 'No madevent flavor matrix elements found in %s' % subproc_dir) return by_iflav - def _write_hacked_driver(self, driver_path, phase_space): + def _write_hacked_driver(self, driver_path, phase_space, smatrix_name='SMATRIX'): lines = [ ' PROGRAM DRIVER', ' use model_object', @@ -277,12 +459,13 @@ def _write_hacked_driver(self, driver_path, phase_space): (component, iparticle, formatted_value)) lines.extend([ + # The per-flavor PDG is read from leshouche.inc in python (madevent's + # GET_FLAVOR returns group indices, and its signature differs between + # the plain and grouped exporters), so the driver only emits IFLAV. ' DO IFLAV=1,MAXFLAVPERPROC', - ' CALL GET_FLAVOR(IFLAV,FLAVOR)', - ' CALL SMATRIX(P, IFLAV, 0.5D0, 0.5D0, 1, IVEC, ANS,', + ' CALL %s(P, IFLAV, 0.5D0, 0.5D0, 1, IVEC, ANS,' % smatrix_name, ' $ SELECTED_HEL, SELECTED_COL)', " WRITE(*,*) 'IFLAV = ', IFLAV", - " WRITE(*,*) 'PDG', (FLAVOR(J),J=1,NEXTERNAL)", " WRITE(*,*) 'Matrix element = ', ANS, ' GeV^',-(2*NEXTERNAL-8)", ' ENDDO', ' END', diff --git a/tests/input_files/IOTestsComparison/IOExportV4IOTest/export_matrix_element_v4_madevent_group/matrix1.f b/tests/input_files/IOTestsComparison/IOExportV4IOTest/export_matrix_element_v4_madevent_group/matrix1.f index 9805b01974..8cc3cfc7db 100644 --- a/tests/input_files/IOTestsComparison/IOExportV4IOTest/export_matrix_element_v4_madevent_group/matrix1.f +++ b/tests/input_files/IOTestsComparison/IOExportV4IOTest/export_matrix_element_v4_madevent_group/matrix1.f @@ -372,8 +372,9 @@ REAL*8 FUNCTION MATRIX1(P,NHEL,IFLAV, IHEL,AMP2, JAMP2, IVEC) INCLUDE 'genps.inc' INCLUDE 'nexternal.inc' INCLUDE 'maxamps.inc' - INTEGER NWAVEFUNCS, NCOLOR + INTEGER NWAVEFUNCS, NCOLOR, NCOLORFOLD PARAMETER (NWAVEFUNCS=5, NCOLOR=2) + PARAMETER (NCOLORFOLD=2) REAL*8 ZERO PARAMETER (ZERO=0D0) COMPLEX*16 IMAG1 @@ -397,10 +398,14 @@ REAL*8 FUNCTION MATRIX1(P,NHEL,IFLAV, IHEL,AMP2, JAMP2, IVEC) C LOCAL VARIABLES C INTEGER I,J,M,N - COMPLEX*16 ZTEMP, TMP_JAMP(0) - INTEGER CF(NCOLOR*(NCOLOR+1)/2) + COMPLEX*16 ZTEMP + COMPLEX*16 TMP_JAMP(0) + + INTEGER CF(NCOLORFOLD*(NCOLORFOLD+1)/2) INTEGER DENOM, CF_INDEX + COMMON /COLOR_MATRIX1/ CF,DENOM COMPLEX*16 AMP(NGRAPHS), JAMP(NCOLOR,NAMPSO) + TYPE(ALOHA) W(NWAVEFUNCS) C Needed for v4 models COMPLEX*16 DUM0,DUM1 @@ -448,11 +453,13 @@ REAL*8 FUNCTION MATRIX1(P,NHEL,IFLAV, IHEL,AMP2, JAMP2, IVEC) C 1 T(2,1) T(3,4) DATA (CF(I),I= 3, 3) /9/ C 1 T(2,4) T(3,1) + C ---------- C BEGIN CODE C ---------- IF (FIRST) THEN FIRST=.FALSE. + CALL INIT_CF1() IF(WZ.NE.0D0) THEN FK_WZ = SIGN(MAX(ABS(WZ), ABS(MZ*SMALL_WIDTH_TREATMENT)), WZ) ELSE @@ -510,12 +517,13 @@ REAL*8 FUNCTION MATRIX1(P,NHEL,IFLAV, IHEL,AMP2, JAMP2, IVEC) ENDDO ENDIF + MATRIX1 = 0.D0 DO M = 1, NAMPSO CF_INDEX = 0 - DO I = 1, NCOLOR + DO I = 1, NCOLORFOLD ZTEMP = (0.D0,0.D0) - DO J = I, NCOLOR + DO J = I, NCOLORFOLD CF_INDEX = CF_INDEX + 1 ZTEMP = ZTEMP + CF(CF_INDEX)*JAMP(J,M) ENDDO @@ -587,6 +595,10 @@ SUBROUTINE PRINT_ZERO_AMP_1() END + SUBROUTINE INIT_CF1() + RETURN + END + INTEGER FUNCTION BROKEN_SYM1(FLAV) INCLUDE 'nexternal.inc' INTEGER FLAV(NEXTERNAL) diff --git a/tests/input_files/IOTestsComparison/IOExportV4IOTest/export_matrix_element_v4_madevent_nogroup/matrix.f b/tests/input_files/IOTestsComparison/IOExportV4IOTest/export_matrix_element_v4_madevent_nogroup/matrix.f index c92934f27b..c2b32d5904 100644 --- a/tests/input_files/IOTestsComparison/IOExportV4IOTest/export_matrix_element_v4_madevent_nogroup/matrix.f +++ b/tests/input_files/IOTestsComparison/IOExportV4IOTest/export_matrix_element_v4_madevent_nogroup/matrix.f @@ -307,8 +307,9 @@ REAL*8 FUNCTION MATRIX(P,NHEL,IFLAV, IVEC) INCLUDE 'genps.inc' INCLUDE 'nexternal.inc' INCLUDE 'maxamps.inc' - INTEGER NWAVEFUNCS, NCOLOR + INTEGER NWAVEFUNCS, NCOLOR, NCOLORFOLD PARAMETER (NWAVEFUNCS=5, NCOLOR=2) + PARAMETER (NCOLORFOLD=2) REAL*8 ZERO PARAMETER (ZERO=0D0) COMPLEX*16 IMAG1 @@ -332,10 +333,14 @@ REAL*8 FUNCTION MATRIX(P,NHEL,IFLAV, IVEC) C LOCAL VARIABLES C INTEGER I,J,M,N - COMPLEX*16 ZTEMP, TMP_JAMP(0) - INTEGER CF(NCOLOR*(NCOLOR+1)) + COMPLEX*16 ZTEMP + COMPLEX*16 TMP_JAMP(0) + + INTEGER CF(NCOLORFOLD*(NCOLORFOLD+1)) INTEGER CF_INDEX,DENOM + COMMON /COLOR_MATRIX/ CF,DENOM COMPLEX*16 AMP(NGRAPHS), JAMP(NCOLOR,NAMPSO) + TYPE(ALOHA) W(NWAVEFUNCS) C Needed for v4 models COMPLEX*16 DUM0,DUM1 @@ -369,11 +374,13 @@ REAL*8 FUNCTION MATRIX(P,NHEL,IFLAV, IVEC) C 1 T(3,4,2,1) DATA (CF(I),I= 3, 3) /16/ C 1 T(4,3,2,1) + C ---------- C BEGIN CODE C ---------- IF (FIRST) THEN FIRST=.FALSE. + CALL INIT_CF() FK_ZERO = 0D0 ENDIF @@ -401,12 +408,13 @@ REAL*8 FUNCTION MATRIX(P,NHEL,IFLAV, IVEC) JAMP(2,1) = ((0.000000000000000D+00,1.000000000000000D+00)) $ *AMP(1)+AMP(2) + MATRIX = 0.D0 DO M = 1, NAMPSO CF_INDEX = 0 - DO I = 1, NCOLOR + DO I = 1, NCOLORFOLD ZTEMP = (0.D0,0.D0) - DO J = I, NCOLOR + DO J = I, NCOLORFOLD CF_INDEX = CF_INDEX +1 ZTEMP = ZTEMP + CF(CF_INDEX)*JAMP(J,M) ENDDO @@ -435,6 +443,10 @@ REAL*8 FUNCTION MATRIX(P,NHEL,IFLAV, IVEC) END + SUBROUTINE INIT_CF() + RETURN + END + INTEGER FUNCTION BROKEN_SYM(FLAV) INCLUDE 'nexternal.inc' INTEGER FLAV(NEXTERNAL) diff --git a/tests/input_files/IOTestsComparison/IOExportV4IOTest/export_matrix_element_v4_standalone/matrix.f b/tests/input_files/IOTestsComparison/IOExportV4IOTest/export_matrix_element_v4_standalone/matrix.f index 330526878a..7276a0dfc4 100644 --- a/tests/input_files/IOTestsComparison/IOExportV4IOTest/export_matrix_element_v4_standalone/matrix.f +++ b/tests/input_files/IOTestsComparison/IOExportV4IOTest/export_matrix_element_v4_standalone/matrix.f @@ -146,6 +146,7 @@ SUBROUTINE SMATRIX(P, FLAV_IDX, ANS) C LOGICAL IS_BORN_HEL_SELECTED INTEGER BROKEN_SYM + C ---------- C Check if helreset mode is on C --------- @@ -199,6 +200,7 @@ SUBROUTINE SMATRIX(P, FLAV_IDX, ANS) ENDDO ENDIF ANS = 0D0 + DO IHEL=1,NCOMB IF (USERHEL.EQ.-1.OR.USERHEL.EQ.IHEL) THEN IF (GOODHEL(IHEL,FLAV_IDX) .OR. NTRY(FLAV_IDX) .LT. @@ -275,7 +277,8 @@ REAL*8 FUNCTION MATRIX(P,NHEL,IC,FLAV_IDX) INTEGER CF(1) INTEGER DENOM COMMON /COLOR_MATRIX/ CF,DENOM - COMPLEX*16 AMP(NGRAPHS), JAMP(NCOLOR), TMP_JAMP(0) + COMPLEX*16 AMP(NGRAPHS), JAMP(NCOLOR) + COMPLEX*16 TMP_JAMP(0) TYPE(ALOHA) W(NWAVEFUNCS) COMPLEX*16 DUM0,DUM1 DATA DUM0, DUM1/(0D0, 0D0), (1D0, 0D0)/ @@ -460,7 +463,9 @@ SUBROUTINE GET_JAMP(AMP,JAMP) PARAMETER ( NCOLOR=1) COMPLEX*16 IMAG1 PARAMETER (IMAG1=(0D0,1D0)) - COMPLEX*16 AMP(NGRAPHS), JAMP(NCOLOR), TMP_JAMP(0) + COMPLEX*16 AMP(NGRAPHS), JAMP(NCOLOR) + COMPLEX*16 TMP_JAMP(0) + JAMP(1) = (-1.000000000000000D+00)*AMP(1)+(-1.000000000000000D $ +00)*AMP(2)+(-1.000000000000000D+00)*AMP(3)+( @@ -480,41 +485,78 @@ SUBROUTINE GET_MATRIX(JAMP,MATRIX) CF2PY INTENT(IN) :: JAMP - INTEGER NCOLOR + INTEGER NCOLOR, NCOLORFOLD PARAMETER (NCOLOR=1) + PARAMETER (NCOLORFOLD=1) REAL*8 ZERO,MATRIX PARAMETER (ZERO=0D0) C C LOCAL VARIABLES C - INTEGER I,J - COMPLEX*16 ZTEMP + INTEGER I,J,NJ,NB + COMPLEX*16 ZTEMP,Z1,Z2,Z3,Z4 INTEGER CF_INDEX - INTEGER CF(NCOLOR*(NCOLOR+1)/2) + INTEGER CF(NCOLORFOLD*(NCOLORFOLD+1)/2) INTEGER DENOM COMMON /COLOR_MATRIX/ CF,DENOM - COMPLEX*16 JAMP(NCOLOR), TMP_JAMP(0) + COMPLEX*16 JAMP(NCOLOR) + COMPLEX*16 JFOLD(NCOLORFOLD) + INTEGER ICF + + COMPLEX*16 TMP_JAMP(0) COMPLEX*16 DUM0,DUM1 DATA DUM0, DUM1/(0D0, 0D0), (1D0, 0D0)/ C C COLOR DATA C + CALL INIT_CF() +C Reversing a color flow gives the same one back up to an overall +C sign, so only one of each pair carries anything: the sum below +C runs +C over those, against a color matrix folded onto them. + DO ICF = 1, NCOLOR + JFOLD(ICF) = JAMP(ICF) + ENDDO MATRIX = 0.D0 CF_INDEX = 0 - DO I = 1, NCOLOR - ZTEMP = (0.D0,0.D0) - DO J = I, NCOLOR - CF_INDEX = CF_INDEX + 1 - ZTEMP = ZTEMP + CF(CF_INDEX)*JAMP(J) +C Four accumulators, not one: with a single one every +C term waits for the one before it to come out of the +C adder, and that latency is what the loop spends its +C time on. No compiler does this by itself, since it +C changes the order the terms are summed in. + DO I = 1, NCOLORFOLD + Z1 = (0.D0,0.D0) + Z2 = (0.D0,0.D0) + Z3 = (0.D0,0.D0) + Z4 = (0.D0,0.D0) + NJ = NCOLORFOLD - I + 1 + NB = (NJ/4)*4 + DO J = 0, NB-4, 4 + Z1 = Z1 + CF(CF_INDEX+J+1)*JFOLD(I+J) + Z2 = Z2 + CF(CF_INDEX+J+2)*JFOLD(I+J+1) + Z3 = Z3 + CF(CF_INDEX+J+3)*JFOLD(I+J+2) + Z4 = Z4 + CF(CF_INDEX+J+4)*JFOLD(I+J+3) + ENDDO + ZTEMP = (Z1+Z2)+(Z3+Z4) + DO J = NB, NJ-1 + ZTEMP = ZTEMP + CF(CF_INDEX+J+1)*JFOLD(I+J) ENDDO - MATRIX = MATRIX+ZTEMP*DCONJG(JAMP(I))/DENOM + CF_INDEX = CF_INDEX + NJ + MATRIX = MATRIX+ZTEMP*DCONJG(JFOLD(I))/DENOM ENDDO END + SUBROUTINE INIT_CF() + RETURN + END + + + + SUBROUTINE GET_INTER(JAMP_1,JAMP_2, INTER) @@ -535,6 +577,7 @@ SUBROUTINE GET_INTER(JAMP_1,JAMP_2, INTER) C COLOR DATA C + CALL INIT_CF() INTER = (0.D0,0.D0) CF_INDEX = 0 diff --git a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_Cppwriter_C/ffv1c1_1.c b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_Cppwriter_C/ffv1c1_1.c index 31eac5225c..cf3e1b34ee 100644 --- a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_Cppwriter_C/ffv1c1_1.c +++ b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_Cppwriter_C/ffv1c1_1.c @@ -14,7 +14,11 @@ P2[1] = -F2.p[1]; P2[2] = -F2.p[2]; P2[3] = -F2.p[3]; F2.flv_index = F1.flv_index; - denom = COUP/((P2[0]*P2[0])-(P2[1]*P2[1])-(P2[2]*P2[2])-(P2[3]*P2[3]) - M2 * (M2 -cI* W2)); + if ((P2[0]*P2[0])-(P2[1]*P2[1])-(P2[2]*P2[2])-(P2[3]*P2[3]) > 0.){ + denom = COUP/((P2[0]*P2[0])-(P2[1]*P2[1])-(P2[2]*P2[2])-(P2[3]*P2[3]) - M2 * (M2 -cI* W2)); + } else { + denom = COUP/((P2[0]*P2[0])-(P2[1]*P2[1])-(P2[2]*P2[2])-(P2[3]*P2[3]) - M2*M2); + } F2.W[0]= denom*(-cI)*(F1.W[0]*(P2[0]*(-V3.W[0]+V3.W[3])+(P2[1]*(V3.W[1]-cI*(V3.W[2]))+(P2[2]*(+cI*(V3.W[1])+V3.W[2])+P2[3]*(-V3.W[0]+V3.W[3]))))+(F1.W[1]*(P2[0]*(V3.W[1]+cI*(V3.W[2]))+(P2[1]*(-1.)*(V3.W[0]+V3.W[3])+(P2[2]*(-1.)*(+cI*(V3.W[0]+V3.W[3]))+P2[3]*(V3.W[1]+cI*(V3.W[2])))))+M2*(F1.W[2]*(V3.W[0]+V3.W[3])+F1.W[3]*(V3.W[1]+cI*(V3.W[2]))))); F2.W[1]= denom*cI*(F1.W[0]*(P2[0]*(-V3.W[1]+cI*(V3.W[2]))+(P2[1]*(V3.W[0]-V3.W[3])+(P2[2]*(-cI*(V3.W[0])+cI*(V3.W[3]))+P2[3]*(V3.W[1]-cI*(V3.W[2])))))+(F1.W[1]*(P2[0]*(V3.W[0]+V3.W[3])+(P2[1]*(-1.)*(V3.W[1]+cI*(V3.W[2]))+(P2[2]*(+cI*(V3.W[1])-V3.W[2])-P2[3]*(V3.W[0]+V3.W[3]))))+M2*(F1.W[2]*(-V3.W[1]+cI*(V3.W[2]))+F1.W[3]*(-V3.W[0]+V3.W[3])))); F2.W[2]= denom*cI*(F1.W[2]*(P2[0]*(V3.W[0]+V3.W[3])+(P2[1]*(-V3.W[1]+cI*(V3.W[2]))+(P2[2]*(-1.)*(+cI*(V3.W[1])+V3.W[2])-P2[3]*(V3.W[0]+V3.W[3]))))+(F1.W[3]*(P2[0]*(V3.W[1]+cI*(V3.W[2]))+(P2[1]*(-V3.W[0]+V3.W[3])+(P2[2]*(-cI*(V3.W[0])+cI*(V3.W[3]))-P2[3]*(V3.W[1]+cI*(V3.W[2])))))+M2*(F1.W[0]*(-V3.W[0]+V3.W[3])+F1.W[1]*(V3.W[1]+cI*(V3.W[2]))))); diff --git a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_Cppwriter_C/ffv1c1_2.c b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_Cppwriter_C/ffv1c1_2.c index a417173bf6..30d5727b98 100644 --- a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_Cppwriter_C/ffv1c1_2.c +++ b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_Cppwriter_C/ffv1c1_2.c @@ -14,7 +14,11 @@ P1[1] = -F1.p[1]; P1[2] = -F1.p[2]; P1[3] = -F1.p[3]; F1.flv_index = F2.flv_index; - denom = COUP/((P1[0]*P1[0])-(P1[1]*P1[1])-(P1[2]*P1[2])-(P1[3]*P1[3]) - M1 * (M1 -cI* W1)); + if ((P1[0]*P1[0])-(P1[1]*P1[1])-(P1[2]*P1[2])-(P1[3]*P1[3]) > 0.){ + denom = COUP/((P1[0]*P1[0])-(P1[1]*P1[1])-(P1[2]*P1[2])-(P1[3]*P1[3]) - M1 * (M1 -cI* W1)); + } else { + denom = COUP/((P1[0]*P1[0])-(P1[1]*P1[1])-(P1[2]*P1[2])-(P1[3]*P1[3]) - M1*M1); + } F1.W[0]= denom*(-cI)*(F2.W[0]*(P1[0]*(V3.W[0]+V3.W[3])+(P1[1]*(-1.)*(V3.W[1]+cI*(V3.W[2]))+(P1[2]*(+cI*(V3.W[1])-V3.W[2])-P1[3]*(V3.W[0]+V3.W[3]))))+(F2.W[1]*(P1[0]*(V3.W[1]-cI*(V3.W[2]))+(P1[1]*(-V3.W[0]+V3.W[3])+(P1[2]*(+cI*(V3.W[0])-cI*(V3.W[3]))+P1[3]*(-V3.W[1]+cI*(V3.W[2])))))+M1*(F2.W[2]*(V3.W[0]-V3.W[3])+F2.W[3]*(-V3.W[1]+cI*(V3.W[2]))))); F1.W[1]= denom*cI*(F2.W[0]*(P1[0]*(-1.)*(V3.W[1]+cI*(V3.W[2]))+(P1[1]*(V3.W[0]+V3.W[3])+(P1[2]*(+cI*(V3.W[0]+V3.W[3]))-P1[3]*(V3.W[1]+cI*(V3.W[2])))))+(F2.W[1]*(P1[0]*(-V3.W[0]+V3.W[3])+(P1[1]*(V3.W[1]-cI*(V3.W[2]))+(P1[2]*(+cI*(V3.W[1])+V3.W[2])+P1[3]*(-V3.W[0]+V3.W[3]))))+M1*(F2.W[2]*(V3.W[1]+cI*(V3.W[2]))-F2.W[3]*(V3.W[0]+V3.W[3])))); F1.W[2]= denom*cI*(F2.W[2]*(P1[0]*(-V3.W[0]+V3.W[3])+(P1[1]*(V3.W[1]+cI*(V3.W[2]))+(P1[2]*(-cI*(V3.W[1])+V3.W[2])+P1[3]*(-V3.W[0]+V3.W[3]))))+(F2.W[3]*(P1[0]*(V3.W[1]-cI*(V3.W[2]))+(P1[1]*(-1.)*(V3.W[0]+V3.W[3])+(P1[2]*(+cI*(V3.W[0]+V3.W[3]))+P1[3]*(V3.W[1]-cI*(V3.W[2])))))+M1*(F2.W[0]*(-1.)*(V3.W[0]+V3.W[3])+F2.W[1]*(-V3.W[1]+cI*(V3.W[2]))))); diff --git a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_F77writer_feynman/ffv1_3.f b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_F77writer_feynman/ffv1_3.f index 0b282e87df..d7e4864717 100644 --- a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_F77writer_feynman/ffv1_3.f +++ b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_F77writer_feynman/ffv1_3.f @@ -21,7 +21,11 @@ subroutine FFV1_3(F1, F2, COUP, M3, W3,V3) V3%W(:) = (0d0,0d0) return endif - denom = COUP/(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2 - M3 * (M3 -CI* W3)) + if (dble(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2).gt.0d0) then + denom = COUP/(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2 - M3 * (M3 -CI* W3)) + else + denom = COUP/(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2 - M3**2) + endif V3%W(1)= denom*(-CI)*(F2 % W(3)*F1 % W(1)+F2 % W(4)*F1 % W(2)+F2 % W(1)*F1 % W(3)+F2 % W(2)*F1 % W(4)) V3%W(2)= denom*(-CI)*(-F2 % W(4)*F1 % W(1)-F2 % W(3)*F1 % W(2)+F2 % W(2)*F1 % W(3)+F2 % W(1)*F1 % W(4)) V3%W(3)= denom*(-CI)*(-CI*(F2 % W(4)*F1 % W(1)+F2 % W(1)*F1 % W(4))+CI*(F2 % W(3)*F1 % W(2)+F2 % W(2)*F1 % W(3))) diff --git a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_Fortranwriter_spin3half/rfsc1_1.f b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_Fortranwriter_spin3half/rfsc1_1.f index 1c5515cf7a..1c1acb5eec 100644 --- a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_Fortranwriter_spin3half/rfsc1_1.f +++ b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_Fortranwriter_spin3half/rfsc1_1.f @@ -14,7 +14,11 @@ subroutine RFSC1_1(R1, S3, COUP, M2, W2,F2) complex*16 denom F2%P(:) = +R1%P(:)+S3%P(:) P2(:) = -F2 % P (:) - denom = COUP/(P2(0)**2-P2(1)**2-P2(2)**2-P2(3)**2 - M2 * (M2 -CI* W2)) + if (dble(P2(0)**2-P2(1)**2-P2(2)**2-P2(3)**2).gt.0d0) then + denom = COUP/(P2(0)**2-P2(1)**2-P2(2)**2-P2(3)**2 - M2 * (M2 -CI* W2)) + else + denom = COUP/(P2(0)**2-P2(1)**2-P2(2)**2-P2(3)**2 - M2**2) + endif F2%W(1)= denom*CI * S3 % W(1)*(P2(0)*(-1d0)*(-R1 % W(1)+R1 % W(6)+R1 % W(13)+CI*(R1 % W(10)))+(P2(1)*(R1 % W(2)+R1 % W(14)-R1 % W(5)+CI*(R1 % W(9)))+(P2(2)*(+CI*(R1 % W(2)+R1 % W(14))-CI*(R1 % W(5))-R1 % W(9))-P2(3)*(-R1 % W(1)+R1 % W(6)+R1 % W(13)+CI*(R1 % W(10)))))) F2%W(2)= denom*CI * S3 % W(1)*(P2(0)*(R1 % W(2)+R1 % W(14)-R1 % W(5)+CI*(R1 % W(9)))+(P2(1)*(-1d0)*(-R1 % W(1)+R1 % W(6)+R1 % W(13)+CI*(R1 % W(10)))+(P2(2)*(-CI*(R1 % W(1))+CI*(R1 % W(6)+R1 % W(13))-R1 % W(10))-P2(3)*(R1 % W(2)+R1 % W(14)-R1 % W(5)+CI*(R1 % W(9)))))) F2%W(3)= denom*CI * M2*S3 % W(1)*(-R1 % W(1)+R1 % W(6)+R1 % W(13)+CI*(R1 % W(10))) diff --git a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_Fortranwriter_spin3half/rfsc1_2.f b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_Fortranwriter_spin3half/rfsc1_2.f index d308376acc..07c38452ca 100644 --- a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_Fortranwriter_spin3half/rfsc1_2.f +++ b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_Fortranwriter_spin3half/rfsc1_2.f @@ -17,7 +17,11 @@ subroutine RFSC1_2(F2, S3, COUP, M1, W1,R1) if (M1.ne.0d0) OM1=1d0/M1**2 R1%P(:) = +F2%P(:)+S3%P(:) P1(:) = -R1 % P (:) - denom = COUP/(P1(0)**2-P1(1)**2-P1(2)**2-P1(3)**2 - M1 * (M1 -CI* W1)) + if (dble(P1(0)**2-P1(1)**2-P1(2)**2-P1(3)**2).gt.0d0) then + denom = COUP/(P1(0)**2-P1(1)**2-P1(2)**2-P1(3)**2 - M1 * (M1 -CI* W1)) + else + denom = COUP/(P1(0)**2-P1(1)**2-P1(2)**2-P1(3)**2 - M1**2) + endif R1%W(1)= denom*1d0/3d0 * CI * M1*S3 % W(1)*(OM1*(P1(0)*(F2 % W(3)*(M1*M1*OM1*(-P1(0)+P1(3))+(+2d0*(P1(0))-P1(3)))+F2 % W(4)*(M1*M1*OM1*(P1(1)-CI*(P1(2)))+(-P1(1)+CI*(P1(2)))))-F2 % W(3)*(P1(3)*P1(3)+P1(1)*P1(1)+P1(2)*P1(2)))-F2 % W(3)) R1%W(2)= denom*1d0/3d0 * CI * M1*S3 % W(1)*(OM1*(P1(0)*(F2 % W(3)*(M1*M1*OM1*(P1(1)+CI*(P1(2)))+(-P1(1)-CI*(P1(2))))+F2 % W(4)*(M1*-M1*OM1*(P1(0)+P1(3))+(+2d0*(P1(0))+P1(3))))-F2 % W(4)*(P1(1)*P1(1)+P1(2)*P1(2)+P1(3)*P1(3)))-F2 % W(4)) R1%W(3)= denom*CI * S3 % W(1)*(F2 % W(3)*(OM1*(P1(0)*(M1*M1*(OM1*(-1d0/3d0)*(-P1(0)*P1(0)+P1(3)*P1(3)+P1(1)*P1(1)+P1(2)*P1(2))+ -5d0/3d0)+(-P1(0)*P1(0)+P1(3)*P1(3)+P1(1)*P1(1)+P1(2)*P1(2)))+1d0/3d0*(P1(3)*M1*M1))+(+7d0/3d0*(P1(0))-1d0/3d0*(P1(3))))+F2 % W(4)*(M1*1d0/3d0 * M1*OM1*(P1(1)-CI*(P1(2)))+(-1d0/3d0*(P1(1))+1d0/3d0 * CI*(P1(2))))) diff --git a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_aloha_MP_mode/ffvm_3.f b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_aloha_MP_mode/ffvm_3.f index c9d2c1b4d6..09e4353c22 100644 --- a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_aloha_MP_mode/ffvm_3.f +++ b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_aloha_MP_mode/ffvm_3.f @@ -26,7 +26,11 @@ subroutine FFVM_3(F1, F2, COUP, M3, W3,V3) return endif TMP0 = (F1 % W(3)*(F2 % W(1)*(P3(0)+P3(3))+F2 % W(2)*(P3(1)-CI*(P3(2))))+F1 % W(4)*(F2 % W(1)*(P3(1)+CI*(P3(2)))+F2 % W(2)*(P3(0)-P3(3)))) - denom = COUP/(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2 - M3 * (M3 -CI* W3)) + if (dble(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2).gt.0d0) then + denom = COUP/(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2 - M3 * (M3 -CI* W3)) + else + denom = COUP/(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2 - M3**2) + endif V3%W(1)= denom*(-CI)*(F2 % W(1)*F1 % W(3)+F2 % W(2)*F1 % W(4)-P3(0)*OM3*TMP0) V3%W(2)= denom*(-CI)*(-F2 % W(2)*F1 % W(3)-F2 % W(1)*F1 % W(4)-P3(1)*OM3*TMP0) V3%W(3)= denom*(-CI)*(+CI*(F2 % W(2)*F1 % W(3))-CI*(F2 % W(1)*F1 % W(4))-P3(2)*OM3*TMP0) @@ -62,7 +66,11 @@ subroutine MP_FFVM_3(F1, F2, COUP, M3, W3,V3) return endif TMP0 = (F1 % W(3)*(F2 % W(1)*(P3(0)+P3(3))+F2 % W(2)*(P3(1)-CI*(P3(2))))+F1 % W(4)*(F2 % W(1)*(P3(1)+CI*(P3(2)))+F2 % W(2)*(P3(0)-P3(3)))) - denom = COUP/(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2 - M3 * (M3 -CI* W3)) + if (dble(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2).gt.0d0) then + denom = COUP/(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2 - M3 * (M3 -CI* W3)) + else + denom = COUP/(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2 - M3**2) + endif V3%W(1)= denom*(-CI)*(F2 % W(1)*F1 % W(3)+F2 % W(2)*F1 % W(4)-P3(0)*OM3*TMP0) V3%W(2)= denom*(-CI)*(-F2 % W(2)*F1 % W(3)-F2 % W(1)*F1 % W(4)-P3(1)*OM3*TMP0) V3%W(3)= denom*(-CI)*(+CI*(F2 % W(2)*F1 % W(3))-CI*(F2 % W(1)*F1 % W(4))-P3(2)*OM3*TMP0) diff --git a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_fortranwriter_C/ffv1c1_1.f b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_fortranwriter_C/ffv1c1_1.f index b4a5e5d977..c97240c9ef 100644 --- a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_fortranwriter_C/ffv1c1_1.f +++ b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_fortranwriter_C/ffv1c1_1.f @@ -16,7 +16,11 @@ subroutine FFV1C1_1(F1, V3, COUP, M2, W2,F2) F2%P(:) = +F1%P(:)+V3%P(:) P2(:) = -F2 % P (:) F2 % FLV_INDEX = F1 % FLV_INDEX - denom = COUP/(P2(0)**2-P2(1)**2-P2(2)**2-P2(3)**2 - M2 * (M2 -CI* W2)) + if (dble(P2(0)**2-P2(1)**2-P2(2)**2-P2(3)**2).gt.0d0) then + denom = COUP/(P2(0)**2-P2(1)**2-P2(2)**2-P2(3)**2 - M2 * (M2 -CI* W2)) + else + denom = COUP/(P2(0)**2-P2(1)**2-P2(2)**2-P2(3)**2 - M2**2) + endif F2%W(1)= denom*(-CI)*(F1 % W(1)*(P2(0)*(-V3 % W(1)+V3 % W(4))+(P2(1)*(V3 % W(2)-CI*(V3 % W(3)))+(P2(2)*(+CI*(V3 % W(2))+V3 % W(3))+P2(3)*(-V3 % W(1)+V3 % W(4)))))+(F1 % W(2)*(P2(0)*(V3 % W(2)+CI*(V3 % W(3)))+(P2(1)*(-1d0)*(V3 % W(1)+V3 % W(4))+(P2(2)*(-1d0)*(+CI*(V3 % W(1)+V3 % W(4)))+P2(3)*(V3 % W(2)+CI*(V3 % W(3))))))+M2*(F1 % W(3)*(V3 % W(1)+V3 % W(4))+F1 % W(4)*(V3 % W(2)+CI*(V3 % W(3)))))) F2%W(2)= denom*CI*(F1 % W(1)*(P2(0)*(-V3 % W(2)+CI*(V3 % W(3)))+(P2(1)*(V3 % W(1)-V3 % W(4))+(P2(2)*(-CI*(V3 % W(1))+CI*(V3 % W(4)))+P2(3)*(V3 % W(2)-CI*(V3 % W(3))))))+(F1 % W(2)*(P2(0)*(V3 % W(1)+V3 % W(4))+(P2(1)*(-1d0)*(V3 % W(2)+CI*(V3 % W(3)))+(P2(2)*(+CI*(V3 % W(2))-V3 % W(3))-P2(3)*(V3 % W(1)+V3 % W(4)))))+M2*(F1 % W(3)*(-V3 % W(2)+CI*(V3 % W(3)))+F1 % W(4)*(-V3 % W(1)+V3 % W(4))))) F2%W(3)= denom*CI*(F1 % W(3)*(P2(0)*(V3 % W(1)+V3 % W(4))+(P2(1)*(-V3 % W(2)+CI*(V3 % W(3)))+(P2(2)*(-1d0)*(+CI*(V3 % W(2))+V3 % W(3))-P2(3)*(V3 % W(1)+V3 % W(4)))))+(F1 % W(4)*(P2(0)*(V3 % W(2)+CI*(V3 % W(3)))+(P2(1)*(-V3 % W(1)+V3 % W(4))+(P2(2)*(-CI*(V3 % W(1))+CI*(V3 % W(4)))-P2(3)*(V3 % W(2)+CI*(V3 % W(3))))))+M2*(F1 % W(1)*(-V3 % W(1)+V3 % W(4))+F1 % W(2)*(V3 % W(2)+CI*(V3 % W(3)))))) diff --git a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_fortranwriter_C/ffv1c1_2.f b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_fortranwriter_C/ffv1c1_2.f index 712a0706a4..ae4e0486c5 100644 --- a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_fortranwriter_C/ffv1c1_2.f +++ b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_fortranwriter_C/ffv1c1_2.f @@ -16,7 +16,11 @@ subroutine FFV1C1_2(F2, V3, COUP, M1, W1,F1) F1%P(:) = +F2%P(:)+V3%P(:) P1(:) = -F1 % P (:) F1 % FLV_INDEX = F2 % FLV_INDEX - denom = COUP/(P1(0)**2-P1(1)**2-P1(2)**2-P1(3)**2 - M1 * (M1 -CI* W1)) + if (dble(P1(0)**2-P1(1)**2-P1(2)**2-P1(3)**2).gt.0d0) then + denom = COUP/(P1(0)**2-P1(1)**2-P1(2)**2-P1(3)**2 - M1 * (M1 -CI* W1)) + else + denom = COUP/(P1(0)**2-P1(1)**2-P1(2)**2-P1(3)**2 - M1**2) + endif F1%W(1)= denom*(-CI)*(F2 % W(1)*(P1(0)*(V3 % W(1)+V3 % W(4))+(P1(1)*(-1d0)*(V3 % W(2)+CI*(V3 % W(3)))+(P1(2)*(+CI*(V3 % W(2))-V3 % W(3))-P1(3)*(V3 % W(1)+V3 % W(4)))))+(F2 % W(2)*(P1(0)*(V3 % W(2)-CI*(V3 % W(3)))+(P1(1)*(-V3 % W(1)+V3 % W(4))+(P1(2)*(+CI*(V3 % W(1))-CI*(V3 % W(4)))+P1(3)*(-V3 % W(2)+CI*(V3 % W(3))))))+M1*(F2 % W(3)*(V3 % W(1)-V3 % W(4))+F2 % W(4)*(-V3 % W(2)+CI*(V3 % W(3)))))) F1%W(2)= denom*CI*(F2 % W(1)*(P1(0)*(-1d0)*(V3 % W(2)+CI*(V3 % W(3)))+(P1(1)*(V3 % W(1)+V3 % W(4))+(P1(2)*(+CI*(V3 % W(1)+V3 % W(4)))-P1(3)*(V3 % W(2)+CI*(V3 % W(3))))))+(F2 % W(2)*(P1(0)*(-V3 % W(1)+V3 % W(4))+(P1(1)*(V3 % W(2)-CI*(V3 % W(3)))+(P1(2)*(+CI*(V3 % W(2))+V3 % W(3))+P1(3)*(-V3 % W(1)+V3 % W(4)))))+M1*(F2 % W(3)*(V3 % W(2)+CI*(V3 % W(3)))-F2 % W(4)*(V3 % W(1)+V3 % W(4))))) F1%W(3)= denom*CI*(F2 % W(3)*(P1(0)*(-V3 % W(1)+V3 % W(4))+(P1(1)*(V3 % W(2)+CI*(V3 % W(3)))+(P1(2)*(-CI*(V3 % W(2))+V3 % W(3))+P1(3)*(-V3 % W(1)+V3 % W(4)))))+(F2 % W(4)*(P1(0)*(V3 % W(2)-CI*(V3 % W(3)))+(P1(1)*(-1d0)*(V3 % W(1)+V3 % W(4))+(P1(2)*(+CI*(V3 % W(1)+V3 % W(4)))+P1(3)*(V3 % W(2)-CI*(V3 % W(3))))))+M1*(F2 % W(1)*(-1d0)*(V3 % W(1)+V3 % W(4))+F2 % W(2)*(-V3 % W(2)+CI*(V3 % W(3)))))) diff --git a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_fortranwriter_CFF/ffv1c1_1.f b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_fortranwriter_CFF/ffv1c1_1.f index e14ac8b9eb..d021025f29 100644 --- a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_fortranwriter_CFF/ffv1c1_1.f +++ b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_fortranwriter_CFF/ffv1c1_1.f @@ -22,7 +22,11 @@ subroutine FFV1C1_1(F1, V3, COUP, M2, W2,F2) F2 % FLV_INDEX = F1 % FLV_INDEX TMP0 = (P3(0)*P3(0)-P3(1)*P3(1)-P3(2)*P3(2)-P3(3)*P3(3)) FCT0 = exp(TMP0) - denom = COUP/(P2(0)**2-P2(1)**2-P2(2)**2-P2(3)**2 - M2 * (M2 -CI* W2)) + if (dble(P2(0)**2-P2(1)**2-P2(2)**2-P2(3)**2).gt.0d0) then + denom = COUP/(P2(0)**2-P2(1)**2-P2(2)**2-P2(3)**2 - M2 * (M2 -CI* W2)) + else + denom = COUP/(P2(0)**2-P2(1)**2-P2(2)**2-P2(3)**2 - M2**2) + endif F2%W(1)= denom*(-CI )* FCT0*(F1 % W(1)*(P2(0)*(-V3 % W(1)+V3 % W(4))+(P2(1)*(V3 % W(2)-CI*(V3 % W(3)))+(P2(2)*(+CI*(V3 % W(2))+V3 % W(3))+P2(3)*(-V3 % W(1)+V3 % W(4)))))+(F1 % W(2)*(P2(0)*(V3 % W(2)+CI*(V3 % W(3)))+(P2(1)*(-1d0)*(V3 % W(1)+V3 % W(4))+(P2(2)*(-1d0)*(+CI*(V3 % W(1)+V3 % W(4)))+P2(3)*(V3 % W(2)+CI*(V3 % W(3))))))+M2*(F1 % W(3)*(V3 % W(1)+V3 % W(4))+F1 % W(4)*(V3 % W(2)+CI*(V3 % W(3)))))) F2%W(2)= denom*CI * FCT0*(F1 % W(1)*(P2(0)*(-V3 % W(2)+CI*(V3 % W(3)))+(P2(1)*(V3 % W(1)-V3 % W(4))+(P2(2)*(-CI*(V3 % W(1))+CI*(V3 % W(4)))+P2(3)*(V3 % W(2)-CI*(V3 % W(3))))))+(F1 % W(2)*(P2(0)*(V3 % W(1)+V3 % W(4))+(P2(1)*(-1d0)*(V3 % W(2)+CI*(V3 % W(3)))+(P2(2)*(+CI*(V3 % W(2))-V3 % W(3))-P2(3)*(V3 % W(1)+V3 % W(4)))))+M2*(F1 % W(3)*(-V3 % W(2)+CI*(V3 % W(3)))+F1 % W(4)*(-V3 % W(1)+V3 % W(4))))) F2%W(3)= denom*CI * FCT0*(F1 % W(3)*(P2(0)*(V3 % W(1)+V3 % W(4))+(P2(1)*(-V3 % W(2)+CI*(V3 % W(3)))+(P2(2)*(-1d0)*(+CI*(V3 % W(2))+V3 % W(3))-P2(3)*(V3 % W(1)+V3 % W(4)))))+(F1 % W(4)*(P2(0)*(V3 % W(2)+CI*(V3 % W(3)))+(P2(1)*(-V3 % W(1)+V3 % W(4))+(P2(2)*(-CI*(V3 % W(1))+CI*(V3 % W(4)))-P2(3)*(V3 % W(2)+CI*(V3 % W(3))))))+M2*(F1 % W(1)*(-V3 % W(1)+V3 % W(4))+F1 % W(2)*(V3 % W(2)+CI*(V3 % W(3)))))) diff --git a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_fortranwriter_CFF/ffv1c2_1.f b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_fortranwriter_CFF/ffv1c2_1.f index dffa908e3f..c5137c4acb 100644 --- a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_fortranwriter_CFF/ffv1c2_1.f +++ b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_fortranwriter_CFF/ffv1c2_1.f @@ -24,7 +24,11 @@ subroutine FFV1C1_1(F1, V3, COUP, M2, W2,F2) F2 % FLV_INDEX = F1 % FLV_INDEX TMP0 = (P3(0)*P3(0)-P3(1)*P3(1)-P3(2)*P3(2)-P3(3)*P3(3)) FCT1 = mymdl_VEC(TMP0) - denom = COUP/(P2(0)**2-P2(1)**2-P2(2)**2-P2(3)**2 - M2 * (M2 -CI* W2)) + if (dble(P2(0)**2-P2(1)**2-P2(2)**2-P2(3)**2).gt.0d0) then + denom = COUP/(P2(0)**2-P2(1)**2-P2(2)**2-P2(3)**2 - M2 * (M2 -CI* W2)) + else + denom = COUP/(P2(0)**2-P2(1)**2-P2(2)**2-P2(3)**2 - M2**2) + endif F2%W(1)= denom*(-CI )* FCT1*(F1 % W(1)*(P2(0)*(-V3 % W(1)+V3 % W(4))+(P2(1)*(V3 % W(2)-CI*(V3 % W(3)))+(P2(2)*(+CI*(V3 % W(2))+V3 % W(3))+P2(3)*(-V3 % W(1)+V3 % W(4)))))+(F1 % W(2)*(P2(0)*(V3 % W(2)+CI*(V3 % W(3)))+(P2(1)*(-1d0)*(V3 % W(1)+V3 % W(4))+(P2(2)*(-1d0)*(+CI*(V3 % W(1)+V3 % W(4)))+P2(3)*(V3 % W(2)+CI*(V3 % W(3))))))+M2*(F1 % W(3)*(V3 % W(1)+V3 % W(4))+F1 % W(4)*(V3 % W(2)+CI*(V3 % W(3)))))) F2%W(2)= denom*CI * FCT1*(F1 % W(1)*(P2(0)*(-V3 % W(2)+CI*(V3 % W(3)))+(P2(1)*(V3 % W(1)-V3 % W(4))+(P2(2)*(-CI*(V3 % W(1))+CI*(V3 % W(4)))+P2(3)*(V3 % W(2)-CI*(V3 % W(3))))))+(F1 % W(2)*(P2(0)*(V3 % W(1)+V3 % W(4))+(P2(1)*(-1d0)*(V3 % W(2)+CI*(V3 % W(3)))+(P2(2)*(+CI*(V3 % W(2))-V3 % W(3))-P2(3)*(V3 % W(1)+V3 % W(4)))))+M2*(F1 % W(3)*(-V3 % W(2)+CI*(V3 % W(3)))+F1 % W(4)*(-V3 % W(1)+V3 % W(4))))) F2%W(3)= denom*CI * FCT1*(F1 % W(3)*(P2(0)*(V3 % W(1)+V3 % W(4))+(P2(1)*(-V3 % W(2)+CI*(V3 % W(3)))+(P2(2)*(-1d0)*(+CI*(V3 % W(2))+V3 % W(3))-P2(3)*(V3 % W(1)+V3 % W(4)))))+(F1 % W(4)*(P2(0)*(V3 % W(2)+CI*(V3 % W(3)))+(P2(1)*(-V3 % W(1)+V3 % W(4))+(P2(2)*(-CI*(V3 % W(1))+CI*(V3 % W(4)))-P2(3)*(V3 % W(2)+CI*(V3 % W(3))))))+M2*(F1 % W(1)*(-V3 % W(1)+V3 % W(4))+F1 % W(2)*(V3 % W(2)+CI*(V3 % W(3)))))) diff --git a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_pythonwriter_spin3half/rfsc1_1.py b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_pythonwriter_spin3half/rfsc1_1.py index 2d7bca5c77..44a31cea48 100644 --- a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_pythonwriter_spin3half/rfsc1_1.py +++ b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_pythonwriter_spin3half/rfsc1_1.py @@ -7,7 +7,10 @@ def RFSC1_1(R1,S3,COUP,M2,W2): F2.momenta[2] = +R1.momenta[2]+S3.momenta[2] F2.momenta[3] = +R1.momenta[3]+S3.momenta[3] P2 = [-F2.momenta[j] for j in range(4)] - denom = COUP/(P2[0]**2-P2[1]**2-P2[2]**2-P2[3]**2 - M2 * (M2 -1j* W2)) + if (P2[0]**2-P2[1]**2-P2[2]**2-P2[3]**2).real > 0: + denom = COUP/(P2[0]**2-P2[1]**2-P2[2]**2-P2[3]**2 - M2 * (M2 -1j* W2)) + else: + denom = COUP/(P2[0]**2-P2[1]**2-P2[2]**2-P2[3]**2 - M2**2) F2.W[0]= denom*1j * S3.W[0]*(P2[0]*(-1)*(-R1.W[0]+R1.W[5]+R1.W[12]+1j*(R1.W[9]))+(P2[1]*(R1.W[1]+R1.W[13]-R1.W[4]+1j*(R1.W[8]))+(P2[2]*(+1j*(R1.W[1]+R1.W[13])-1j*(R1.W[4])-R1.W[8])-P2[3]*(-R1.W[0]+R1.W[5]+R1.W[12]+1j*(R1.W[9]))))) F2.W[1]= denom*1j * S3.W[0]*(P2[0]*(R1.W[1]+R1.W[13]-R1.W[4]+1j*(R1.W[8]))+(P2[1]*(-1)*(-R1.W[0]+R1.W[5]+R1.W[12]+1j*(R1.W[9]))+(P2[2]*(-1j*(R1.W[0])+1j*(R1.W[5]+R1.W[12])-R1.W[9])-P2[3]*(R1.W[1]+R1.W[13]-R1.W[4]+1j*(R1.W[8]))))) F2.W[2]= denom*1j * M2*S3.W[0]*(-R1.W[0]+R1.W[5]+R1.W[12]+1j*(R1.W[9])) diff --git a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_pythonwriter_spin3half/rfsc1_2.py b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_pythonwriter_spin3half/rfsc1_2.py index 0958548914..798a37eaac 100644 --- a/tests/input_files/IOTestsComparison/TestAlohaWriter/short_pythonwriter_spin3half/rfsc1_2.py +++ b/tests/input_files/IOTestsComparison/TestAlohaWriter/short_pythonwriter_spin3half/rfsc1_2.py @@ -13,7 +13,10 @@ def RFSC1_2(F2,S3,COUP,M1,W1): flv_index2 = F2.flavor if flv_index1 != -1 and flv_index2 != -1 and flv_index1 != flv_index2: return R1 - denom = COUP/(P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2 - M1 * (M1 -1j* W1)) + if (P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2).real > 0: + denom = COUP/(P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2 - M1 * (M1 -1j* W1)) + else: + denom = COUP/(P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2 - M1**2) R1.W[0]= denom*1j/3 * M1*S3.W[0]*(OM1*(P1[0]*(F2.W[2]*(M1*M1*OM1*(-P1[0]+P1[3])+(+2*(P1[0])-P1[3]))+F2.W[3]*(M1*M1*OM1*(P1[1]-1j*(P1[2]))+(-P1[1]+1j*(P1[2]))))-F2.W[2]*(P1[3]*P1[3]+P1[1]*P1[1]+P1[2]*P1[2]))-F2.W[2]) R1.W[4]= denom*-1j/3 * M1*S3.W[0]*(OM1*(P1[1]*(F2.W[2]*(M1*M1*OM1*(P1[0]-P1[3])+(-P1[0]+P1[3]))+F2.W[3]*(M1*M1*OM1*(-P1[1]+1j*(P1[2]))+(+2*(P1[1])-1j*(P1[2]))))+F2.W[3]*(P1[2]*P1[2]+P1[3]*P1[3]-P1[0]*P1[0]))+F2.W[3]) R1.W[8]= denom*-1/3 * M1*S3.W[0]*(OM1*(P1[2]*(F2.W[2]*(M1*M1*OM1*(+1j*(P1[0])-1j*(P1[3]))+(-1j*(P1[0])+1j*(P1[3])))+F2.W[3]*(M1*-M1*OM1*(+1j*(P1[1])+P1[2])+(+1j*(P1[1])+2*(P1[2]))))+F2.W[3]*(P1[1]*P1[1]+P1[3]*P1[3]-P1[0]*P1[0]))+F2.W[3]) diff --git a/tests/input_files/IOTestsComparison/TestCmdMatchBox/MatchBoxOutput/%TEST%SubProcesses%P0_wpwm_wpwm%check_sa.f b/tests/input_files/IOTestsComparison/TestCmdMatchBox/MatchBoxOutput/%TEST%SubProcesses%P0_wpwm_wpwm%check_sa.f index 8ebba6bea0..58ff281f3b 100644 --- a/tests/input_files/IOTestsComparison/TestCmdMatchBox/MatchBoxOutput/%TEST%SubProcesses%P0_wpwm_wpwm%check_sa.f +++ b/tests/input_files/IOTestsComparison/TestCmdMatchBox/MatchBoxOutput/%TEST%SubProcesses%P0_wpwm_wpwm%check_sa.f @@ -41,7 +41,20 @@ PROGRAM DRIVER INTEGER FLAVOR(NEXTERNAL, MAXFLAVOR) INTEGER PDG_FOR_FLAVOR(NEXTERNAL,MAXFLAVOR) INTEGER FLAV_IDX - INTEGER GET_FLAVOR_INDEX + INTEGER MG5_0_GET_FLAVOR_INDEX +C Signed per-leg PDG of a crossed process (filled by GET_PDG_FOR_FLAVOR), +C the two crossing-partner loop indices, and the number of flavor +C combinations; used only by the crossing-symmetry demonstration below. + INTEGER XPDG(NEXTERNAL) + INTEGER FLIP1, FLIP2, NFLAV +C Per-leg loop index and the two match flags of the crossing demonstration. + INTEGER XCK + LOGICAL XCVALID, XCMATCH +C Representative signed-PDG signatures of the crossed subprocesses folded +C into this matrix element; a crossing is demonstrated when its runtime PDG +C (GET_PDG_FOR_FLAVOR) matches one of them. + INTEGER XCSIG(NEXTERNAL, (NEXTERNAL+1)*(NEXTERNAL+1)) + INTEGER XCNSIG, XCS C C EXTERNAL C @@ -125,9 +138,9 @@ PROGRAM DRIVER c do I=1, MAXFLAVOR IF(unique_flavor.gt.0.and.unique_flavor.ne.I) CYCLE - FLAV_IDX = GET_FLAVOR_INDEX(FLAVOR(1,I)) + FLAV_IDX = MG5_0_GET_FLAVOR_INDEX(FLAVOR(1,I)) do J=1, NB_TRY - CALL SMATRIX(P,FLAV_IDX, MATELEM) + CALL MG5_0_SMATRIX(P,FLAV_IDX, MATELEM) enddo c write(*,*) "PDG", PDG_FOR_FLAVOR(:,I) @@ -135,6 +148,8 @@ PROGRAM DRIVER write (*,*) "-----------------------------------------------------------------------------" enddo + + if (.false.)then do I=1, MAXFLAVOR write (*,*) "==== density matrix for flavor", I, @@ -167,7 +182,7 @@ PROGRAM DRIVER c .dsqrt(dabs(DOT(p(0,i),p(0,i)))) c enddo c -c CALL SMATRIX(P,MATELEM) +c CALL MG5_0_SMATRIX(P,MATELEM) c c write (*,*) "-------------------------------------------------" c write (*,*) "Matrix element = ", MATELEM, " GeV^",-(2*nexternal-8) @@ -206,8 +221,9 @@ SUBROUTINE get_density_matrix(P, FLAVOR) c The value of alphas is 0 to keep the value of the param_card c The value of mu_r2 is set to 0 but it is a dummy variable at tree-level anyway - call GET_DENSITY(P, POS, N_CHANGING, ALLOW_HEL, N_COMB, FLAVOR, 0d0, 0d0, INTER) - + WRITE(*,*) 'no density matrix in this output' + INTER = (0d0, 0d0) + SOL=0 DO I=1, N_COMB DO J = I, N_COMB diff --git a/tests/input_files/IOTestsComparison/TestCmdMatchBox/MatchBoxOutput/%TEST%SubProcesses%P0_wpwm_wpwm%f2py_matrix_wrapper.f b/tests/input_files/IOTestsComparison/TestCmdMatchBox/MatchBoxOutput/%TEST%SubProcesses%P0_wpwm_wpwm%f2py_matrix_wrapper.f deleted file mode 100644 index 851e2a931f..0000000000 --- a/tests/input_files/IOTestsComparison/TestCmdMatchBox/MatchBoxOutput/%TEST%SubProcesses%P0_wpwm_wpwm%f2py_matrix_wrapper.f +++ /dev/null @@ -1,242 +0,0 @@ -C f2py wrappers. Each entry comes in two flavors: a FLAVOR(NEXTERNAL) -C variant (back-compat) that resolves the flavor index via -C GET_FLAVOR_INDEX, and a *_IDX variant taking the flavor index directly. -C The Python dispatch wrapper (flavor_dispatch.py) picks the right one. - SUBROUTINE PY_MG5_0_SMATRIXHEL(P,HEL,FLAVOR,ANS) - IMPLICIT NONE -C -C CONSTANT -C - INTEGER NEXTERNAL - PARAMETER (NEXTERNAL=4) - INTEGER NCOMB - PARAMETER ( NCOMB=81) -CF2PY INTENT(OUT) :: ANS -CF2PY INTENT(IN) :: HEL -CF2PY INTENT(IN) :: P(0:3,NEXTERNAL) -CF2PY INTENT(IN) :: FLAVOR(NEXTERNAL) - -C -C ARGUMENTS -C - REAL*8 P(0:3,NEXTERNAL),ANS - INTEGER HEL - INTEGER FLAVOR(NEXTERNAL) - INTEGER MG5_0_GET_FLAVOR_INDEX - - CALL MG5_0_SMATRIXHEL(P,HEL, - & MG5_0_GET_FLAVOR_INDEX(FLAVOR),ANS) - END - - SUBROUTINE PY_MG5_0_SMATRIXHEL_IDX(P,HEL,FLAV_IDX,ANS) - IMPLICIT NONE - INTEGER NEXTERNAL - PARAMETER (NEXTERNAL=4) -CF2PY INTENT(OUT) :: ANS -CF2PY INTENT(IN) :: HEL -CF2PY INTENT(IN) :: P(0:3,NEXTERNAL) -CF2PY INTENT(IN) :: FLAV_IDX - REAL*8 P(0:3,NEXTERNAL),ANS - INTEGER HEL - INTEGER FLAV_IDX - CALL MG5_0_SMATRIXHEL(P,HEL,FLAV_IDX,ANS) - END - - SUBROUTINE PY_MG5_0_SMATRIX(P,FLAVOR,ANS) -C -C -C MadGraph5_aMC@NLO StandAlone Version -C -C Returns amplitude squared summed/avg over colors -c and helicities -c for the point in phase space P(0:3,NEXTERNAL) -C -C Process: w+ w- > w+ w- WEIGHTED<=4 -C - IMPLICIT NONE -C -C CONSTANTS -C - INTEGER NEXTERNAL - PARAMETER (NEXTERNAL=4) - INTEGER NINITIAL - PARAMETER (NINITIAL=2) -C -C ARGUMENTS -C - REAL*8 P(0:3,NEXTERNAL),ANS - INTEGER FLAVOR(NEXTERNAL) - INTEGER MG5_0_GET_FLAVOR_INDEX -CF2PY INTENT(OUT) :: ANS -CF2PY INTENT(IN) :: P(0:3,NEXTERNAL) -CF2PY INTENT(IN) :: FLAVOR(NEXTERNAL) - call MG5_0_SMATRIX(P, - & MG5_0_GET_FLAVOR_INDEX(FLAVOR),ANS) - END - - SUBROUTINE PY_MG5_0_SMATRIX_IDX(P,FLAV_IDX,ANS) - IMPLICIT NONE - INTEGER NEXTERNAL - PARAMETER (NEXTERNAL=4) - REAL*8 P(0:3,NEXTERNAL),ANS - INTEGER FLAV_IDX -CF2PY INTENT(OUT) :: ANS -CF2PY INTENT(IN) :: P(0:3,NEXTERNAL) -CF2PY INTENT(IN) :: FLAV_IDX - call MG5_0_SMATRIX(P,FLAV_IDX,ANS) - END - - - REAL*8 FUNCTION PY_MG5_0_MATRIX(P,NHEL,IC,FLAVOR) -C -C -C Returns amplitude squared -- no average over initial state/symmetry factor -c for the point with external lines W(0:6,NEXTERNAL) -C -C Process: w+ w- > w+ w- WEIGHTED<=4 -C - IMPLICIT NONE -C -C CONSTANTS -C - INTEGER NEXTERNAL - PARAMETER (NEXTERNAL=4) -C -C ARGUMENTS -C - REAL*8 P(0:3,NEXTERNAL) - INTEGER NHEL(NEXTERNAL), IC(NEXTERNAL) - INTEGER FLAVOR(NEXTERNAL) -CF2PY INTENT(IN) :: P(0:3,NEXTERNAL) -CF2PY INTENT(IN) :: NHEL(NEXTERNAL) -CF2PY INTENT(IN) :: IC(NEXTERNAL) -CF2PY INTENT(IN) :: FLAVOR(NEXTERNAL) -C -C FUNCTIONS -C - real*8 MG5_0_MATRIX - INTEGER MG5_0_GET_FLAVOR_INDEX - PY_MG5_0_MATRIX = MG5_0_MATRIX(P,NHEL,IC, - & MG5_0_GET_FLAVOR_INDEX(FLAVOR)) - END - - REAL*8 FUNCTION PY_MG5_0_MATRIX_IDX(P,NHEL,IC,FLAV_IDX) - IMPLICIT NONE - INTEGER NEXTERNAL - PARAMETER (NEXTERNAL=4) - REAL*8 P(0:3,NEXTERNAL) - INTEGER NHEL(NEXTERNAL), IC(NEXTERNAL) - INTEGER FLAV_IDX -CF2PY INTENT(IN) :: P(0:3,NEXTERNAL) -CF2PY INTENT(IN) :: NHEL(NEXTERNAL) -CF2PY INTENT(IN) :: IC(NEXTERNAL) -CF2PY INTENT(IN) :: FLAV_IDX - real*8 MG5_0_MATRIX - PY_MG5_0_MATRIX_IDX = MG5_0_MATRIX(P,NHEL,IC,FLAV_IDX) - END - - SUBROUTINE PY_MG5_0_GET_value(P, ALPHAS, NHEL, - & FLAVOR, ANS) - IMPLICIT NONE -C -C CONSTANT -C - INTEGER NEXTERNAL - PARAMETER (NEXTERNAL=4) -C -C ARGUMENTS -C - REAL*8 P(0:3,NEXTERNAL),ANS - INTEGER NHEL - INTEGER FLAVOR(NEXTERNAL) - DOUBLE PRECISION ALPHAS -CF2PY INTENT(OUT) :: ANS -CF2PY INTENT(IN) :: NHEL -CF2PY INTENT(IN) :: P(0:3,NEXTERNAL) -CF2PY INTENT(IN) :: ALPHAS -CF2PY INTENT(IN) :: FLAVOR(NEXTERNAL) - call MG5_0_GET_value(P, ALPHAS, NHEL, FLAVOR, ANS) - return - end - - SUBROUTINE PY_MG5_0_GET_value_idx(P, ALPHAS, NHEL, - & FLAV_IDX, ANS) - IMPLICIT NONE - INTEGER NEXTERNAL - PARAMETER (NEXTERNAL=4) - REAL*8 P(0:3,NEXTERNAL),ANS - INTEGER NHEL - INTEGER FLAV_IDX - DOUBLE PRECISION ALPHAS -CF2PY INTENT(OUT) :: ANS -CF2PY INTENT(IN) :: NHEL -CF2PY INTENT(IN) :: P(0:3,NEXTERNAL) -CF2PY INTENT(IN) :: ALPHAS -CF2PY INTENT(IN) :: FLAV_IDX - call MG5_0_GET_value_idx(P, ALPHAS, NHEL, FLAV_IDX, ANS) - return - end - - SUBROUTINE PY_MG5_0_INITIALISEMODEL(PATH) -C ROUTINE FOR F2PY to read the benchmark point. - IMPLICIT NONE - CHARACTER*512 PATH -CF2PY INTENT(IN) :: PATH - call setpara(PATH) !first call to setup the paramaters - return - end - - SUBROUTINE PY_MG5_0_GET_DENSITY(P, POS, N_CHANGING, - & ALLOW_HEL, N_COMB, FLAVOR, ALPHAS, SCALE2, INTER) -C F2PY wrapper around MG5_0_GET_DENSITY so the density-matrix -C computation is exposed in the standalone matrix2py module. -C The CF2PY directives mirror the working pattern used by the -C auto-generated allmatrix2py PY_GET_DENSITY wrapper: they must -C appear before the Fortran type declarations so that f2py can pick -C up the per-argument intent/dimension overrides. - IMPLICIT NONE -CF2PY double precision, intent(in), dimension(0:3,4) :: P -CF2PY integer, intent(in), dimension(*) :: POS -CF2PY integer, intent(in) :: N_CHANGING -CF2PY integer, intent(in), dimension(N_CHANGING*N_COMB) :: ALLOW_HEL -CF2PY integer, intent(in) :: N_COMB -CF2PY integer, intent(in), dimension(4) :: FLAVOR -CF2PY double precision, intent(in) :: ALPHAS -CF2PY double precision, intent(in) :: SCALE2 -CF2PY double complex, intent(out), dimension(N_COMB*(N_COMB+1)/2) :: INTER -C ARGUMENTS - INTEGER NEXTERNAL - PARAMETER (NEXTERNAL=4) - REAL*8 P(0:3,NEXTERNAL) - INTEGER N_CHANGING, N_COMB - INTEGER POS(*) - INTEGER ALLOW_HEL(*) - INTEGER FLAVOR(NEXTERNAL) - DOUBLE PRECISION ALPHAS, SCALE2 -C INTER must be declared with its explicit size (not INTER(*)): f2py reads -C this Fortran declaration to size the intent(out) array, and an assumed-size -C (*) makes it allocate a zero-length buffer, corrupting memory at runtime. - DOUBLE COMPLEX INTER(N_COMB*(N_COMB+1)/2) -C GET_DENSITY takes (..., ALPHAS, SCALE2, INTER): SCALE2 must be passed, -C otherwise INTER lands on the SCALE2 slot and the real INTER pointer is -C undefined, corrupting memory when the density matrix is written. - CALL MG5_0_GET_DENSITY(P, POS, N_CHANGING, ALLOW_HEL, - & N_COMB, FLAVOR, ALPHAS, SCALE2, INTER) - RETURN - END - - LOGICAL FUNCTION PY_MG5_0_IS_BORN_HEL_SELECTED(HELID) - IMPLICIT NONE -C -C CONSTANTS -C - INTEGER NEXTERNAL - PARAMETER (NEXTERNAL=4) -C -C ARGUMENTS -C - INTEGER HELID - LOGICAL MG5_0_IS_BORN_HEL_SELECTED - PY_MG5_0_IS_BORN_HEL_SELECTED = MG5_0_IS_BORN_HEL_SELECTED(HELID) - RETURN - END diff --git a/tests/input_files/IOTestsComparison/TestCmdMatchBox/MatchBoxOutput/%TEST%SubProcesses%P0_wpwm_wpwm%matrix.f b/tests/input_files/IOTestsComparison/TestCmdMatchBox/MatchBoxOutput/%TEST%SubProcesses%P0_wpwm_wpwm%matrix.f index b1546c6ad1..0a3514d058 100644 --- a/tests/input_files/IOTestsComparison/TestCmdMatchBox/MatchBoxOutput/%TEST%SubProcesses%P0_wpwm_wpwm%matrix.f +++ b/tests/input_files/IOTestsComparison/TestCmdMatchBox/MatchBoxOutput/%TEST%SubProcesses%P0_wpwm_wpwm%matrix.f @@ -398,7 +398,7 @@ SUBROUTINE MG5_0_GET_JAMP(NJAMP, ONEJAMP) INTEGER NCOLOR, NJAMP PARAMETER (NCOLOR=1) - COMPLEX*16 JAMP(NCOLOR), ONEJAMP + COMPLEX*16 JAMP(NCOLOR), LNJAMP(NCOLOR), ONEJAMP COMMON/MG5_0_JAMP/JAMP,LNJAMP ONEJAMP = JAMP(NJAMP+1) ! +1 since njamp start at zero (c convention) diff --git a/tests/input_files/IOTestsComparison/TestCmdMatchBox/MatchBoxOutput/%TEST%SubProcesses%P0_wpwm_wpwm%nsqso_born.inc b/tests/input_files/IOTestsComparison/TestCmdMatchBox/MatchBoxOutput/%TEST%SubProcesses%P0_wpwm_wpwm%nsqso_born.inc new file mode 100644 index 0000000000..d706349704 --- /dev/null +++ b/tests/input_files/IOTestsComparison/TestCmdMatchBox/MatchBoxOutput/%TEST%SubProcesses%P0_wpwm_wpwm%nsqso_born.inc @@ -0,0 +1,2 @@ + INTEGER NSQSO_BORN + PARAMETER (NSQSO_BORN=1) diff --git a/tests/input_files/IOTestsComparison/TestCmdMatchBox/MatchBoxOutput/%TEST%SubProcesses%P1_uux_uux%born_matrix.f b/tests/input_files/IOTestsComparison/TestCmdMatchBox/MatchBoxOutput/%TEST%SubProcesses%P1_uux_uux%born_matrix.f index bf116dd8db..819ba1819b 100644 --- a/tests/input_files/IOTestsComparison/TestCmdMatchBox/MatchBoxOutput/%TEST%SubProcesses%P1_uux_uux%born_matrix.f +++ b/tests/input_files/IOTestsComparison/TestCmdMatchBox/MatchBoxOutput/%TEST%SubProcesses%P1_uux_uux%born_matrix.f @@ -1,4 +1,4 @@ - SUBROUTINE MG5_1_SMATRIX(P,ANS_SUMMED) + SUBROUTINE MG5_1_SMATRIX(P, FLAV_IDX, ANS_SUMMED) C C Simple routine wrapper to provide the same interface for C backward compatibility for usage without split orders. @@ -11,9 +11,13 @@ SUBROUTINE MG5_1_SMATRIX(P,ANS_SUMMED) INTEGER NSQAMPSO PARAMETER (NSQAMPSO=1) C -C ARGUMENTS +C ARGUMENTS C REAL*8 P(0:3,NEXTERNAL), ANS_SUMMED +C FLAV_IDX selects one column of the flavor table, i.e. one of the +C flavor combinations this (possibly merged) matrix element covers. +C The canonical flavor is column 1. + INTEGER FLAV_IDX C C VARIABLES C @@ -22,12 +26,12 @@ SUBROUTINE MG5_1_SMATRIX(P,ANS_SUMMED) C C BEGIN CODE C - CALL MG5_1_SMATRIX_SPLITORDERS(P,ANS) + CALL MG5_1_SMATRIX_SPLITORDERS(P, FLAV_IDX, ANS) ANS_SUMMED=ANS(0) END - SUBROUTINE MG5_1_SMATRIXHEL(P,HEL,ANS) + SUBROUTINE MG5_1_SMATRIXHEL(P,HEL, FLAV_IDX, ANS) IMPLICIT NONE C C CONSTANT @@ -37,10 +41,11 @@ SUBROUTINE MG5_1_SMATRIXHEL(P,HEL,ANS) INTEGER NCOMB PARAMETER ( NCOMB=16) C -C ARGUMENTS +C ARGUMENTS C REAL*8 P(0:3,NEXTERNAL),ANS INTEGER HEL + INTEGER FLAV_IDX C C GLOBAL VARIABLES C @@ -50,12 +55,12 @@ SUBROUTINE MG5_1_SMATRIXHEL(P,HEL,ANS) C BEGIN CODE C ---------- USERHEL=HEL - CALL MG5_1_SMATRIX(P,ANS) + CALL MG5_1_SMATRIX(P, FLAV_IDX, ANS) USERHEL=-1 END - SUBROUTINE MG5_1_SMATRIX_SPLITORDERS(P,ANS) + SUBROUTINE MG5_1_SMATRIX_SPLITORDERS(P, FLAV_IDX, ANS) C C Generated by MadGraph5_aMC@NLO v. %(version)s, %(date)s C By the MadGraph5_aMC@NLO Development Team @@ -88,16 +93,29 @@ SUBROUTINE MG5_1_SMATRIX_SPLITORDERS(P,ANS) C ARGUMENTS C REAL*8 P(0:3,NEXTERNAL),ANS(0:NSQAMPSO) +C FLAV_IDX (the dummy argument) is declared with NFLAV below. C -C LOCAL VARIABLES +C LOCAL VARIABLES C - INTEGER NHEL(NEXTERNAL,NCOMB),NTRY + INTEGER NHEL(NEXTERNAL,NCOMB) REAL*8 T(NSQAMPSO), BUFF INTEGER IHEL,IDEN, I INTEGER JC(NEXTERNAL) - LOGICAL GOODHEL(NCOMB) - DATA NTRY/0/ - DATA GOODHEL/NCOMB*.FALSE./ +C One good-helicity table and one warm-up counter per flavor: the +C helicity configurations that vanish depend on the flavor (masses +C and +C the diagrams the flavor mask keeps), so they cannot be shared. + INTEGER FLAVOR(NEXTERNAL) + INTEGER NFLAV + PARAMETER (NFLAV=1) + INTEGER NNTRY_FLAV, NGOODHEL_FLAV + PARAMETER (NNTRY_FLAV=NFLAV) + PARAMETER (NGOODHEL_FLAV=NCOMB*NFLAV) + INTEGER FLAV_IDX + INTEGER NTRY(NFLAV) + LOGICAL GOODHEL(NCOMB,NFLAV) + DATA NTRY/NNTRY_FLAV*0/ + DATA GOODHEL/NGOODHEL_FLAV*.FALSE./ DATA (NHEL(I, 1),I=1,4) / 1,-1,-1, 1/ DATA (NHEL(I, 2),I=1,4) / 1,-1,-1,-1/ DATA (NHEL(I, 3),I=1,4) / 1,-1, 1, 1/ @@ -116,6 +134,10 @@ SUBROUTINE MG5_1_SMATRIX_SPLITORDERS(P,ANS) DATA (NHEL(I, 16),I=1,4) /-1, 1, 1,-1/ DATA IDEN/36/ C +C FUNCTIONS +C + INTEGER MG5_1_BROKEN_SYM +C C GLOBAL VARIABLES C INTEGER USERHEL @@ -125,7 +147,20 @@ SUBROUTINE MG5_1_SMATRIX_SPLITORDERS(P,ANS) C ---------- C BEGIN CODE C ---------- - NTRY=NTRY+1 +C FLAV_IDX=0 (or out of range): GET_FLAVOR_INDEX could not resolve +C the +C requested flavor -- it is not an allowed combination, so its +C matrix +C element is identically zero. Short-circuit before touching the +C 1..NFLAV GOODHEL/NTRY arrays. + IF (FLAV_IDX.LT.1 .OR. FLAV_IDX.GT.NFLAV) THEN + DO I=0,NSQAMPSO + ANS(I) = 0D0 + ENDDO + RETURN + ENDIF + CALL MG5_1_GET_FLAVOR(FLAV_IDX, FLAVOR) + NTRY(FLAV_IDX)=NTRY(FLAV_IDX)+1 DO IHEL=1,NEXTERNAL JC(IHEL) = +1 ENDDO @@ -134,22 +169,27 @@ SUBROUTINE MG5_1_SMATRIX_SPLITORDERS(P,ANS) ENDDO DO IHEL=1,NCOMB IF (USERHEL.EQ.-1.OR.USERHEL.EQ.IHEL) THEN - IF (GOODHEL(IHEL) .OR. NTRY .LT. 2) THEN - CALL MG5_1_MATRIX(P ,NHEL(1,IHEL),JC(1), T) + IF (GOODHEL(IHEL,FLAV_IDX) .OR. NTRY(FLAV_IDX) .LT. 2) THEN + CALL MG5_1_MATRIX(P ,NHEL(1,IHEL),JC(1), FLAV_IDX, T) BUFF=0D0 DO I=1,NSQAMPSO ANS(I)=ANS(I)+T(I) BUFF=BUFF+T(I) ENDDO - IF (BUFF .NE. 0D0 .AND. .NOT. GOODHEL(IHEL)) THEN - GOODHEL(IHEL)=.TRUE. + IF (BUFF .NE. 0D0 .AND. .NOT. GOODHEL(IHEL,FLAV_IDX)) + $ THEN + GOODHEL(IHEL,FLAV_IDX)=.TRUE. ENDIF ENDIF ENDIF ENDDO ANS(0)=0.0D0 DO I=1,NSQAMPSO - ANS(I)=ANS(I)/DBLE(IDEN) +C IDEN averages the canonical flavor; BROKEN_SYM undoes the +C identical +C -particle part of it for the flavors that are not actually +C identical. + ANS(I)=ANS(I)/DBLE(IDEN)*MG5_1_BROKEN_SYM(FLAVOR) IF (CHOSEN_SO_CONFIGS(I)) THEN ANS(0)=ANS(0)+ANS(I) ENDIF @@ -162,7 +202,7 @@ SUBROUTINE MG5_1_SMATRIX_SPLITORDERS(P,ANS) ENDIF END - SUBROUTINE MG5_1_SMATRIXHEL_SPLITORDERS(P,HEL,ANS) + SUBROUTINE MG5_1_SMATRIXHEL_SPLITORDERS(P,HEL, FLAV_IDX, ANS) IMPLICIT NONE C C CONSTANT @@ -174,10 +214,11 @@ SUBROUTINE MG5_1_SMATRIXHEL_SPLITORDERS(P,HEL,ANS) INTEGER NSQAMPSO PARAMETER (NSQAMPSO=1) C -C ARGUMENTS +C ARGUMENTS C REAL*8 P(0:3,NEXTERNAL),ANS(0:NSQAMPSO) INTEGER HEL + INTEGER FLAV_IDX C C GLOBAL VARIABLES C @@ -187,12 +228,13 @@ SUBROUTINE MG5_1_SMATRIXHEL_SPLITORDERS(P,HEL,ANS) C BEGIN CODE C ---------- USERHEL=HEL - CALL MG5_1_SMATRIX_SPLITORDERS(P,ANS) + CALL MG5_1_SMATRIX_SPLITORDERS(P, FLAV_IDX, ANS) USERHEL=-1 END - SUBROUTINE MG5_1_MATRIX(P,NHEL,IC,RES) + SUBROUTINE MG5_1_MATRIX(P,NHEL,IC,FLAV_IDX,RES) + USE MODEL_OBJECT C C Generated by MadGraph5_aMC@NLO v. %(version)s, %(date)s C By the MadGraph5_aMC@NLO Development Team @@ -203,6 +245,7 @@ SUBROUTINE MG5_1_MATRIX(P,NHEL,IC,RES) C C Process: u u~ > u u~ [ virt = QCD ] @1 C + USE ALOHA_OBJECT IMPLICIT NONE C C CONSTANTS @@ -224,9 +267,15 @@ SUBROUTINE MG5_1_MATRIX(P,NHEL,IC,RES) C REAL*8 P(0:3,NEXTERNAL) INTEGER NHEL(NEXTERNAL), IC(NEXTERNAL) +C The HELAS calls below take FLAVOR, which the block that follows +C rebuilds from FLAV_IDX -- one column of the flavor table, i.e. +C one of +C the flavor combinations this (possibly merged) matrix element +C covers. + INTEGER FLAV_IDX REAL*8 RES(NSQAMPSO) C -C LOCAL VARIABLES +C LOCAL VARIABLES C INTEGER I,J,M,N COMPLEX*16 ZTEMP @@ -236,9 +285,10 @@ SUBROUTINE MG5_1_MATRIX(P,NHEL,IC,RES) COMPLEX*16 JAMP(NCOLOR,NAMPSO), LNJAMP(NCOLOR,NAMPSO) COMPLEX*16 TMP_JAMP(0) COMMON/MG5_1_JAMP/JAMP,LNJAMP - COMPLEX*16 W(18,NWAVEFUNCS) + TYPE(ALOHA) W(NWAVEFUNCS) COMPLEX*16 DUM0,DUM1 DATA DUM0, DUM1/(0D0, 0D0), (1D0, 0D0)/ + INTEGER FLAVOR(NEXTERNAL) C Flavor table for the FLAV_IDX -> FLAVOR rebuild. INTEGER NMASK_FLAV PARAMETER (NMASK_FLAV=1) @@ -256,10 +306,10 @@ SUBROUTINE MG5_1_MATRIX(P,NHEL,IC,RES) C C COLOR DATA C - DATA MG5_1_DENOM/1/ - DATA (MG5_1_CF(I),I= 1, 2) /9,6/ + DATA DENOM/1/ + DATA (CF(I),I= 1, 2) /9,6/ C 1 T(2,1) T(3,4) - DATA (MG5_1_CF(I),I= 3, 3) /9/ + DATA (CF(I),I= 3, 3) /9/ C 1 T(2,4) T(3,1) C ---------- C BEGIN CODE @@ -341,7 +391,8 @@ SUBROUTINE MG5_1_MATRIX(P,NHEL,IC,RES) - SUBROUTINE MG5_1_BORN(P,NHEL) + SUBROUTINE MG5_1_BORN(P,NHEL,FLAVOR) + USE MODEL_OBJECT C C Generated by MadGraph5_aMC@NLO v. %(version)s, %(date)s C By the MadGraph5_aMC@NLO Development Team @@ -352,6 +403,7 @@ SUBROUTINE MG5_1_BORN(P,NHEL) C C Process: u u~ > u u~ [ virt = QCD ] @1 C + USE ALOHA_OBJECT IMPLICIT NONE C C CONSTANTS @@ -374,19 +426,30 @@ SUBROUTINE MG5_1_BORN(P,NHEL) C ARGUMENTS C REAL*8 P(0:3,NEXTERNAL) - INTEGER NHEL(NEXTERNAL), IC(NEXTERNAL) + INTEGER NHEL(NEXTERNAL) +C FLAVOR carries the per-leg flavor of the requested combination, +C in the +C same convention as the non-split BORN of +C matrix_standalone_matchbox.inc. +C It is resolved to a flavor-table column below. + INTEGER FLAVOR(NEXTERNAL) C -C LOCAL VARIABLES +C LOCAL VARIABLES C + INTEGER IC(NEXTERNAL) INTEGER I,J,M,N COMPLEX*16 ZTEMP -C REAL*8 CF(NCOLOR,NCOLOR) + INTEGER CF(NCOLOR*(NCOLOR+1)/2) + INTEGER CF_INDEX, DENOM COMPLEX*16 AMP(NGRAPHS) COMPLEX*16 JAMP(NCOLOR,NAMPSO), LNJAMP(NCOLOR,NAMPSO) + COMPLEX*16 TMP_JAMP(0) COMMON/MG5_1_JAMP/JAMP,LNJAMP - COMPLEX*16 W(18,NWAVEFUNCS) + TYPE(ALOHA) W(NWAVEFUNCS) COMPLEX*16 DUM0,DUM1 DATA DUM0, DUM1/(0D0, 0D0), (1D0, 0D0)/ + INTEGER FLAV_IDX + INTEGER MG5_1_GET_FLAVOR_INDEX C Flavor table for the FLAV_IDX -> FLAVOR rebuild. INTEGER NMASK_FLAV PARAMETER (NMASK_FLAV=1) @@ -401,10 +464,10 @@ SUBROUTINE MG5_1_BORN(P,NHEL) C C COLOR DATA C - DATA MG5_1_DENOM/1/ - DATA (MG5_1_CF(I),I= 1, 2) /9,6/ + DATA DENOM/1/ + DATA (CF(I),I= 1, 2) /9,6/ C 1 T(2,1) T(3,4) - DATA (MG5_1_CF(I),I= 3, 3) /9/ + DATA (CF(I),I= 3, 3) /9/ C 1 T(2,4) T(3,1) C ---------- C BEGIN CODE @@ -414,6 +477,19 @@ SUBROUTINE MG5_1_BORN(P,NHEL) ENDDO + FLAV_IDX = MG5_1_GET_FLAVOR_INDEX(FLAVOR) +C Unresolved flavor (not an allowed combination): all color +C amplitudes +C are zero. + IF (FLAV_IDX.EQ.0) THEN + DO M = 1, NAMPSO + DO I = 1, NCOLOR + JAMP(I,M) = (0D0, 0D0) + LNJAMP(I,M) = (0D0, 0D0) + ENDDO + ENDDO + RETURN + ENDIF C Rebuild FLAVOR(NEXTERNAL) from the resolved flavor index. IF (FLAV_IDX .GE. 1 .AND. FLAV_IDX .LE. NMASK_FLAV) THEN DO MASK_J = 1, NEXTERNAL @@ -552,6 +628,141 @@ SUBROUTINE MG5_1__GET_CHOSEN_SO_CONFIG(M,N, OUT) END + INTEGER FUNCTION MG5_1_BROKEN_SYM(FLAV) + INCLUDE 'nexternal.inc' + INTEGER FLAV(NEXTERNAL) + INTEGER I,J,K,ICOMP + INTEGER N_TOT, OLD_FACTOR, TOTAL_FACTOR + INTEGER NCOMP, NENTRIES + PARAMETER (NCOMP=1) + PARAMETER (NENTRIES=2) + INTEGER COMP_BEG(NCOMP), COMP_END(NCOMP), COMP_OLD(NCOMP) + INTEGER PID_LIST(NENTRIES), PID_WORK(NENTRIES) + INTEGER BLOCK_START(NENTRIES), BLOCK_LEN(NENTRIES) + LOGICAL SAME_BLOCK + DATA COMP_BEG /1/ + DATA COMP_END /2/ + DATA COMP_OLD /1/ + DATA PID_LIST /2,-2/ + DATA BLOCK_START /3,4/ + DATA BLOCK_LEN /1,1/ + + PID_WORK = PID_LIST + TOTAL_FACTOR = 1 + DO ICOMP=1,NCOMP + OLD_FACTOR = COMP_OLD(ICOMP) + IF (COMP_OLD(ICOMP).GT.1) THEN + DO I=COMP_BEG(ICOMP),COMP_END(ICOMP) + IF (PID_WORK(I).EQ.0) CYCLE + N_TOT = 1 + DO J=I+1,COMP_END(ICOMP) + IF (PID_WORK(I).EQ.PID_WORK(J)) THEN + SAME_BLOCK = .TRUE. + IF (BLOCK_LEN(I).NE.BLOCK_LEN(J)) SAME_BLOCK = .FALSE. + DO K=1,BLOCK_LEN(I) + IF (FLAV(BLOCK_START(I)+K-1).NE.FLAV(BLOCK_START(J) + $ +K-1)) THEN + SAME_BLOCK = .FALSE. + ENDIF + ENDDO + IF (SAME_BLOCK) THEN + PID_WORK(J) = 0 + N_TOT = N_TOT + 1 + OLD_FACTOR = OLD_FACTOR/N_TOT + ENDIF + ENDIF + ENDDO + ENDDO + ENDIF + TOTAL_FACTOR = TOTAL_FACTOR*OLD_FACTOR + ENDDO + MG5_1_BROKEN_SYM = TOTAL_FACTOR + RETURN + END + + + + INTEGER FUNCTION MG5_1_GET_FLAVOR_INDEX(FLAVOR) +C Resolve an external FLAVOR(NEXTERNAL) group-position vector to +C its +C 1-based index in the allowed-flavor table (the same ordering +C used by +C compute_flavor_masks / the FLAV_TABLE mask columns). A resolved +C flavor +C returns an index in [1,NFLAV]; a flavor that is NOT in the table +C (i.e. +C not a physical/allowed combination, so its matrix element is +C zero) +C returns 0. Callers MUST treat the 0 sentinel as "not a valid +C flavor" +C and short-circuit to a zero result before indexing the 1..NFLAV +C GOODHEL/NTRY arrays or FLAV_TABLE (there is no reserved 0 slot). +C Computed once per phase-space point and then threaded down to +C MATRIX/GET_AMP and the good-helicity filter. + INCLUDE 'nexternal.inc' + INTEGER NFLAV + PARAMETER (NFLAV=1) + INTEGER FLAVOR(NEXTERNAL) +CF2PY INTENT(IN) :: FLAVOR(NEXTERNAL) +CF2PY INTENT(OUT) :: MG5_1_GET_FLAVOR_INDEX + INTEGER FI_I, FI_J + LOGICAL FI_MATCH + INTEGER FI_TABLE(NEXTERNAL, NFLAV) + DATA FI_TABLE /1, 1, 1, 1/ +C 0 sentinel for an unresolved (not-in-table) flavor (see above). + MG5_1_GET_FLAVOR_INDEX = 0 + DO FI_I = 1, NFLAV + FI_MATCH = .TRUE. + DO FI_J = 1, NEXTERNAL + IF (FLAVOR(FI_J) .NE. FI_TABLE(FI_J, FI_I)) THEN + FI_MATCH = .FALSE. + EXIT + ENDIF + ENDDO + IF (FI_MATCH) THEN + MG5_1_GET_FLAVOR_INDEX = FI_I + RETURN + ENDIF + ENDDO + RETURN + END + + + + SUBROUTINE MG5_1_GET_FLAVOR(FLAV_IDX, FLAVOR) +C Reverse of GET_FLAVOR_INDEX: fill FLAVOR(NEXTERNAL) with the +C per-leg +C group-position vector of the FLAV_IDX-th allowed flavor (same +C table / +C ordering). FLAV_IDX is expected in [1,NFLAV] (GET_FLAVOR_INDEX +C never +C returns 0); the bounds guard below is purely defensive and maps +C any +C out-of-range value to the first flavor. Used by the outer entry +C points +C (SMATRIX, ...) which receive FLAV_IDX but still need the FLAVOR +C array +C (e.g. for BROKEN_SYM). + INCLUDE 'nexternal.inc' + INTEGER NFLAV + PARAMETER (NFLAV=1) + INTEGER FLAV_IDX + INTEGER FLAVOR(NEXTERNAL) +CF2PY INTENT(IN) :: FLAV_IDX +CF2PY INTENT(OUT) :: FLAVOR(NEXTERNAL) + INTEGER FA_I, FA_USE + INTEGER FA_TABLE(NEXTERNAL, NFLAV) + DATA FA_TABLE /1, 1, 1, 1/ + FA_USE = FLAV_IDX + IF (FA_USE .LT. 1 .OR. FA_USE .GT. NFLAV) FA_USE = 1 + DO FA_I = 1, NEXTERNAL + FLAVOR(FA_I) = FA_TABLE(FA_I, FA_USE) + ENDDO + RETURN + END + + + C Set of functions to handle the array indices of the split orders diff --git a/tests/input_files/IOTestsComparison/test_aloha_creation/short_aloha_multiple_lorentz_and_symmetry/cpp.cc b/tests/input_files/IOTestsComparison/test_aloha_creation/short_aloha_multiple_lorentz_and_symmetry/cpp.cc index c7f4a28490..8c3d5dd414 100644 --- a/tests/input_files/IOTestsComparison/test_aloha_creation/short_aloha_multiple_lorentz_and_symmetry/cpp.cc +++ b/tests/input_files/IOTestsComparison/test_aloha_creation/short_aloha_multiple_lorentz_and_symmetry/cpp.cc @@ -19,7 +19,11 @@ P1[1] = -V1.p[1]; P1[2] = -V1.p[2]; P1[3] = -V1.p[3]; TMP0 = (V2.W[0]*P1[0]-V2.W[1]*P1[1]-V2.W[2]*P1[2]-V2.W[3]*P1[3]); - denom = COUP/((P1[0]*P1[0])-(P1[1]*P1[1])-(P1[2]*P1[2])-(P1[3]*P1[3]) - M1 * (M1 -cI* W1)); + if ((P1[0]*P1[0])-(P1[1]*P1[1])-(P1[2]*P1[2])-(P1[3]*P1[3]) > 0.){ + denom = COUP/((P1[0]*P1[0])-(P1[1]*P1[1])-(P1[2]*P1[2])-(P1[3]*P1[3]) - M1 * (M1 -cI* W1)); + } else { + denom = COUP/((P1[0]*P1[0])-(P1[1]*P1[1])-(P1[2]*P1[2])-(P1[3]*P1[3]) - M1*M1); + } V1.W[0]= denom*S3.W[0]*(-cI*(V2.W[0])+cI*(P1[0]*OM1*TMP0)); V1.W[1]= denom*S3.W[0]*(-cI*(V2.W[1])+cI*(P1[1]*OM1*TMP0)); V1.W[2]= denom*S3.W[0]*(-cI*(V2.W[2])+cI*(P1[2]*OM1*TMP0)); diff --git a/tests/input_files/IOTestsComparison/test_aloha_creation/short_aloha_multiple_lorentz_and_symmetry/fortran.f b/tests/input_files/IOTestsComparison/test_aloha_creation/short_aloha_multiple_lorentz_and_symmetry/fortran.f index 6172a93895..16572a4516 100644 --- a/tests/input_files/IOTestsComparison/test_aloha_creation/short_aloha_multiple_lorentz_and_symmetry/fortran.f +++ b/tests/input_files/IOTestsComparison/test_aloha_creation/short_aloha_multiple_lorentz_and_symmetry/fortran.f @@ -20,7 +20,11 @@ subroutine VVS1_1(V2, S3, COUP, M1, W1,V1) V1%P(:) = +V2%P(:)+S3%P(:) P1(:) = -V1 % P (:) TMP0 = (V2 % W(1)*P1(0)-V2 % W(2)*P1(1)-V2 % W(3)*P1(2)-V2 % W(4)*P1(3)) - denom = COUP/(P1(0)**2-P1(1)**2-P1(2)**2-P1(3)**2 - M1 * (M1 -CI* W1)) + if (dble(P1(0)**2-P1(1)**2-P1(2)**2-P1(3)**2).gt.0d0) then + denom = COUP/(P1(0)**2-P1(1)**2-P1(2)**2-P1(3)**2 - M1 * (M1 -CI* W1)) + else + denom = COUP/(P1(0)**2-P1(1)**2-P1(2)**2-P1(3)**2 - M1**2) + endif V1%W(1)= denom*S3 % W(1)*(-CI*(V2 % W(1))+CI*(P1(0)*OM1*TMP0)) V1%W(2)= denom*S3 % W(1)*(-CI*(V2 % W(2))+CI*(P1(1)*OM1*TMP0)) V1%W(3)= denom*S3 % W(1)*(-CI*(V2 % W(3))+CI*(P1(2)*OM1*TMP0)) diff --git a/tests/input_files/IOTestsComparison/test_aloha_creation/short_aloha_multiple_lorentz_and_symmetry/vvs1.py b/tests/input_files/IOTestsComparison/test_aloha_creation/short_aloha_multiple_lorentz_and_symmetry/vvs1.py index 17179c77da..42df29c1a2 100644 --- a/tests/input_files/IOTestsComparison/test_aloha_creation/short_aloha_multiple_lorentz_and_symmetry/vvs1.py +++ b/tests/input_files/IOTestsComparison/test_aloha_creation/short_aloha_multiple_lorentz_and_symmetry/vvs1.py @@ -10,7 +10,10 @@ def VVS1_1(V2,S3,COUP,M1,W1): V1.momenta[3] = +V2.momenta[3]+S3.momenta[3] P1 = [-V1.momenta[j] for j in range(4)] TMP0 = (V2.W[0]*P1[0]-V2.W[1]*P1[1]-V2.W[2]*P1[2]-V2.W[3]*P1[3]) - denom = COUP/(P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2 - M1 * (M1 -1j* W1)) + if (P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2).real > 0: + denom = COUP/(P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2 - M1 * (M1 -1j* W1)) + else: + denom = COUP/(P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2 - M1**2) V1.W[0]= denom*S3.W[0]*(-1j*(V2.W[0])+1j*(P1[0]*OM1*TMP0)) V1.W[1]= denom*S3.W[0]*(-1j*(V2.W[1])+1j*(P1[1]*OM1*TMP0)) V1.W[2]= denom*S3.W[0]*(-1j*(V2.W[2])+1j*(P1[2]*OM1*TMP0)) diff --git a/tests/input_files/wpj_merged_antiparticle.lhe.gz b/tests/input_files/wpj_merged_antiparticle.lhe.gz new file mode 100644 index 0000000000..b3da4f29a0 Binary files /dev/null and b/tests/input_files/wpj_merged_antiparticle.lhe.gz differ diff --git a/tests/parallel_tests/decay_comparator.py b/tests/parallel_tests/decay_comparator.py index e2ed05418d..472df7f1c5 100755 --- a/tests/parallel_tests/decay_comparator.py +++ b/tests/parallel_tests/decay_comparator.py @@ -349,7 +349,7 @@ def check_3body(self, part, multi1='all', multi2='all', multi3='all', log=None, os.system('rm -rf %s >/dev/null' % dir_name) os.system('rm -rf %s_dec >/dev/null' % dir_name) self.cmd.run_cmd('set automatic_html_opening False --no-save') - self.cmd.exec_cmd('generate %s > %s %s %s $ all $$ %s --optimize' % + self.cmd.exec_cmd('generate %s > %s %s %s $ all $$ %s --optimize' % (part, multi1, multi2, multi3, ' '.join(to_avoid))) print('generate %s > %s %s %s $ all $$ %s --optimize' % \ (part, multi1, multi2, multi3, ' '.join(to_avoid))) diff --git a/tests/parallel_tests/input_files/mg5_short_paralleltest_heft.pkl b/tests/parallel_tests/input_files/mg5_short_paralleltest_heft.pkl index eace2c4e7f..b1f2bff711 100644 Binary files a/tests/parallel_tests/input_files/mg5_short_paralleltest_heft.pkl and b/tests/parallel_tests/input_files/mg5_short_paralleltest_heft.pkl differ diff --git a/tests/parallel_tests/input_files/mg5_short_paralleltest_mssm.pkl b/tests/parallel_tests/input_files/mg5_short_paralleltest_mssm.pkl index e8ff01ded7..8403789b12 100644 Binary files a/tests/parallel_tests/input_files/mg5_short_paralleltest_mssm.pkl and b/tests/parallel_tests/input_files/mg5_short_paralleltest_mssm.pkl differ diff --git a/tests/parallel_tests/input_files/mg5_short_paralleltest_sm.pkl b/tests/parallel_tests/input_files/mg5_short_paralleltest_sm.pkl index 20c2c6d45f..f02a230d12 100644 Binary files a/tests/parallel_tests/input_files/mg5_short_paralleltest_sm.pkl and b/tests/parallel_tests/input_files/mg5_short_paralleltest_sm.pkl differ diff --git a/tests/parallel_tests/input_files/mg5_short_paralleltest_sqso.pkl b/tests/parallel_tests/input_files/mg5_short_paralleltest_sqso.pkl index 1c260c8656..a62797260b 100644 Binary files a/tests/parallel_tests/input_files/mg5_short_paralleltest_sqso.pkl and b/tests/parallel_tests/input_files/mg5_short_paralleltest_sqso.pkl differ diff --git a/tests/parallel_tests/me_comparator.py b/tests/parallel_tests/me_comparator.py index 83411edf5c..22de93acbc 100755 --- a/tests/parallel_tests/me_comparator.py +++ b/tests/parallel_tests/me_comparator.py @@ -482,6 +482,49 @@ def __init__(self, cms, gauge): self.type = '%s_%s' %(self.cms, self.gauge) self.name = 'MG5_%s_%s' %(self.cms, self.gauge) + # Above this collision energy every electroweak resonance (M_W, M_Z, M_H, + # M_top ~ 80-173 GeV) is far off shell, so a fixed-width propagator can have + # its width dropped without hitting a pole. At/near a resonance (the 90 GeV + # runs sit on the Z pole) the width is physical and must be kept. + RESONANCE_SAFE_ENERGY = 300.0 + + def fix_energy_in_check(self, dir_name, energy): + """Set the collision energy (parent behaviour) and, for the fixed-width + runs *well above the resonances*, zero every width in the param_card. + + Rationale: this test compares the complex-mass scheme against the + fixed-width scheme in several gauges. A finite width i*M*Gamma is what + breaks gauge/scheme invariance at O(Gamma) -- and the default treatment + keeps it for timelike (s-channel) but drops it for spacelike (t-channel) + propagators (aloha.t_channel_width / zerowidth_tchannel), an imbalance + the FD gauge is sensitive enough to fail on (e+ e- > e+ ve d u~ at + 500 GeV). Off resonance the width is a pure O(Gamma) nuisance, so zero + every width in the fixed-width runs: the three fixed-width gauges then + agree exactly. On the Z pole (90 GeV) the width regulates a real + resonance, so it is kept there -- zeroing it would blow the propagator + up (e.g. b b~ > b b~ g). The complex-mass (cms='True') runs always keep + their widths: the width lives inside the complex mass and defines the + scheme. + """ + if self.cms == 'False' and energy >= self.RESONANCE_SAFE_ENERGY: + self._zero_widths_in_param_card(dir_name) + return super(MG5_UFO_gauge_Runner, self).fix_energy_in_check( + dir_name, energy) + + @staticmethod + def _zero_widths_in_param_card(dir_name): + """Rewrite every DECAY width to 0 in /Cards/param_card.dat.""" + card = os.path.join(dir_name, 'Cards', 'param_card.dat') + if not os.path.exists(card): + return + with open(card) as fsock: + text = fsock.read() + # DECAY [ # comment] -> DECAY 0.000000e+00 [ # ...] + text = re.sub(r'(?im)^(DECAY\s+\d+\s+)[+-]?\d*\.?\d+(?:[eEdD][+-]?\d+)?', + r'\g<1>0.000000e+00', text) + with open(card, 'w') as fsock: + fsock.write(text) + def format_mg5_proc_card(self, proc_list, model, orders): """Create a proc_card.dat string following v5 conventions.""" @@ -489,6 +532,16 @@ def format_mg5_proc_card(self, proc_list, model, orders): v5_string += "set automatic_html_opening False\n" v5_string += 'set complex_mass_scheme %s \n' % self.cms v5_string += 'set gauge %s \n' % self.gauge + # Keep the width in spacelike (t-channel) propagators. This matters for + # the on-resonance (90 GeV) runs, where widths are NOT zeroed below: + # the complex-mass scheme carries i*M*Gamma in every propagator (it + # lives in the complex mass M^2 -> M^2 - i*M*Gamma), t-channel included, + # whereas the default fixed-width treatment DROPS it for spacelike + # momenta -- an s/t imbalance that violates gauge invariance at + # O(Gamma). Above the resonances the widths are zeroed outright (see + # fix_energy_in_check), so this is a no-op there. Ignored for the CMS + # runs (the width already lives in the complex mass). + v5_string += 'set zerowidth_tchannel False \n' v5_string += "import model %s \n" % os.path.join(self.model_dir, model) couplings = MERunner.get_coupling_definitions(orders) diff --git a/tests/parallel_tests/test_aloha.py b/tests/parallel_tests/test_aloha.py index 7a6c9f1a7e..df263a8c5d 100755 --- a/tests/parallel_tests/test_aloha.py +++ b/tests/parallel_tests/test_aloha.py @@ -1084,7 +1084,7 @@ def test_short_expand_veto(self): analytical2= analytical2.expand() for name, cexpr in aloha_lib.KERNEL.reduced_expr2.items(): try: - exec('%s = %s' % (name, cexpr)) + globals()[name] = eval(str(cexpr)) except: pass @@ -1214,7 +1214,7 @@ def test_short_part_spin32propagator(self): M1 = math.sqrt(P1_0 **2 - P1_1 **2 -P1_2 **2 -P1_3 **2) for name, cexpr in aloha_lib.KERNEL.reduced_expr2.items(): try: - exec('%s = %s' % (name, cexpr)) + globals()[name] = eval(str(cexpr)) except: pass for ind in zero.listindices(): @@ -1337,7 +1337,7 @@ def test_short_spin2propagator4(self): OM1 = 1.0/48#(P1_0 **2 - P1_1 **2 -P1_2 **2 -P1_3 **2) for name, cexpr in aloha_lib.KERNEL.reduced_expr2.items(): try: - exec('%s = %s' % (name, cexpr)) + globals()[name] = eval(str(cexpr)) except: pass @@ -2854,7 +2854,7 @@ def test_short_aloha_ZPZZ(self): P2_0,P2_1,P2_2,P2_3 = 101, 111, 121, 134 P3_0,P3_1,P3_2,P3_3 = 1001, 1106, 1240, 1320 for name, cexpr in abstract_ZP.contracted.items(): - exec('%s = %s' % (name, cexpr)) + globals()[name] = eval(str(cexpr)) for ind in expr.listindices(): self.assertEqual(eval(str(expr.get_rep(ind))), 178727040j) @@ -2920,10 +2920,10 @@ def test_short_use_of_library_spin2(self): M3 = 500 #for name, cexpr in one_exp.contracted.items(): - # exec('%s = %s' % (name, cexpr)) + # globals()[name] = eval(str(cexpr)) for name, cexpr in aloha_lib.KERNEL.reduced_expr2.items(): try: - exec('%s = %s' % (name, cexpr)) + globals()[name] = eval(str(cexpr)) except: pass for ind in one_exp.listindices(): @@ -3017,7 +3017,7 @@ def test_short_aloha_FFT2(self): M3 = 500 for name, cexpr in aloha_lib.KERNEL.reduced_expr2.items(): - exec('%s = %s' % (name, cexpr)) + globals()[name] = eval(str(cexpr)) for ind in zero.listindices(): self.assertAlmostEqual(eval(str(zero.get_rep(ind))),0) @@ -3082,7 +3082,7 @@ def test_short_aloha_FFV(self): P3_0,P3_1,P3_2,P3_3 = 10, 11, 12, 13 for name, cexpr in aloha_lib.KERNEL.reduced_expr2.items(): - exec('%s = %s' % (name, cexpr)) + globals()[name] = eval(str(cexpr)) for ind in abstract.expr.listindices(): self.assertAlmostEqual(eval(str(abstract.expr.get_rep(ind))) - @@ -3116,7 +3116,7 @@ def test_short_aloha_FFVP1N(self): #evaluate all contraction for name, cexpr in aloha_lib.KERNEL.reduced_expr2.items(): - exec('%s = %s' % (name, cexpr)) + globals()[name] = eval(str(cexpr)) # evaluate FFV_0 val_V = eval(str(V.expr.get_rep((0,)))) @@ -3174,7 +3174,7 @@ def test_short_aloha_FFV_MG4(self): s4 = -j*((OM3*(P3_3*((F2_1*((F1_3*(-P3_0-P3_3))+(F1_4*(-P3_1-1*j*P3_2))))+(F2_2*((F1_3*(-P3_1+1*j*P3_2))+(F1_4*(-P3_0+P3_3)))))))+(-(F1_3*F2_1)+(F1_4*F2_2))) for name, cexpr in aloha_lib.KERNEL.reduced_expr2.items(): - exec('%s = %s' % (name, cexpr)) + globals()[name] = eval(str(cexpr)) self.assertEqual(s1, eval(str(abstract_M.expr.get_rep([0])))) self.assertEqual(s2, eval(str(abstract_M.expr.get_rep([1])))) @@ -3193,7 +3193,7 @@ def test_short_aloha_FFV_MG4(self): zero = abstract_6.expr - abstract_M.expr - \ 2* abstract_P.expr for name, cexpr in aloha_lib.KERNEL.reduced_expr2.items(): - exec('%s = %s' % (name, cexpr)) + globals()[name] = eval(str(cexpr)) for ind in zero.listindices(): self.assertEqual(eval(str(zero.get_rep(ind))),0) @@ -4020,7 +4020,10 @@ def SSS1_1(S2,S3,COUP,M1,W1): S1.momenta[2] = +S2.momenta[2]+S3.momenta[2] S1.momenta[3] = +S2.momenta[3]+S3.momenta[3] P1 = [-S1.momenta[j] for j in range(4)] - denom = COUP/(P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2 - M1 * (M1 -1j* W1)) + if (P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2).real > 0: + denom = COUP/(P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2 - M1 * (M1 -1j* W1)) + else: + denom = COUP/(P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2 - M1**2) S1.W[0]= denom*1j * S3.W[0]*S2.W[0] return S1 @@ -4140,7 +4143,10 @@ def FFV1C1_1(F1,V3,COUP,M2,W2): F2.momenta[3] = +F1.momenta[3]+V3.momenta[3] P2 = [-F2.momenta[j] for j in range(4)] F2.flavor = F1.flavor - denom = COUP/(P2[0]**2-P2[1]**2-P2[2]**2-P2[3]**2 - M2 * (M2 -1j* W2)) + if (P2[0]**2-P2[1]**2-P2[2]**2-P2[3]**2).real > 0: + denom = COUP/(P2[0]**2-P2[1]**2-P2[2]**2-P2[3]**2 - M2 * (M2 -1j* W2)) + else: + denom = COUP/(P2[0]**2-P2[1]**2-P2[2]**2-P2[3]**2 - M2**2) F2.W[0]= denom*(-1j)*(F1.W[0]*(P2[0]*(-V3.W[0]+V3.W[3])+(P2[1]*(V3.W[1]-1j*(V3.W[2]))+(P2[2]*(+1j*(V3.W[1])+V3.W[2])+P2[3]*(-V3.W[0]+V3.W[3]))))+(F1.W[1]*(P2[0]*(V3.W[1]+1j*(V3.W[2]))+(P2[1]*(-1)*(V3.W[0]+V3.W[3])+(P2[2]*(-1)*(+1j*(V3.W[0]+V3.W[3]))+P2[3]*(V3.W[1]+1j*(V3.W[2])))))+M2*(F1.W[2]*(V3.W[0]+V3.W[3])+F1.W[3]*(V3.W[1]+1j*(V3.W[2]))))) F2.W[1]= denom*1j*(F1.W[0]*(P2[0]*(-V3.W[1]+1j*(V3.W[2]))+(P2[1]*(V3.W[0]-V3.W[3])+(P2[2]*(-1j*(V3.W[0])+1j*(V3.W[3]))+P2[3]*(V3.W[1]-1j*(V3.W[2])))))+(F1.W[1]*(P2[0]*(V3.W[0]+V3.W[3])+(P2[1]*(-1)*(V3.W[1]+1j*(V3.W[2]))+(P2[2]*(+1j*(V3.W[1])-V3.W[2])-P2[3]*(V3.W[0]+V3.W[3]))))+M2*(F1.W[2]*(-V3.W[1]+1j*(V3.W[2]))+F1.W[3]*(-V3.W[0]+V3.W[3])))) F2.W[2]= denom*1j*(F1.W[2]*(P2[0]*(V3.W[0]+V3.W[3])+(P2[1]*(-V3.W[1]+1j*(V3.W[2]))+(P2[2]*(-1)*(+1j*(V3.W[1])+V3.W[2])-P2[3]*(V3.W[0]+V3.W[3]))))+(F1.W[3]*(P2[0]*(V3.W[1]+1j*(V3.W[2]))+(P2[1]*(-V3.W[0]+V3.W[3])+(P2[2]*(-1j*(V3.W[0])+1j*(V3.W[3]))-P2[3]*(V3.W[1]+1j*(V3.W[2])))))+M2*(F1.W[0]*(-V3.W[0]+V3.W[3])+F1.W[1]*(V3.W[1]+1j*(V3.W[2]))))) @@ -4172,7 +4178,10 @@ def FFV1C1_2(F2,V3,COUP,M1,W1): F1.momenta[3] = +F2.momenta[3]+V3.momenta[3] P1 = [-F1.momenta[j] for j in range(4)] F1.flavor = F2.flavor - denom = COUP/(P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2 - M1 * (M1 -1j* W1)) + if (P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2).real > 0: + denom = COUP/(P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2 - M1 * (M1 -1j* W1)) + else: + denom = COUP/(P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2 - M1**2) F1.W[0]= denom*(-1j)*(F2.W[0]*(P1[0]*(V3.W[0]+V3.W[3])+(P1[1]*(-1)*(V3.W[1]+1j*(V3.W[2]))+(P1[2]*(+1j*(V3.W[1])-V3.W[2])-P1[3]*(V3.W[0]+V3.W[3]))))+(F2.W[1]*(P1[0]*(V3.W[1]-1j*(V3.W[2]))+(P1[1]*(-V3.W[0]+V3.W[3])+(P1[2]*(+1j*(V3.W[0])-1j*(V3.W[3]))+P1[3]*(-V3.W[1]+1j*(V3.W[2])))))+M1*(F2.W[2]*(V3.W[0]-V3.W[3])+F2.W[3]*(-V3.W[1]+1j*(V3.W[2]))))) F1.W[1]= denom*1j*(F2.W[0]*(P1[0]*(-1)*(V3.W[1]+1j*(V3.W[2]))+(P1[1]*(V3.W[0]+V3.W[3])+(P1[2]*(+1j*(V3.W[0]+V3.W[3]))-P1[3]*(V3.W[1]+1j*(V3.W[2])))))+(F2.W[1]*(P1[0]*(-V3.W[0]+V3.W[3])+(P1[1]*(V3.W[1]-1j*(V3.W[2]))+(P1[2]*(+1j*(V3.W[1])+V3.W[2])+P1[3]*(-V3.W[0]+V3.W[3]))))+M1*(F2.W[2]*(V3.W[1]+1j*(V3.W[2]))-F2.W[3]*(V3.W[0]+V3.W[3])))) F1.W[2]= denom*1j*(F2.W[2]*(P1[0]*(-V3.W[0]+V3.W[3])+(P1[1]*(V3.W[1]+1j*(V3.W[2]))+(P1[2]*(-1j*(V3.W[1])+V3.W[2])+P1[3]*(-V3.W[0]+V3.W[3]))))+(F2.W[3]*(P1[0]*(V3.W[1]-1j*(V3.W[2]))+(P1[1]*(-1)*(V3.W[0]+V3.W[3])+(P1[2]*(+1j*(V3.W[0]+V3.W[3]))+P1[3]*(V3.W[1]-1j*(V3.W[2])))))+M1*(F2.W[0]*(-1)*(V3.W[0]+V3.W[3])+F2.W[1]*(-V3.W[1]+1j*(V3.W[2]))))) @@ -4200,7 +4209,10 @@ def FFV1C1_1(F1,V3,COUP,M2,W2): F2.momenta[3] = +F1.momenta[3]+V3.momenta[3] P2 = [-F2.momenta[j] for j in range(4)] F2.flavor = F1.flavor - denom = COUP/(P2[0]**2-P2[1]**2-P2[2]**2-P2[3]**2 - M2 * (M2 -1j* W2)) + if (P2[0]**2-P2[1]**2-P2[2]**2-P2[3]**2).real > 0: + denom = COUP/(P2[0]**2-P2[1]**2-P2[2]**2-P2[3]**2 - M2 * (M2 -1j* W2)) + else: + denom = COUP/(P2[0]**2-P2[1]**2-P2[2]**2-P2[3]**2 - M2**2) F2.W[0]= denom*(-1j)*(F1.W[0]*(P2[0]*(-V3.W[0]+V3.W[3])+(P2[1]*(V3.W[1]-1j*(V3.W[2]))+(P2[2]*(+1j*(V3.W[1])+V3.W[2])+P2[3]*(-V3.W[0]+V3.W[3]))))+(F1.W[1]*(P2[0]*(V3.W[1]+1j*(V3.W[2]))+(P2[1]*(-1)*(V3.W[0]+V3.W[3])+(P2[2]*(-1)*(+1j*(V3.W[0]+V3.W[3]))+P2[3]*(V3.W[1]+1j*(V3.W[2])))))+M2*(F1.W[2]*(V3.W[0]+V3.W[3])+F1.W[3]*(V3.W[1]+1j*(V3.W[2]))))) F2.W[1]= denom*1j*(F1.W[0]*(P2[0]*(-V3.W[1]+1j*(V3.W[2]))+(P2[1]*(V3.W[0]-V3.W[3])+(P2[2]*(-1j*(V3.W[0])+1j*(V3.W[3]))+P2[3]*(V3.W[1]-1j*(V3.W[2])))))+(F1.W[1]*(P2[0]*(V3.W[0]+V3.W[3])+(P2[1]*(-1)*(V3.W[1]+1j*(V3.W[2]))+(P2[2]*(+1j*(V3.W[1])-V3.W[2])-P2[3]*(V3.W[0]+V3.W[3]))))+M2*(F1.W[2]*(-V3.W[1]+1j*(V3.W[2]))+F1.W[3]*(-V3.W[0]+V3.W[3])))) F2.W[2]= denom*1j*(F1.W[2]*(P2[0]*(V3.W[0]+V3.W[3])+(P2[1]*(-V3.W[1]+1j*(V3.W[2]))+(P2[2]*(-1)*(+1j*(V3.W[1])+V3.W[2])-P2[3]*(V3.W[0]+V3.W[3]))))+(F1.W[3]*(P2[0]*(V3.W[1]+1j*(V3.W[2]))+(P2[1]*(-V3.W[0]+V3.W[3])+(P2[2]*(-1j*(V3.W[0])+1j*(V3.W[3]))-P2[3]*(V3.W[1]+1j*(V3.W[2])))))+M2*(F1.W[0]*(-V3.W[0]+V3.W[3])+F1.W[1]*(V3.W[1]+1j*(V3.W[2]))))) @@ -4250,7 +4262,10 @@ def FFFF1_1(F2,F3,F4,COUP,M1,W1): P1 = [-F1.momenta[j] for j in range(4)] F1.flavor = F2.flavor TMP0 = (F4.W[0]*F3.W[0]+F4.W[1]*F3.W[1]+F4.W[2]*F3.W[2]+F4.W[3]*F3.W[3]) - denom = COUP/(P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2 - M1 * (M1 -1j* W1)) + if (P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2).real > 0: + denom = COUP/(P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2 - M1 * (M1 -1j* W1)) + else: + denom = COUP/(P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2 - M1**2) F1.W[0]= denom*-1j * TMP0*(F2.W[2]*(P1[0]+P1[3])+(F2.W[3]*(P1[1]+1j*(P1[2]))-F2.W[0]*M1)) F1.W[1]= denom*1j * TMP0*(F2.W[2]*(-P1[1]+1j*(P1[2]))+(F2.W[3]*(-P1[0]+P1[3])+F2.W[1]*M1)) F1.W[2]= denom*1j * TMP0*(F2.W[0]*(-P1[0]+P1[3])+(F2.W[1]*(P1[1]+1j*(P1[2]))+F2.W[2]*M1)) @@ -4284,7 +4299,10 @@ def FFFF1C1_1(F1,F3,F4,COUP,M2,W2): P2 = [-F2.momenta[j] for j in range(4)] F2.flavor = F1.flavor TMP0 = (F4.W[0]*F3.W[0]+F4.W[1]*F3.W[1]+F4.W[2]*F3.W[2]+F4.W[3]*F3.W[3]) - denom = COUP/(P2[0]**2-P2[1]**2-P2[2]**2-P2[3]**2 - M2 * (M2 -1j* W2)) + if (P2[0]**2-P2[1]**2-P2[2]**2-P2[3]**2).real > 0: + denom = COUP/(P2[0]**2-P2[1]**2-P2[2]**2-P2[3]**2 - M2 * (M2 -1j* W2)) + else: + denom = COUP/(P2[0]**2-P2[1]**2-P2[2]**2-P2[3]**2 - M2**2) F2.W[0]= denom*-1j * TMP0*(F1.W[2]*(P2[0]+P2[3])+(F1.W[3]*(P2[1]+1j*(P2[2]))-F1.W[0]*M2)) F2.W[1]= denom*1j * TMP0*(F1.W[2]*(-P2[1]+1j*(P2[2]))+(F1.W[3]*(-P2[0]+P2[3])+F1.W[1]*M2)) F2.W[2]= denom*1j * TMP0*(F1.W[0]*(-P2[0]+P2[3])+(F1.W[1]*(P2[1]+1j*(P2[2]))+F1.W[2]*M2)) @@ -4318,7 +4336,10 @@ def FFFF1C2_1(F2,F4,F3,COUP,M1,W1): P1 = [-F1.momenta[j] for j in range(4)] F1.flavor = F2.flavor TMP0 = (F4.W[0]*F3.W[0]+F4.W[1]*F3.W[1]+F4.W[2]*F3.W[2]+F4.W[3]*F3.W[3]) - denom = COUP/(P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2 - M1 * (M1 -1j* W1)) + if (P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2).real > 0: + denom = COUP/(P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2 - M1 * (M1 -1j* W1)) + else: + denom = COUP/(P1[0]**2-P1[1]**2-P1[2]**2-P1[3]**2 - M1**2) F1.W[0]= denom*-1j * TMP0*(F2.W[2]*(P1[0]+P1[3])+(F2.W[3]*(P1[1]+1j*(P1[2]))-F2.W[0]*M1)) F1.W[1]= denom*1j * TMP0*(F2.W[2]*(-P1[1]+1j*(P1[2]))+(F2.W[3]*(-P1[0]+P1[3])+F2.W[1]*M1)) F1.W[2]= denom*1j * TMP0*(F2.W[0]*(-P1[0]+P1[3])+(F2.W[1]*(P1[1]+1j*(P1[2]))+F2.W[2]*M1)) diff --git a/tests/unit_tests/core/test_color_algebra.py b/tests/unit_tests/core/test_color_algebra.py index bf65214c42..da87178e21 100755 --- a/tests/unit_tests/core/test_color_algebra.py +++ b/tests/unit_tests/core/test_color_algebra.py @@ -166,6 +166,11 @@ def test_f_object(self): self.assertEqual(my_f.simplify(), color.ColorFactor([col_str1, col_str2])) + # f is real, so complex conjugation must NOT reverse the indices + # (which would flip the sign of the totally antisymmetric f) + self.assertEqual(color.f(1, 2, 3).complex_conjugate(), + color.f(1, 2, 3)) + def test_d_object(self): """Test the d color object""" # T should have exactly 3 indices! diff --git a/tests/unit_tests/core/test_color_amp.py b/tests/unit_tests/core/test_color_amp.py index 9775c450fc..8f4eeb0db4 100755 --- a/tests/unit_tests/core/test_color_amp.py +++ b/tests/unit_tests/core/test_color_amp.py @@ -17,9 +17,12 @@ color information for diagrams.""" from __future__ import absolute_import +import collections import copy import fractions +import madgraph +import madgraph.various.misc as misc import madgraph.core.base_objects as base_objects import madgraph.core.diagram_generation as diagram_generation @@ -255,7 +258,11 @@ def test_colorize_uux_ggg(self): myamplitude.set('process', myprocess) - myamplitude.generate_diagrams() + # What is checked below is colorize, against diagrams picked by their + # position, so it wants the plain generation order -- the four gluon + # merging reorders them + with misc.TMP_variable(madgraph, 'merge_quartic_vertices', False): + myamplitude.generate_diagrams() my_col_basis = color_amp.ColorBasis() @@ -765,3 +772,365 @@ def test_helper_lcm_functions(self): self.assertEqual(color_amp.ColorMatrix.lcm(6, 3), 6) self.assertEqual(color_amp.ColorMatrix.lcmm(6, 3, 5, 2), 30) + + def get_gluon_amplitude(self, n_final): + """Amplitude for g g > n_final gluons, using the test model.""" + + myleglist = base_objects.LegList() + myleglist.append(base_objects.Leg({'id':21, 'state':False})) + myleglist.append(base_objects.Leg({'id':21, 'state':False})) + myleglist.extend([base_objects.Leg({'id':21, + 'state':True})] * n_final) + myamplitude = diagram_generation.Amplitude() + myamplitude.set('process', base_objects.Process({'legs':myleglist, + 'model':self.mymodel})) + myamplitude.generate_diagrams() + return myamplitude + + def get_quark_amplitude(self, n_pairs): + """Amplitude for u u~ > n_pairs (u u~), using the test model.""" + + myleglist = base_objects.LegList() + myleglist.append(base_objects.Leg({'id':2, 'state':False})) + myleglist.append(base_objects.Leg({'id':-2, 'state':False})) + for _ in range(n_pairs): + myleglist.append(base_objects.Leg({'id':2, 'state':True})) + myleglist.append(base_objects.Leg({'id':-2, 'state':True})) + myamplitude = diagram_generation.Amplitude() + myamplitude.set('process', base_objects.Process({'legs':myleglist, + 'model':self.mymodel})) + myamplitude.generate_diagrams() + return myamplitude + + def test_permute_immutable(self): + """Test the canonical form of a permuted color structure: traces are + cyclic so they are rotated to start on their smallest index, open + chains are not, and the color objects are sorted.""" + + # Tr is cyclic: exchanging 1 and 3 has to be rotated back + self.assertEqual(color_amp.permute_immutable((('Tr', (1, 2, 3, 4)),), + {1: 3, 3: 1}), + (('Tr', (1, 4, 3, 2)),)) + # identity permutation still canonicalises + self.assertEqual(color_amp.permute_immutable((('Tr', (3, 4, 1, 2)),), + {}), + (('Tr', (1, 2, 3, 4)),)) + # T is an open chain, no rotation, but the factors get sorted + self.assertEqual(color_amp.permute_immutable((('T', (5, 2, 1)), + ('T', (5, 4, 3))), + {1: 3, 3: 1}), + (('T', (5, 2, 3)), ('T', (5, 4, 1)))) + # indices which are not in the permutation are left alone + self.assertEqual(color_amp.permute_immutable((('ColorOne', ()),), {}), + (('ColorOne', ()),)) + + def test_color_basis_symmetry(self): + """The permutations found have to map the basis onto itself, and for + a pure gluon process they act transitively so a single row of the + color matrix determines all the others.""" + + for n_final in range(1, 4): + col_basis = color_amp.ColorBasis(self.get_gluon_amplitude(n_final)) + keys = sorted(col_basis.keys()) + symmetry = color_amp.ColorBasisSymmetry(keys) + + positions = dict((key, i) for i, key in enumerate(keys)) + for induced in symmetry.generators1: + # a permutation of the basis, and an involution + self.assertEqual(sorted(induced), list(range(len(keys)))) + for i in range(len(keys)): + self.assertEqual(induced[induced[i]], i) + + # all gluons are equivalent, so there is a single orbit + self.assertEqual(len(symmetry.representatives), 1) + # every row is reachable from the representative + self.assertEqual(symmetry.row_rep, [0] * len(keys)) + self.assertEqual(len(positions), len(keys)) + + def test_color_basis_symmetry_no_symmetry(self): + """When no index permutation maps the basis onto itself, every row is + its own representative and the color matrix falls back to the plain + scan, which still has to give a correct symmetric matrix.""" + + # no transposition maps these bases onto themselves + for keys in [[(('T', (1, 2, 3)),), (('Tr', (1, 2, 3)),)], + [(('T', (1, 2, 3)),)]]: + symmetry = color_amp.ColorBasisSymmetry(keys) + self.assertEqual(symmetry.generators1, []) + self.assertFalse(symmetry.has_symmetry()) + self.assertEqual(symmetry.representatives, list(range(len(keys)))) + + # the color matrix then falls back to the plain scan + keys = [(('T', (1, 2, 3)),)] + col_matrix = color_amp.ColorMatrix(dict((key, []) for key in keys), + Nc=3) + self.assertEqual(len(col_matrix), 1) + self.assertEqual(col_matrix.col_matrix_fixed_Nc[(0, 0)], + (fractions.Fraction(4), 0)) + + def test_color_matrix_matches_direct_computation(self): + """The color matrix built by orbits must agree entry by entry with the + one obtained by computing every entry independently.""" + + for amplitude in [self.get_gluon_amplitude(2), + self.get_gluon_amplitude(3), + self.get_quark_amplitude(1)]: + col_basis = color_amp.ColorBasis(amplitude) + col_matrix = color_amp.ColorMatrix(col_basis, Nc=3) + keys = sorted(col_basis.keys()) + + for i1, struct1 in enumerate(keys): + for i2, struct2 in enumerate(keys): + new_struct2 = color_amp.ColorMatrix.fix_summed_indices( + struct1, struct2) + result, result_fixed_Nc = col_matrix.create_new_entry( + struct1, new_struct2, None, None, 3) + self.assertEqual(col_matrix.col_matrix_fixed_Nc[(i1, i2)], + result_fixed_Nc) + # entries are recycled between equivalent index pairs, so + # the terms of the color factor can come in another order + self.assertEqual(sorted(map(str, col_matrix[(i1, i2)])), + sorted(map(str, result))) + + # the mapping interface still behaves like the dictionary it was + self.assertEqual(len(col_matrix), len(keys) ** 2) + self.assertTrue(col_matrix) + self.assertTrue((0, 0) in col_matrix) + self.assertFalse((0, len(keys)) in col_matrix) + self.assertEqual(sorted(col_matrix.keys()), + sorted([(i, j) for i in range(len(keys)) + for j in range(len(keys))])) + + def test_relabel_canonical(self): + """The shortcut taken when a simplified color factor is recycled with + relabelled indices must agree with the full simplification, and it has + to be the path actually taken for QCD processes.""" + + col_basis = color_amp.ColorBasis() + col_basis.build(self.get_gluon_amplitude(3)) + self.assertTrue(col_basis._fast_relabel_dict) + self.assertTrue(all(col_basis._fast_relabel_dict.values())) + + # explicitly compare both paths on every recycled structure + for color_dict in col_basis._list_color_dict: + for col_str in color_dict.values(): + canonical_rep, rep_dict = col_str.to_canonical() + if canonical_rep not in col_basis._canonical_dict: + continue + col_fact = col_basis._canonical_dict[canonical_rep].create_copy() + col_fact.replace_indices(col_basis._invert_dict(rep_dict)) + for one_str in col_fact: + one_str.coeff = one_str.coeff * col_str.coeff + slow = col_fact.create_copy().simplify().simplify() + fast = col_basis._canonicalize_strings(col_fact) + self.assertEqual([s.to_immutable() for s in fast], + [s.to_immutable() for s in slow]) + self.assertEqual([s.coeff for s in fast], + [s.coeff for s in slow]) + + def test_color_factor_simplify_merges_like_strings(self): + """ColorFactor.simplify has to add up similar strings, keeping them in + order of first appearance.""" + + col_fact = color.ColorFactor([ + color.ColorString([color.T(1, 2, 3)], + coeff=fractions.Fraction(2, 3)), + color.ColorString([color.T(4, 5, 6)], + coeff=fractions.Fraction(1, 5)), + color.ColorString([color.T(1, 2, 3)], + coeff=fractions.Fraction(1, 3))]) + result = col_fact.simplify() + self.assertEqual([col_str.to_immutable() for col_str in result], + [(('T', (1, 2, 3)),), (('T', (4, 5, 6)),)]) + self.assertEqual([col_str.coeff for col_str in result], + [fractions.Fraction(1, 1), fractions.Fraction(1, 5)]) + + # strings adding up to zero are dropped + col_fact = color.ColorFactor([ + color.ColorString([color.T(1, 2, 3)], + coeff=fractions.Fraction(1, 3)), + color.ColorString([color.T(1, 2, 3)], + coeff=fractions.Fraction(-1, 3))]) + self.assertEqual(len(col_fact.simplify()), 0) + + def test_spanning_generators(self): + """The spanning subset has to still reach every line, and dropping the + redundant generators has to make a real difference.""" + + col_basis = color_amp.ColorBasis(self.get_gluon_amplitude(3)) + keys = sorted(col_basis.keys()) + symmetry = color_amp.ColorBasisSymmetry(keys) + kept = symmetry.spanning_generators() + self.assertTrue(0 < len(kept) < len(symmetry.generators1)) + + representatives, representative, parent, gens = symmetry.spanning_tree() + self.assertEqual(len(gens), len(kept)) + # a single orbit, and every line but the representative has a parent + self.assertEqual(representatives, [0]) + self.assertEqual(representative, [0] * len(keys)) + self.assertEqual(parent[0], None) + self.assertTrue(all(parent[i] is not None + for i in range(1, len(keys)))) + # every parent link is a generator applied to the parent line + for line in range(1, len(keys)): + origin, local = parent[line] + self.assertEqual(gens[local][origin], line) + + def test_spanning_tree_rebuilds_color_matrix(self): + """Following the parents back to the representative gives the column + permutation relating the two lines, which has to rebuild the matrix.""" + + for amplitude in [self.get_gluon_amplitude(2), + self.get_gluon_amplitude(3), + self.get_quark_amplitude(1)]: + col_basis = color_amp.ColorBasis(amplitude) + col_matrix = color_amp.ColorMatrix(col_basis, Nc=3) + keys = sorted(col_basis.keys()) + symmetry = color_amp.ColorBasisSymmetry(keys) + representatives, representative, parent, gens = \ + symmetry.spanning_tree() + denominator = max(col_matrix.get_line_denominators()) + rows = [col_matrix.get_line_numerators(i, denominator) + for i in range(len(keys))] + + for line in range(len(keys)): + # walk up to the representative, permuting the columns + perm = list(range(len(keys))) + node = line + while parent[node] is not None: + origin, local = parent[node] + perm = [gens[local][p] for p in perm] + node = origin + self.assertEqual(node, representative[line]) + self.assertEqual([rows[node][perm[j]] + for j in range(len(keys))], rows[line]) + + +class DDMColorBasisTest(unittest.TestCase): + """Test the Del Duca-Dixon-Maltoni (n-2)! adjoint color basis""" + + mypartlist = base_objects.ParticleList() + myinterlist = base_objects.InteractionList() + mymodel = base_objects.Model() + + def setUp(self): + # same gluon + quark model as ColorSquareTest, built only once + if not len(self.mypartlist): + ColorSquareTest.setUp(self) + color_amp.set_ddm_basis(True) + + def tearDown(self): + color_amp.set_ddm_basis(False) + + def get_amplitude(self, ids): + """Amplitude for the process with the given pdg codes, the first two + being in the initial state.""" + + myleglist = base_objects.LegList() + for i, pdg in enumerate(ids): + myleglist.append(base_objects.Leg({'id': pdg, 'state': i > 1})) + + myamplitude = diagram_generation.Amplitude() + myamplitude.set('process', base_objects.Process({'legs': myleglist, + 'model': self.mymodel})) + myamplitude.generate_diagrams() + + return myamplitude + + def test_ddm_half_ladder(self): + """The half-ladder structures written down explicitly""" + + self.assertEqual(color_amp.ddm_half_ladder((2,), 1, 3), + (('f', (1, 2, 3)),)) + self.assertEqual(color_amp.ddm_half_ladder((3, 2), 1, 4), + (('f', (-1, 2, 4)), ('f', (1, 3, -1)))) + self.assertEqual(color_amp.ddm_half_ladder((2, 3, 4), 1, 5), + (('f', (-2, 4, 5)), ('f', (-1, 3, -2)), + ('f', (1, 2, -1)))) + + def test_ddm_basis_size_multi_gluons(self): + """The DDM basis of gg > n*g has (n+2-2)! elements instead of (n+1)!""" + + for n, size in enumerate([1, 2, 6, 24]): + amplitude = self.get_amplitude([21] * (n + 3)) + self.assertEqual(len(color_amp.ColorBasis(amplitude)), size) + + def test_ddm_reduction_matches_trace_basis(self): + """Expanding the DDM decomposition of every diagram back on the trace + basis must give the direct trace decomposition.""" + + def trace_expand(col_str): + res = collections.defaultdict(fractions.Fraction) + for cs in color.ColorFactor([col_str]).full_simplify(): + res[(cs.to_immutable(), cs.is_imaginary, cs.Nc_power)] += \ + cs.coeff + return dict((k, v) for k, v in res.items() if v) + + for nb_gluons in range(3, 6): + amplitude = self.get_amplitude([21] * nb_gluons) + col_basis = color_amp.ColorBasis() + color_dicts = col_basis.create_color_dict_list(amplitude) + ends = col_basis.get_ddm_ends() + self.assertEqual(ends, (1, nb_gluons)) + + nb_checked = 0 + for color_dict in color_dicts: + for col_str in color_dict.values(): + via_ddm = collections.defaultdict(fractions.Fraction) + for ddm_str in color_amp.reduce_to_ddm(col_str, *ends): + for k, v in trace_expand(ddm_str).items(): + via_ddm[k] += v + self.assertEqual(trace_expand(col_str), + dict((k, v) for k, v in via_ddm.items() if v)) + nb_checked += 1 + self.assertTrue(nb_checked > 0) + + def test_ddm_color_matrix_gg_gg(self): + """The 2x2 DDM color matrix of gg > gg, N^2(N^2-1) on the diagonal and + half of it off diagonal""" + + col_basis = color_amp.ColorBasis(self.get_amplitude([21] * 4)) + col_matrix = color_amp.ColorMatrix(col_basis, Nc=3) + + self.assertEqual(len(col_basis), 2) + for i, j, goal in [(0, 0, 72), (1, 1, 72), (0, 1, 36), (1, 0, 36)]: + self.assertEqual(col_matrix.col_matrix_fixed_Nc[(i, j)], + (fractions.Fraction(goal, 1), 0)) + + def test_ddm_color_matrix_is_positive_definite(self): + """A Gram matrix of real color tensors: the diagonal must be positive + and the matrix symmetric, for an even as well as an odd number of + gluons.""" + + for nb_gluons in range(3, 6): + col_basis = color_amp.ColorBasis(self.get_amplitude([21] * nb_gluons)) + col_matrix = color_amp.ColorMatrix(col_basis, Nc=3) + for i in range(len(col_basis)): + real, imag = col_matrix.col_matrix_fixed_Nc[(i, i)] + self.assertTrue(real > 0) + self.assertEqual(imag, 0) + for j in range(len(col_basis)): + self.assertEqual(col_matrix.col_matrix_fixed_Nc[(i, j)], + col_matrix.col_matrix_fixed_Nc[(j, i)]) + + def test_ddm_fall_back_on_trace_basis(self): + """Processes which are not fully adjoint keep the trace basis""" + + # u u~ > g g has T objects in its color structures + col_basis = color_amp.ColorBasis(self.get_amplitude([2, -2, 21, 21])) + self.assertEqual(col_basis._ddm_ends, None) + self.assertEqual(len(col_basis), 2) + + # and so has u u~ > u u~ + col_basis = color_amp.ColorBasis(self.get_amplitude([2, -2, 2, -2])) + self.assertEqual(col_basis._ddm_ends, None) + self.assertEqual(len(col_basis), 2) + + def test_ddm_no_color_flow_decomposition(self): + """The color flow decomposition is not defined in the DDM basis, and + must say so instead of returning something wrong.""" + + col_basis = color_amp.ColorBasis(self.get_amplitude([21] * 4)) + self.assertRaises(color_amp.ColorBasis.ColorBasisError, + col_basis.color_flow_decomposition, + {1: 8, 2: 8, 3: 8, 4: 8}, 2) diff --git a/tests/unit_tests/core/test_diagram_generation.py b/tests/unit_tests/core/test_diagram_generation.py index 729c50ce3b..256950246b 100755 --- a/tests/unit_tests/core/test_diagram_generation.py +++ b/tests/unit_tests/core/test_diagram_generation.py @@ -17,6 +17,7 @@ from __future__ import absolute_import import copy +import fractions import itertools import logging import math @@ -24,7 +25,10 @@ import tests.unit_tests as unittest +import madgraph +import madgraph.various.misc as misc import madgraph.core.base_objects as base_objects +import madgraph.core.color_amp as color_amp import madgraph.core.diagram_generation as diagram_generation import models.import_ufo as import_ufo from madgraph import MadGraph5Error, InvalidCmd @@ -53,7 +57,8 @@ class AmplitudeTest(unittest.TestCase): def setUp(self): self.mydict = {'diagrams':self.mydiaglist, 'process':self.myprocess, - 'has_mirror_process': False} + 'has_mirror_process': False, + 'crossed_processes': []} self.myamplitude = diagram_generation.Amplitude(self.mydict) @@ -124,7 +129,8 @@ def test_representation(self): goal = "{\n" goal = goal + " \'process\': %s,\n" % repr(self.myprocess) goal = goal + " \'diagrams\': %s,\n" % repr(self.mydiaglist) - goal = goal + " \'has_mirror_process\': False\n}" + goal = goal + " \'has_mirror_process\': False,\n" + goal = goal + " \'crossed_processes\': []\n}" self.assertEqual(goal, str(self.myamplitude)) @@ -3745,7 +3751,11 @@ def test_diagram_tag_gg_ggg(self): myproc = base_objects.Process({'legs':myleglist, 'model':self.base_model}) - myamplitude = diagram_generation.Amplitude(myproc) + # DiagramTag is what is checked here, against diagram numbers, so it + # wants the plain generation order -- the four gluon merging reorders + # them + with misc.TMP_variable(madgraph, 'merge_quartic_vertices', False): + myamplitude = diagram_generation.Amplitude(myproc) tags = [] permutations = [] @@ -3893,3 +3903,468 @@ def test_diagram_tag_to_diagram_uux_nglue(self): self.assertEqual(dtag, diagram_generation.DiagramTag(\ dtag.diagram_from_tag(self.base_model))) + +#=============================================================================== +# TestQuarticUnrolling +#=============================================================================== +class TestQuarticUnrolling(unittest.TestCase): + """Test the unrolling of quartic vertices into pairs of cubic ones""" + + def setUp(self): + self.base_model = import_ufo.import_model('sm') + + def make_amplitude(self, initial, final, orders=None): + myleglist = base_objects.LegList( + [base_objects.Leg({'id':pdg, 'state':False}) for pdg in initial] + + [base_objects.Leg({'id':pdg, 'state':True}) for pdg in final]) + mydict = {'legs':myleglist, 'model':self.base_model} + if orders: + mydict['orders'] = orders + return diagram_generation.Amplitude(base_objects.Process(mydict)) + + def colour_directions(self, amplitude): + """Return {(diagram, colour chain): (direction, norm)} where direction + is the colour vector of that contribution normalised to its first non + zero entry. Two contributions can be summed exactly when they share a + direction, the relative weight being the ratio of their norms. This is + computed from the colour algebra and is completely independent from + the unrolling being tested.""" + + basis = color_amp.ColorBasis() + basis.build(amplitude) + components = {} + for index, key in enumerate(sorted(basis.keys())): + for (diag, chain, coeff, imag, nc, loop_nc) in basis[key]: + components.setdefault((diag, chain), {})[index] = \ + (fractions.Fraction(coeff), imag, nc, loop_nc) + + res = {} + for piece, entries in components.items(): + first = min(entries) + norm, imag, nc, loop_nc = entries[first] + res[piece] = (tuple(sorted( + (i, c / norm, m ^ imag, n - nc, l - loop_nc) + for i, (c, m, n, l) in entries.items())), norm) + return res + + def check_process(self, initial, final, nlink, orders=None): + """Every link must reproduce the colour algebra, and every quartic + contribution must be linked to exactly one cubic diagram.""" + + amplitude = self.make_amplitude(initial, final, orders) + links = amplitude.unroll_quartic_vertices() + directions = self.colour_directions(amplitude) + diagrams = amplitude.get('diagrams') + + self.assertEqual(len(links), nlink) + + for (diag, chain), (target, coeff) in links.items(): + target_chain = (0,) * len(diagrams[target].get('vertices')) + source_dir, source_norm = directions[(diag, chain)] + target_dir, target_norm = directions[(target, target_chain)] + # same colour direction, and the coefficient is the relative weight + self.assertEqual(source_dir, target_dir) + self.assertEqual(coeff, source_norm / target_norm) + # the target is a genuine cubic diagram + self.assertEqual(amplitude.unrolled_diagram( + diagrams[target], {}, + diagram_generation.get_unrollable_quartic_vertices( + self.base_model)).get('vertices'), + diagrams[target].get('vertices')) + + # nothing is left behind: the contributions which are not linked are + # exactly the ones carried by a diagram without any quartic vertex + unrollable = diagram_generation.get_unrollable_quartic_vertices( + self.base_model) + cubic = [i for i, d in enumerate(diagrams) + if not any(v.get('id') in unrollable + for v in d.get('vertices'))] + unlinked = [piece for piece in directions if piece not in links] + self.assertEqual(sorted(piece[0] for piece in unlinked), sorted(cubic)) + + def test_unrollable_vertices_sm(self): + """The four gluon vertex is the only one factorising in the SM""" + + unrollable = diagram_generation.get_unrollable_quartic_vertices( + self.base_model) + self.assertEqual(len(unrollable), 1) + (cubic, pairings), = unrollable.values() + quartic, = unrollable.keys() + self.assertEqual([p.get_pdg_code() for p in + self.base_model.get_interaction(quartic).get('particles')], + [21, 21, 21, 21]) + self.assertEqual([p.get_pdg_code() for p in + self.base_model.get_interaction(cubic).get('particles')], + [21, 21, 21]) + # each colour structure splits the four legs into two pairs + self.assertEqual(pairings, [((0, 1), (2, 3)), + ((0, 2), (1, 3)), + ((0, 3), (1, 2))]) + + def test_unroll_gg_gg(self): + """g g > g g: the contact term merges into the s, t and u channel""" + + self.check_process([21, 21], [21, 21], 3) + + def test_unroll_gg_ggg(self): + """g g > g g g: 45 contributions collapse onto the 15 cubic diagrams""" + + self.check_process([21, 21], [21, 21, 21], 30) + + def test_unroll_gg_gggg(self): + """g g > g g g g: 510 contributions collapse onto 105 cubic diagrams""" + + self.check_process([21, 21], [21, 21, 21, 21], 405) + + def test_unroll_bbx_ggg(self): + """A quartic vertex sitting next to a colour triplet line""" + + self.check_process([5, -5], [21, 21, 21], 3) + + def test_unroll_gg_ttxg(self): + """The quartic vertex feeding an internal gluon of a t t~ pair""" + + self.check_process([21, 21], [6, -6, 21], 3) + + def test_links_match_helas_colour_indices(self): + """The links must be usable against HelasAmplitude. + + helas_objects colorizes the reconstructed base amplitude, whose + vertex order can differ from the generated diagrams, so the chains + only line up with HelasAmplitude.get('color_indices') when the + unrolling is run on get_base_amplitude().""" + + import madgraph.core.helas_objects as helas_objects + + amplitude = self.make_amplitude([21, 21], [21, 21, 21]) + matrix_element = helas_objects.HelasMatrixElement(amplitude) + base = matrix_element.get('base_amplitude') + + known = {} + for diagram in matrix_element.get('diagrams'): + for helas_amp in diagram.get('amplitudes'): + known[(diagram.get('number') - 1, + tuple(helas_amp.get('color_indices')))] = \ + helas_amp.get('number') + + links = base.unroll_quartic_vertices() + self.assertTrue(links) + for (diag, chain), (target, _) in links.items(): + target_chain = (0,) * len(base.get('diagrams')[target].get('vertices')) + self.assertIn((diag, chain), known) + self.assertIn((target, target_chain), known) + # every amplitude is either a merge target or folded into one + self.assertEqual(len(known) - len(links), + len(set(target for target, _ in links.values()))) + + def test_no_unrolling_without_quartic(self): + """Processes without a four gluon vertex give no link""" + + amplitude = self.make_amplitude([5, -5], [5, -5]) + self.assertEqual(amplitude.unroll_quartic_vertices(), {}) + +#=============================================================================== +# TestSeedRule +#=============================================================================== +class TestSeedRule(unittest.TestCase): + """Test the seed rule: no two cubic gluon vertices sharing a line. + + Unrolling a quartic vertex always yields two cubic vertices joined by the + line which replaced it, so the diagrams generation has to keep are exactly + those with no such pair to contract back.""" + + def setUp(self): + self.base_model = import_ufo.import_model('sm') + self.cubic_ids = diagram_generation.get_unrollable_cubic_ids( + self.base_model) + self.merge_quartic = madgraph.merge_quartic_vertices + + def tearDown(self): + madgraph.merge_quartic_vertices = self.merge_quartic + + def make_diagrams(self, initial, final, seed): + """The generated diagrams, either the full set or -- with the seed + rule on and the expansion stopped -- the seed it starts from.""" + + madgraph.merge_quartic_vertices = seed + myleglist = base_objects.LegList( + [base_objects.Leg({'id':pdg, 'state':False}) for pdg in initial] + + [base_objects.Leg({'id':pdg, 'state':True}) for pdg in final]) + process = base_objects.Process({'legs':myleglist, + 'model':self.base_model}) + if not seed: + return diagram_generation.Amplitude(process).get('diagrams') + + class SeedOnlyAmplitude(diagram_generation.Amplitude): + """Stops after the seed, so that it can be looked at""" + def expand_seed_diagrams(self, seed): + return seed + + return SeedOnlyAmplitude(process).get('diagrams') + + def cubic_adjacencies(self, diagram): + """Number of lines joining two cubic gluon vertices, counted without + any help from the generation. A line is recognised by its leg number + being live, a vertex reusing the smallest number coming in for the leg + it produces.""" + + vertices = diagram.get('vertices') + last = len(vertices) - 1 + live = {} + count = 0 + for i, vertex in enumerate(vertices): + legs = vertex.get('legs') + for leg in (legs if i == last else legs[:-1]): + producer = live.pop(leg.get('number'), None) + if producer is not None and \ + vertices[producer].get('id') in self.cubic_ids and \ + vertex.get('id') in self.cubic_ids: + count += 1 + if i != last: + live[legs[-1].get('number')] = i + self.assertFalse(live) + return count + + def check_process(self, initial, final, nfull, nseed): + """The generated seed has to be exactly the full generation filtered + on the rule -- same diagrams, not merely the same count.""" + + full = self.make_diagrams(initial, final, False) + seed = self.make_diagrams(initial, final, True) + self.assertEqual(len(full), nfull) + self.assertEqual(len(seed), nseed) + + def tags(diagrams): + return set(str(diagram_generation.UnrollDiagramTag( + diagram, self.base_model, len(initial))) + for diagram in diagrams) + + self.assertEqual(tags(seed), + tags([d for d in full + if not self.cubic_adjacencies(d)])) + self.assertFalse([d for d in seed if self.cubic_adjacencies(d)]) + + def test_seed_gg_gg(self): + """g g > g g: only the contact term has no cubic pair""" + + self.check_process([21, 21], [21, 21], 4, 1) + + def test_seed_gg_ggg(self): + """g g > g g g: the ten one quartic one cubic diagrams""" + + self.check_process([21, 21], [21, 21, 21], 25, 10) + + def test_seed_gg_gggg(self): + """g g > g g g g: 45 with a quartic in the middle, 10 with two""" + + self.check_process([21, 21], [21, 21, 21, 21], 220, 55) + + def test_seed_gg_ggggg(self): + """g g > g g g g g""" + + self.check_process([21, 21], [21, 21, 21, 21, 21], 2485, 385) + + def test_seed_gg_ttxgg(self): + """A cubic gluon vertex next to a quark line is not touched""" + + self.check_process([21, 21], [6, -6, 21, 21], 123, 84) + + def check_expansion(self, initial, final, nfull, nlink): + """Expanding the seed has to give the baseline diagram set back, and + the links it records have to be the ones the colour algebra gives.""" + + base = self.make_diagrams(initial, final, False) + madgraph.merge_quartic_vertices = True + myleglist = base_objects.LegList( + [base_objects.Leg({'id':pdg, 'state':False}) for pdg in initial] + + [base_objects.Leg({'id':pdg, 'state':True}) for pdg in final]) + amplitude = diagram_generation.Amplitude(base_objects.Process( + {'legs':myleglist, 'model':self.base_model})) + expanded = amplitude.get('diagrams') + + def tags(diagrams): + return set(str(diagram_generation.UnrollDiagramTag( + diagram, self.base_model, len(initial))) + for diagram in diagrams) + + # same diagrams, and no diagram reached twice + self.assertEqual(len(expanded), nfull) + self.assertEqual(len(base), nfull) + self.assertEqual(tags(expanded), tags(base)) + self.assertEqual(len(tags(expanded)), nfull) + + # the links recorded while expanding, and the independent ones + recorded = amplitude.get_quartic_unroll_links() + colour = amplitude.unroll_quartic_vertices() + self.assertEqual(len(recorded), nlink) + self.assertEqual(set(recorded), set(colour)) + for key, target in recorded.items(): + self.assertEqual(target, colour[key][0]) + + def test_expand_gg_gg(self): + """g g > g g: the contact term unrolls into s, t and u""" + + self.check_expansion([21, 21], [21, 21], 4, 3) + + def test_expand_gg_ggg(self): + """g g > g g g""" + + self.check_expansion([21, 21], [21, 21, 21], 25, 30) + + def test_expand_gg_gggg(self): + """g g > g g g g: 55 seeds reach all 220 diagrams""" + + self.check_expansion([21, 21], [21, 21, 21, 21], 220, 405) + + def test_expand_gg_ttxgg(self): + """Expansion leaves the quark lines alone""" + + self.check_expansion([21, 21], [6, -6, 21, 21], 123, 54) + + def test_current_sums_gg_ggg(self): + """g g > g g g: seven quartic amplitudes become a current sum""" + + self.check_current_sums([21, 21], [21, 21, 21], 7, 7) + + def test_current_sums_gg_gggg(self): + """g g > g g g g: thirty sums take sixty amplitude calls away""" + + self.check_current_sums([21, 21], [21, 21, 21, 21], 30, 60) + + def test_current_sums_inactive_by_default(self): + """No current sum unless madgraph.merge_quartic_vertices is set""" + + import madgraph.core.helas_objects as helas_objects + + madgraph.merge_quartic_vertices = False + myleglist = base_objects.LegList( + [base_objects.Leg({'id':21, 'state':False})] * 2 + + [base_objects.Leg({'id':21, 'state':True})] * 3) + matrix_element = helas_objects.HelasMatrixElement( + diagram_generation.Amplitude(base_objects.Process( + {'legs':myleglist, 'model':self.base_model}))) + self.assertEqual(matrix_element.get_quartic_current_sums(), + ([], {}, set())) + self.assertEqual(matrix_element.get_quartic_sum_me_ids(), []) + + def check_current_sums(self, initial, final, nsum, nfolded): + """A current sum has to stand for exactly the amplitude it takes away: + the same vertex, with the quartic current where the target has the + cubic one.""" + + import madgraph.core.helas_objects as helas_objects + + madgraph.merge_quartic_vertices = True + myleglist = base_objects.LegList( + [base_objects.Leg({'id':pdg, 'state':False}) for pdg in initial] + + [base_objects.Leg({'id':pdg, 'state':True}) for pdg in final]) + matrix_element = helas_objects.HelasMatrixElement( + diagram_generation.Amplitude(base_objects.Process( + {'legs':myleglist, 'model':self.base_model}))) + + sums, uses, folded = matrix_element.get_quartic_current_sums() + merges = matrix_element.get_quartic_amplitude_merges() + self.assertEqual(len(sums), nsum) + self.assertEqual(len(folded), nfolded) + # every sum gets a wavefunction slot, out of the same pool as the + # wavefunctions themselves + slots = matrix_element.get_quartic_sum_me_ids() + self.assertEqual(len(slots), nsum) + self.assertTrue(all(slot > 0 for slot in slots)) + + amplitudes = dict((amplitude.get('number'), amplitude) + for diagram in matrix_element.get('diagrams') + for amplitude in diagram.get('amplitudes')) + unrollable = diagram_generation.get_unrollable_quartic_vertices( + self.base_model) + + # every sum is a quartic current against the cubic pair it splits into + for cubic, quartic, coeff in sums: + self.assertTrue(helas_objects.HelasMatrixElement.is_unrolled_pair( + quartic, cubic, unrollable, self.cubic_ids)) + + # and every amplitude taken away is the target with some of the + # substitutions applied, which is exactly what reading the sums gives + for source in folded: + self.assertIn(source, merges) + target, coeff = merges[source] + self.assertIn(target, uses) + swap = dict((cubic, sums[index][1].get('number')) + for cubic, index in uses[target].items()) + mothers = [mother.get('number') + for mother in amplitudes[target].get('mothers')] + options = [] + for size in range(1, len(swap) + 1): + for combination in itertools.combinations(sorted(swap), size): + options.append(sorted(swap[number] + if number in combination else number + for number in mothers)) + self.assertIn(sorted(mother.get('number') for mother in + amplitudes[source].get('mothers')), options) + self.assertEqual(amplitudes[source].get('interaction_id'), + amplitudes[target].get('interaction_id')) + + # a folded amplitude must not also be a target + self.assertFalse(folded & set(uses)) + + def check_auto_order(self, initial, final): + """'auto' has to generate the 'speed' order, and 'slots' has to be + that same order reversed. + + This is what lets an output pick between the two: by then the diagrams + exist and only their order can still be changed, so the two modes are + only interchangeable at that point if one is the other backwards.""" + + def tags(mode): + madgraph.merge_quartic_vertices = mode + amplitude = diagram_generation.Amplitude(base_objects.Process( + {'legs':base_objects.LegList( + [base_objects.Leg({'id':pdg, 'state':False}) + for pdg in initial] + + [base_objects.Leg({'id':pdg, 'state':True}) + for pdg in final]), + 'model':self.base_model})) + return [str(diagram_generation.UnrollDiagramTag( + diagram, self.base_model, len(initial))) + for diagram in amplitude.get('diagrams')] + + speed, slots, auto = tags('speed'), tags('slots'), tags('auto') + self.assertEqual(auto, speed) + self.assertEqual(slots, speed[::-1]) + # and it is a reordering, nothing gained or lost + self.assertEqual(sorted(slots), sorted(speed)) + self.assertEqual(len(set(speed)), len(speed)) + + def test_auto_order_gg_ggg(self): + self.check_auto_order([21, 21], [21, 21, 21]) + + def test_auto_order_gg_gggg(self): + self.check_auto_order([21, 21], [21, 21, 21, 21]) + + def test_auto_current_sums(self): + """'auto' keeps the sums, being the 'speed' order; 'slots' drops them + because reversing puts every target ahead of what feeds it""" + + import madgraph.core.helas_objects as helas_objects + + for mode, wanted in (('auto', 7), ('speed', 7), ('slots', 0)): + madgraph.merge_quartic_vertices = mode + amplitude = diagram_generation.Amplitude(base_objects.Process( + {'legs':base_objects.LegList( + [base_objects.Leg({'id':21, 'state':False})] * 2 + + [base_objects.Leg({'id':21, 'state':True})] * 3), + 'model':self.base_model})) + element = helas_objects.HelasMatrixElement(amplitude) + self.assertEqual(len(element.get_quartic_current_sums()[0]), wanted) + + def test_seed_inactive_by_default(self): + """Nothing changes unless madgraph.merge_quartic_vertices is set""" + + madgraph.merge_quartic_vertices = False + amplitude = diagram_generation.Amplitude(base_objects.Process( + {'legs':base_objects.LegList( + [base_objects.Leg({'id':21, 'state':False})] * 2 + + [base_objects.Leg({'id':21, 'state':True})] * 2), + 'model':self.base_model})) + self.assertEqual(amplitude.seed_forbidden_cubic_ids, frozenset()) + self.assertEqual(len(amplitude.get('diagrams')), 4) diff --git a/tests/unit_tests/fks/test_fks_base.py b/tests/unit_tests/fks/test_fks_base.py index f975840a39..9c6a39e923 100755 --- a/tests/unit_tests/fks/test_fks_base.py +++ b/tests/unit_tests/fks/test_fks_base.py @@ -23,6 +23,7 @@ import tests.unit_tests as unittest import madgraph.various.misc as misc +import madgraph import madgraph.fks.fks_base as fks_base import madgraph.fks.fks_common as fks_common import madgraph.core.base_objects as MG @@ -35,6 +36,15 @@ class TestFKSProcess(unittest.TestCase): """a class to test FKS Processes""" + def setUp(self): + # these build the fks amplitudes by hand, so they have to turn the + # four gluon merging off themselves, as FKSMultiProcess does + self.merge_quartic = madgraph.merge_quartic_vertices + madgraph.merge_quartic_vertices = False + + def tearDown(self): + madgraph.merge_quartic_vertices = self.merge_quartic + # the model, import the SM but remove 2nd and 3rd gen quarks remove_list = [3,4,5,6,-3,-4,-5,-6] mymodel = import_ufo.import_model('sm', options={'apply_flavor_grouping':False}) diff --git a/tests/unit_tests/fks/test_fks_common.py b/tests/unit_tests/fks/test_fks_common.py index 879f4d6462..79919e196f 100755 --- a/tests/unit_tests/fks/test_fks_common.py +++ b/tests/unit_tests/fks/test_fks_common.py @@ -24,6 +24,7 @@ sys.path.insert(0, os.path.join(root_path,'..','..')) import tests.unit_tests as unittest +import madgraph import madgraph.fks.fks_common as fks_common import madgraph.core.base_objects as MG import madgraph.core.color_algebra as color @@ -2933,9 +2934,16 @@ class TestLinkRBConfHEFT(unittest.TestCase): (only processes with 3 point interactions)""" def setUp(self): + # link_rb_configs reads the vertex decomposition of the real diagrams, + # so it runs with the four gluon merging off, as FKSMultiProcess does + self.merge_quartic = madgraph.merge_quartic_vertices + madgraph.merge_quartic_vertices = False if not hasattr(self, 'base_model'): TestLinkRBConfHEFT.base_model = import_ufo.import_model('heft') + def tearDown(self): + madgraph.merge_quartic_vertices = self.merge_quartic + def test_link_gghg_ggh(self): """tests that the real emission process gg>hg and born process gg>h are @@ -3062,9 +3070,16 @@ class TestLinkRBConfSM(unittest.TestCase): (only processes with 3 point interactions)""" def setUp(self): + # link_rb_configs reads the vertex decomposition of the real diagrams, + # so it runs with the four gluon merging off, as FKSMultiProcess does + self.merge_quartic = madgraph.merge_quartic_vertices + madgraph.merge_quartic_vertices = False if not hasattr(self, 'base_model'): TestLinkRBConfSM.base_model = import_ufo.import_model('sm', options={'apply_flavor_grouping':False}) + def tearDown(self): + madgraph.merge_quartic_vertices = self.merge_quartic + def test_link_udxwpg_udxwp(self): """tests that the real emission process ud~>w+g and born process u u~>w+ are correctly linked""" diff --git a/tests/unit_tests/interface/test_reweight_density.py b/tests/unit_tests/interface/test_reweight_density.py new file mode 100644 index 0000000000..fdfc8bc8b5 --- /dev/null +++ b/tests/unit_tests/interface/test_reweight_density.py @@ -0,0 +1,223 @@ +############################################################################## +# +# Copyright (c) 2010 The MadGraph Development team and Contributors +# +# This file is a part of the MadGraph 5 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 MadGraph license which should accompany this +# distribution. +# +# For more information, please visit: http://madgraph.phys.ucl.ac.be +# +################################################################################ +""" Test of the average density matrix helpers of the density mode. + +Those helpers are shared by DensityInterface (which accumulates the average +while it reweights the events) and by CommonRunCmd.do_reweight (which has to +re-build the average from the recombined event file after a multicore run). +""" + +from __future__ import absolute_import +import os +import shutil +import tempfile +import unittest + +import madgraph.interface.reweight_interface as rwgt_interface + +pjoin = os.path.join + + +class TestAverageDensityMatrix(unittest.TestCase): + """check the average density matrix helpers""" + + # a 2x2 density matrix is stored as its upper triangle: (00, 01, 11) + events = [(1.0, [0.6+0j, 0.1+0.2j, 0.4+0j]), + (2.0, [0.5+0j, 0.0-0.1j, 0.5+0j]), + (1.0, [0.7+0j, 0.2+0.0j, 0.3+0j])] + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix='rwgt_density') + self.lhe_path = pjoin(self.tmpdir, 'unweighted_events.lhe') + self.write_lhe(self.lhe_path, self.events) + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + @staticmethod + def write_lhe(path, events): + """write a minimal event file where each event carries a tag, + exactly as DensityInterface does""" + + text = ['', '', ''] + for wgt, density in events: + text.append('') + text.append(' 2 1 %+13.7e 1.0000000e+02 7.5000000e-03 1.2000000e-01' % wgt) + text.append(' 21 -1 0 0 501 502 0. 0. 0. 0. 0. 0. 1.') + text.append(' 6 1 1 2 501 0 0. 0. 0. 0. 0. 0. 1.') + text.append(' %s' % \ + ''.join('%s ' % complex(value) for value in density)) + text.append('') + text.append('') + with open(path, 'w') as fsock: + fsock.write('\n'.join(text) + '\n') + + def test_average_normalised(self): + """with matrix_normalisation the average is weighted by the event weight""" + + rho_avg = rwgt_interface.average_density_matrix_from_lhe(self.lhe_path, True) + + total_wgt = sum(wgt for wgt, _ in self.events) + solution = [sum(wgt * density[i] for wgt, density in self.events) / total_wgt + for i in range(len(self.events[0][1]))] + self.assertEqual(len(rho_avg), len(solution)) + for value, expected in zip(rho_avg, solution): + self.assertAlmostEqual(value.real, expected.real, places=12) + self.assertAlmostEqual(value.imag, expected.imag, places=12) + + def test_average_not_normalised(self): + """without matrix_normalisation the average is a plain event average""" + + rho_avg = rwgt_interface.average_density_matrix_from_lhe(self.lhe_path, False) + + nb_event = len(self.events) + solution = [sum(density[i] for _, density in self.events) / nb_event + for i in range(len(self.events[0][1]))] + for value, expected in zip(rho_avg, solution): + self.assertAlmostEqual(value.real, expected.real, places=12) + self.assertAlmostEqual(value.imag, expected.imag, places=12) + + def test_average_no_density(self): + """an event file without density matrix returns nothing""" + + path = pjoin(self.tmpdir, 'no_density.lhe') + with open(path, 'w') as fsock: + fsock.write(""" + + + + 2 1 +1.0000000e+00 1.0000000e+02 7.5000000e-03 1.2000000e-01 + 21 -1 0 0 501 502 0. 0. 0. 0. 0. 0. 1. + 6 1 1 2 501 0 0. 0. 0. 0. 0. 0. 1. + + +""") + self.assertEqual(rwgt_interface.average_density_matrix_from_lhe(path), None) + + def test_label_and_path(self): + """the canonical name does not depend on the file being gzipped or not""" + + self.assertEqual(rwgt_interface.average_density_matrix_label(self.lhe_path), + 'unweighted_events') + self.assertEqual(rwgt_interface.average_density_matrix_label(self.lhe_path + '.gz'), + 'unweighted_events') + self.assertEqual(rwgt_interface.average_density_matrix_path(self.lhe_path + '.gz'), + pjoin(self.tmpdir, 'Average_density_matrix_unweighted_events.txt')) + + def test_write_average(self): + """the values are written as plain complex, not as numpy repr""" + + rho_avg = rwgt_interface.average_density_matrix_from_lhe(self.lhe_path, True) + path = rwgt_interface.write_average_density_matrix(rho_avg, self.lhe_path) + + self.assertEqual(path, pjoin(self.tmpdir, + 'Average_density_matrix_unweighted_events.txt')) + text = open(path).read() + self.assertTrue(text.startswith( + 'Average density matrix of LHE file unweighted_events:\n')) + self.assertNotIn('np.complex', text) + + # the consumer parser reads the file line by line as a list of complex + rho_square = [] + for line in text.split('\n')[1:]: + if not line.strip(): + continue + rho_square.append([complex(value.strip(' ()')) + for value in line.strip('\t[]').split(',')]) + self.assertEqual(len(rho_square), 2) + for row in rho_square: + self.assertEqual(len(row), 2) + # hermitian, and the trace is the sum of the (normalised) diagonal + self.assertAlmostEqual(rho_square[0][1].real, rho_square[1][0].real, places=12) + self.assertAlmostEqual(rho_square[0][1].imag, -rho_square[1][0].imag, places=12) + self.assertAlmostEqual(rho_square[0][0].real, rho_avg[0].real, places=12) + self.assertAlmostEqual(rho_square[1][1].real, rho_avg[2].real, places=12) + + def test_combine_density_matrix(self): + """the multicore recombination writes the canonical file and removes the + per chunk ones""" + + # simulate what the multicore jobs leave behind: the recombined event file + # plus one average density matrix per chunk of events + chunks = [self.lhe_path + '.gz_%s.lhe' % i for i in range(3)] + for chunk in chunks: + with open(rwgt_interface.average_density_matrix_path(chunk), 'w') as fsock: + fsock.write('average of a single chunk of events\n') + + canonical = rwgt_interface.combine_density_matrix(self.lhe_path, chunks) + + self.assertEqual(canonical, pjoin(self.tmpdir, + 'Average_density_matrix_unweighted_events.txt')) + self.assertEqual(sorted(name for name in os.listdir(self.tmpdir) + if name.startswith('Average_density_matrix_')), + ['Average_density_matrix_unweighted_events.txt']) + + # and the content is the one of a single core run over the same events + rho_avg = rwgt_interface.average_density_matrix_from_lhe(self.lhe_path, True) + reference = pjoin(self.tmpdir, 'reference') + os.mkdir(reference) + rwgt_interface.write_average_density_matrix(rho_avg, self.lhe_path, + output_dir=reference) + self.assertEqual(open(canonical).read(), + open(pjoin(reference, + 'Average_density_matrix_unweighted_events.txt')).read()) + + def test_matrix_normalisation_from_card(self): + """the option is read from the reweight card as DensityInterface does""" + + card = pjoin(self.tmpdir, 'reweight_card.dat') + def write_card(*lines): + with open(card, 'w') as fsock: + fsock.write('\n'.join(lines) + '\n') + + # default value of DensityInterface when the option is absent + write_card('# change matrix_normalisation False', + 'change particle_in_density_matrix [6, -6]') + self.assertEqual(rwgt_interface.get_matrix_normalisation(card), True) + + write_card('change matrix_normalisation True') + self.assertEqual(rwgt_interface.get_matrix_normalisation(card), True) + + write_card('change matrix_normalisation False') + self.assertEqual(rwgt_interface.get_matrix_normalisation(card), False) + + # anything else is refused, as in do_change_matrix_normalisation + write_card('change matrix_normalisation garbage') + self.assertEqual(rwgt_interface.get_matrix_normalisation(card), False) + + # last occurence wins, as when the card is executed line by line + write_card('change matrix_normalisation True', + 'change matrix_normalisation False') + self.assertEqual(rwgt_interface.get_matrix_normalisation(card), False) + + self.assertEqual(rwgt_interface.get_matrix_normalisation( + pjoin(self.tmpdir, 'no_such_card.dat')), True) + + def test_combine_density_matrix_uses_the_card(self): + """matrix_normalisation False switches to the plain event average""" + + card = pjoin(self.tmpdir, 'reweight_card.dat') + with open(card, 'w') as fsock: + fsock.write('change matrix_normalisation False\n') + + canonical = rwgt_interface.combine_density_matrix(self.lhe_path, + reweight_card=card) + rho_avg = rwgt_interface.average_density_matrix_from_lhe(self.lhe_path, False) + rho_square = [[complex(value.strip(' ()')) + for value in line.strip('\t[]').split(',')] + for line in open(canonical).read().split('\n')[1:] if line.strip()] + + self.assertAlmostEqual(rho_square[0][0].real, rho_avg[0].real, places=12) + self.assertAlmostEqual(rho_square[1][1].real, rho_avg[2].real, places=12) diff --git a/tests/unit_tests/iolibs/test_export_cpp.py b/tests/unit_tests/iolibs/test_export_cpp.py index d621b4dd59..ddd31374c1 100755 --- a/tests/unit_tests/iolibs/test_export_cpp.py +++ b/tests/unit_tests/iolibs/test_export_cpp.py @@ -19,6 +19,8 @@ import fractions import os import re +import shutil +import tempfile import tests.IOTests as IOTests from tests import test_manager @@ -28,6 +30,7 @@ import aloha.create_aloha as create_aloha import madgraph.iolibs.export_cpp as export_cpp +import madgraph.iolibs.export_mg7 as export_mg7 import madgraph.iolibs.export_v4 as export_v4 import madgraph.iolibs.file_writers as writers import madgraph.iolibs.helas_call_writers as helas_call_writer @@ -37,6 +40,7 @@ import madgraph.core.base_objects as base_objects import madgraph.core.color_algebra as color +import madgraph.core.color_amp as color_amp import madgraph.core.helas_objects as helas_objects import madgraph.core.diagram_generation as diagram_generation @@ -920,7 +924,8 @@ def test_cpp_export_decay_chain_broken_symmetry_metadata(self): 'broken_sym_component_old_factors': ",".join(str(v) for v in sym_data['component_old_factors']), 'broken_sym_pid_list': ",".join(str(v) for v in sym_data['pid_list']), 'broken_sym_block_starts': ",".join(str(v) for v in sym_data['block_starts']), - 'broken_sym_block_lengths': ",".join(str(v) for v in sym_data['block_lengths']) + 'broken_sym_block_lengths': ",".join(str(v) for v in sym_data['block_lengths']), + 'ident_cross_function': '' } template_path = pjoin(MG5DIR, 'madgraph', 'iolibs', 'template_files', 'cpp_process_function_definitions.inc') @@ -929,3 +934,87 @@ def test_cpp_export_decay_chain_broken_symmetry_metadata(self): self.assertIn('const int n_components = 3;', rendered) self.assertIn('const int comp_old[n_components] = {1,1,1};', rendered) self.assertIn('const int block_len[n_entries] = {2,2,1,1,1,1};', rendered) + + +#=============================================================================== +# DDMColorFlowMG7Test +#=============================================================================== +class DDMColorFlowMG7Test(unittest.TestCase): + """The mg7 exporter picks a color flow among the (n-1)! trace structures + even when the color sum runs on the (n-2)! DDM ones, so everything which + indexes a color flow must be built on the trace basis. Switching the color + basis changes how the jamps are computed, never which color flows exist, + so none of it may depend on the mode.""" + + def get_exporter(self, ids, ddm, cls=None): + """The mg7 exporter for the all-gluon process with npar = len(ids), + built with or without the DDM color basis. `cls` selects a subclass + (the madmatrix one shares this constructor).""" + + cls = cls if cls else export_mg7.OneProcessExporterMG7 + color_amp.set_ddm_basis(ddm, with_flow=ddm) + try: + model = import_ufo.import_model('sm') + legs = base_objects.LegList( + [base_objects.Leg({'id': pdg, 'state': i > 1}) + for i, pdg in enumerate(ids)]) + amplitude = diagram_generation.Amplitude( + base_objects.Process({'legs': legs, 'model': model})) + matrix_element = helas_objects.HelasMatrixElement(amplitude) + return cls( + matrix_element, helas_call_writer.CPPUFOHelasCallWriter(model)) + finally: + color_amp.set_ddm_basis(False) + + def test_ddm_color_flow_basis_is_the_trace_one(self): + """The color sum shrinks to (n-2)! structures, the color flows stay + the (n-1)! trace ones.""" + + for npar, ncolor, nflow in [(4, 2, 6), (5, 6, 24)]: + exporter = self.get_exporter([21] * npar, ddm=True) + self.assertEqual(len(exporter.color_basis), ncolor) + self.assertEqual(len(exporter.color_flow_basis), nflow) + + def test_ddm_active_colors_index_the_color_flows(self): + """active_colors ends up in the icolamp mask, which the color + selection walks over ncolor_flow entries: it must be the same mask + the trace basis writes, not one indexed on the smaller DDM basis.""" + + for npar in (4, 5): + trace = self.get_exporter([21] * npar, ddm=False) + ddm = self.get_exporter([21] * npar, ddm=True) + self.assertEqual(ddm.active_color_map, trace.active_color_map) + # and it is a mask over the color flows, not over the color sum + nflow = len(ddm.color_flow_basis) + for active_colors in ddm.active_color_map: + self.assertTrue(active_colors) + self.assertLess(max(active_colors), nflow) + + def test_ddm_coloramps_is_written_and_mode_independent(self): + """coloramps.h bakes the canonical color flow code of each flow, which + has to be decomposed on the flow basis: asking the DDM basis itself + raises (its elements are products of f's and have no single flow + each). That left a 0 byte coloramps.h and no CPPProcess.cc at all for + every all-gluon process, silently and with a zero exit code.""" + + import madmatrix.model_handling as model_handling + + written = {} + for npar in (4, 5): + for ddm in (False, True): + exporter = self.get_exporter( + [21] * npar, ddm=ddm, + cls=model_handling.OneProcessExporterMadMatrix) + exporter.path = tempfile.mkdtemp() + try: + exporter.edit_coloramps() + with open(pjoin(exporter.path, 'coloramps.h')) as stream: + written[(npar, ddm)] = stream.read() + finally: + shutil.rmtree(exporter.path) + self.assertTrue(written[(npar, ddm)]) + self.assertIn('colorflowcode_valid = true', + written[(npar, ddm)]) + # switching the color basis changes how the jamps are computed, + # never which color flows exist + self.assertEqual(written[(npar, True)], written[(npar, False)]) diff --git a/tests/unit_tests/iolibs/test_export_v4.py b/tests/unit_tests/iolibs/test_export_v4.py index f2d500e560..1bef842629 100644 --- a/tests/unit_tests/iolibs/test_export_v4.py +++ b/tests/unit_tests/iolibs/test_export_v4.py @@ -16,9 +16,11 @@ """Unit test library for the export v4 format routines""" from __future__ import absolute_import +import collections import copy import fractions -import os +import os +import random import sys root_path = os.path.split(os.path.dirname(os.path.realpath( __file__ )))[0] sys.path.append(os.path.join(root_path, os.path.pardir, os.path.pardir)) @@ -169,8 +171,92 @@ def test_flavor_mask_placeholders_present_in_nonstandalone_templates(self): content = open(pjoin(template_dir, template_name)).read() self.assertIn('%(flavor_mask_decl)s', content) self.assertIn('%(flavor_mask_setup)s', content) - - @IOTests.createIOTest() + + def test_splitorders_entry_points_agree_with_their_callers(self): + """The two split-orders templates (generic and matchbox) are compiled + against the same drivers: check_sa_splitOrders.f, written next to every + split-orders matrix element, and the MadLoop template, whose + loop_matrix.f calls into the born_matrix.f built from either one. F77 + has no interface checking, so a signature that drifts from its caller + links happily and writes the result over the caller's third argument. + Pin the argument lists of the entry points those drivers call. + """ + template_dir = pjoin(MG5DIR, 'madgraph', 'iolibs', 'template_files') + + def read(*parts): + return open(pjoin(template_dir, *parts)).read() + + # The callee side: both templates take the flavor index in the same + # slot, so one driver can call either. + for template_name in ['matrix_standalone_splitOrders_v4.inc', + 'matrix_standalone_matchbox_splitOrders_v4.inc']: + content = read(template_name) + self.assertIn( + 'SUBROUTINE %(proc_prefix)sSMATRIX_SPLITORDERS(P, FLAV_IDX, ANS)', + content) + self.assertIn( + 'SUBROUTINE %(proc_prefix)sSMATRIXHEL_SPLITORDERS(P,HEL, FLAV_IDX, ANS)', + content) + + # The caller side. Both drivers pass an all-ones INTEGER array where + # FLAV_IDX is expected, which resolves to its first element: the + # canonical flavor. That is deliberate -- neither driver knows about + # merged flavors -- but the argument count has to line up. + self.assertIn( + 'CALL %(proc_prefix)sSMATRIX_SPLITORDERS(P,FLAVOR,MATELEMS)', + read('check_sa_splitOrders.f')) + self.assertIn( + 'CALL %(proc_prefix)sSMATRIXHEL_SPLITORDERS(P_USER,USERHEL,IC,BORNBUFF(0))', + read('loop_optimized', 'loop_matrix_standalone.inc')) + + def test_matchbox_drivers_use_the_matrix_element_prefix(self): + """check_sa.f calls the matrix element by name, and matchbox renames + every routine after the process id -- ignoring the --prefix a caller + may have passed. The driver has to be given that same name or it does + not link, which nothing notices because the matchbox `make` is a no-op. + """ + sa = export_v4.ProcessExporterFortranSA() + matchbox = export_v4.ProcessExporterFortranMatchBox() + proc_id = self.mymatrixelement.get('processes')[0].get('id') + + self.assertEqual('', sa.get_proc_prefix(self.mymatrixelement)) + self.assertEqual('M1_', sa.get_proc_prefix(self.mymatrixelement, 'M1_')) + # what write_matrix_element_v4 puts on the routines, whatever it is + # handed + self.assertEqual('MG5_%i_' % proc_id, + matchbox.get_proc_prefix(self.mymatrixelement)) + self.assertEqual('MG5_%i_' % proc_id, + matchbox.get_proc_prefix(self.mymatrixelement, 'M1_')) + + def test_matrix_template_provides_reports_the_missing_entry_points(self): + """The blocks check_sa.f writes -- the density driver, the crossing + demonstration -- call routines that only the default template has, and + the color sum it links against only reads a folded color matrix in that + same template. Each is emitted behind this predicate, so pin what it + answers for the two templates that differ. + """ + sa = export_v4.ProcessExporterFortranSA() + matchbox = export_v4.ProcessExporterFortranMatchBox() + + self.assertEqual('matrix_standalone_v4.inc', + sa.get_matrix_template(self.mymatrixelement)) + self.assertEqual('matrix_standalone_matchbox.inc', + matchbox.get_matrix_template(self.mymatrixelement)) + + for marker in ('GET_DENSITY', '%(flavor_pdg_function)s', + '%(color_fold_gather)s'): + self.assertTrue( + sa.matrix_template_provides(self.mymatrixelement, marker), + '%s missing from the default standalone template' % marker) + self.assertFalse( + matchbox.matrix_template_provides(self.mymatrixelement, marker), + '%s unexpectedly in the matchbox template' % marker) + + # ... and the color sum is folded only where it can be read back + self.assertIsNone(matchbox.get_jamp_folding(self.mymatrixelement)) + + + @IOTests.createIOTest() def testIO_export_matrix_element_v4_standalone(self): """target: matrix.f """ @@ -3639,7 +3725,11 @@ def test_generate_helas_diagrams_uux_uuxuux(self): """) - # Test leshouche.inc output + # Test leshouche.inc output. + # The madevent exporter drops the ICOLUP colour-flow table from + # leshouche.inc when the matrix element carries a canonical colour code + # (drop_icolup): addmothers.f now rebuilds the Les Houches colour tags + # from colorflow.inc instead. leshouche.inc keeps only IDUP and MOTHUP. writer = writers.FortranWriter(self.give_pos('leshouche')) exporter.write_leshouche_file(writer, matrix_element) writer.close() @@ -3648,18 +3738,27 @@ def test_generate_helas_diagrams_uux_uuxuux(self): """ DATA (IDUP(I,1,1),I=1,6)/2,-2,2,-2,2,-2/ DATA (MOTHUP(1,I),I=1, 6)/ 0, 0, 1, 1, 1, 1/ DATA (MOTHUP(2,I),I=1, 6)/ 0, 0, 2, 2, 2, 2/ - DATA (ICOLUP(1,I,1,1),I=1, 6)/501, 0,502, 0,503, 0/ - DATA (ICOLUP(2,I,1,1),I=1, 6)/ 0,501, 0,502, 0,503/ - DATA (ICOLUP(1,I,2,1),I=1, 6)/501, 0,502, 0,503, 0/ - DATA (ICOLUP(2,I,2,1),I=1, 6)/ 0,501, 0,503, 0,502/ - DATA (ICOLUP(1,I,3,1),I=1, 6)/502, 0,502, 0,503, 0/ - DATA (ICOLUP(2,I,3,1),I=1, 6)/ 0,501, 0,501, 0,503/ - DATA (ICOLUP(1,I,4,1),I=1, 6)/503, 0,502, 0,503, 0/ - DATA (ICOLUP(2,I,4,1),I=1, 6)/ 0,501, 0,501, 0,502/ - DATA (ICOLUP(1,I,5,1),I=1, 6)/502, 0,502, 0,503, 0/ - DATA (ICOLUP(2,I,5,1),I=1, 6)/ 0,501, 0,503, 0,501/ - DATA (ICOLUP(1,I,6,1),I=1, 6)/503, 0,502, 0,503, 0/ - DATA (ICOLUP(2,I,6,1),I=1, 6)/ 0,501, 0,502, 0,501/ +""") + + # Test colorflow.inc output: the six colour flows that used to be the + # ICOLUP rows above are now encoded by the canonical colour code, which + # addmothers.f decodes back into the very same tags. The old rows, for + # reference (colour anti-colour per external leg, one flow per line): + # 501 0 502 0 503 0 / 0 501 0 502 0 503 + # 501 0 502 0 503 0 / 0 501 0 503 0 502 + # 502 0 502 0 503 0 / 0 501 0 501 0 503 + # 503 0 502 0 503 0 / 0 501 0 501 0 502 + # 502 0 502 0 503 0 / 0 501 0 503 0 501 + # 503 0 502 0 503 0 / 0 501 0 502 0 501 + writer = writers.FortranWriter(self.give_pos('colorflow')) + exporter.write_colorflow_file(writer, matrix_element) + writer.close() + + self.assertFileContains('colorflow', + """ DATA NCOLSLOT(1)/3/ + DATA (ICOLCSL(I,1),I=1,3)/2,3,5/ + DATA (ICOLASL(I,1),I=1,3)/1,4,6/ + DATA (ICOLCODE(I,1),I=1,6)/21,15,19,7,11,5/ """) # Test pdf output (for auto_dsig.f) @@ -3796,6 +3895,10 @@ def test_generate_helas_diagrams_gg_gg(self): denom = 6 i = 0 + # the numbers above are the color matrix over every color flow; the + # folded form sums each reversal pair into one line and is checked + # against |M|^2 itself elsewhere + exporter.jamp_fold = False for data in exporter.get_color_data_lines(\ matrix_element): @@ -10086,8 +10189,12 @@ def test_header(self): F1%P(:) = +F2%P(:)+V3%P(:) P1(:) = -F1 % P (:) F1 % FLV_INDEX = F2 % FLV_INDEX - DENOM = COUP/(P1(0)**2-P1(1)**2-P1(2)**2-P1(3)**2 - M1 * (M1 -CI - $ * W1))""" + IF (DBLE(P1(0)**2-P1(1)**2-P1(2)**2-P1(3)**2).GT.0D0) THEN + DENOM = COUP/(P1(0)**2-P1(1)**2-P1(2)**2-P1(3)**2 - M1 * (M1 + $ -CI* W1)) + ELSE + DENOM = COUP/(P1(0)**2-P1(1)**2-P1(2)**2-P1(3)**2 - M1**2) + ENDIF""" abstract_M = create_aloha.AbstractRoutineBuilder(FFV1).compute_routine(1) abstract_M.add_symmetry(2) @@ -10095,8 +10202,12 @@ def test_header(self): self.assertTrue(os.path.exists('/tmp/FFV1_1.f')) textfile = open('/tmp/FFV1_1.f','r').read() - split_sol = solution.split('\n') - self.assertEqual(split_sol, textfile.split('\n')[:len(split_sol)]) + # rstrip each line: the ALOHA line wrapper can leave a trailing space + # (e.g. after "(M1 " when the width term spills to a continuation), and + # that cosmetic whitespace is not what this test is checking. + split_sol = [l.rstrip() for l in solution.split('\n')] + split_cur = [l.rstrip() for l in textfile.split('\n')[:len(split_sol)]] + self.assertEqual(split_sol, split_cur) class UFO_model_to_mg4_Test(unittest.TestCase): @@ -10360,3 +10471,379 @@ def test_madevent_template_uses_decay_aware_broken_symmetry_metadata(self): [me.get('diagrams')[323], me.get('diagrams')[954], me.get('diagrams')[1123], me.get('diagrams')[1139]]) + + +class OptimiseJampTest(unittest.TestCase): + """Test the common sub-expression elimination applied to the JAMP + definitions.""" + + @staticmethod + def reference_optimise_jamp(all_element, nb_line=0, nb_col=0, added=0): + """Straightforward version of ProcessExporterFortran.optimise_jamp, + looking every column up in the matrix instead of indexing the non zero + entries. The splitting of wide matrices is left out, so keep the test + matrices below the 600 columns which trigger it.""" + + if not nb_line: + for i, j in all_element: + if i + 1 > nb_line: + nb_line = i + 1 + if j + 1 > nb_col: + nb_col = j + 1 + assert nb_col <= 600 + + max_count = 0 + all_index = [] + operation = collections.defaultdict( + lambda: collections.defaultdict(int)) + for (i, j1), v1 in all_element.items(): + ratios = [(j2, all_element.get((i, j2), 0) / v1) + for j2 in range(j1 + 1, nb_col) + if all_element.get((i, j2), 0)] + for j2, R in ratios: + operation[(j1, j2)][R] += 1 + if operation[(j1, j2)][R] > max_count: + max_count = operation[(j1, j2)][R] + all_index = [(j1, j2, R)] + elif operation[(j1, j2)][R] == max_count: + all_index.append((j1, j2, R)) + + if max_count <= 1: + return all_element, [] + + to_add = [] + for j1, j2, R in all_index: + first = True + for i in range(nb_line): + v1 = all_element.get((i, j1), 0) + v2 = all_element.get((i, j2), 0) + if not v1 or not v2: + continue + if v2 / v1 == R: + if first: + first = False + added += 1 + to_add.append((added, j1, j2, R, max_count)) + all_element[(i, -added)] = v1 + del all_element[(i, j1)] + del all_element[(i, j2)] + + new_element, new_def = OptimiseJampTest.reference_optimise_jamp( + all_element, nb_line, nb_col, added) + for one_def in to_add: + new_def.insert(0, one_def) + return new_element, new_def + + @staticmethod + def random_matrix(seed, nb_line, nb_col, density): + """Sparse matrix with repeating values, so that the optimisation has + something to find.""" + + # no zero value: the scan divides by the entry it starts from, so a + # stored zero in a line which has other entries makes it raise + values = [1, -1, 2, -2, 0.5, -0.5, 3, 1j, -1j] + rng = random.Random(seed) + all_element = {} + for i in range(nb_line): + for j in range(nb_col): + if rng.random() < density: + all_element[(i, j)] = complex(rng.choice(values)) + return all_element + + def test_index_jamp_matrix(self): + """The indices must list the non zero entries in increasing order, and + leave out both the zero values and the columns beyond nb_col.""" + + all_element = {(0, 2): 1, (0, 0): 3, (0, 1): 0, + (1, 0): 2, (1, 5): 7, (0, -1): 4} + lines, columns = export_v4.ProcessExporterFortran.index_jamp_matrix( + all_element, 3) + self.assertEqual(dict(lines), {0: [-1, 0, 2], 1: [0]}) + self.assertEqual(dict(columns), {-1: [0], 0: [0, 1], 2: [0]}) + + def test_common_jamp_lines(self): + """Lines where two columns are both filled, in increasing order.""" + + columns = {1: [0, 2, 3, 7], 2: [2, 3, 5], 3: [9]} + common = export_v4.ProcessExporterFortran.common_jamp_lines + self.assertEqual(common(columns, 10, 1, 2), [2, 3]) + self.assertEqual(common(columns, 3, 1, 2), [2]) + self.assertEqual(common(columns, 10, 1, 3), []) + self.assertEqual(common(columns, 10, 1, 4), []) + + def test_optimise_jamp_matches_reference(self): + """The optimisation has to give exactly the same sub-expressions as + the straightforward scan, including the order they are defined in.""" + + exporter = export_v4.ProcessExporterFortranSA() + for seed, nb_line, nb_col, density in [(1, 8, 12, 0.5), + (2, 12, 25, 0.35), + (3, 20, 40, 0.25), + (4, 5, 5, 0.9), + (5, 30, 15, 0.6)]: + all_element = self.random_matrix(seed, nb_line, nb_col, density) + reference, reference_def = self.reference_optimise_jamp( + dict(all_element)) + exporter.myjamp_count = 0 + result, result_def = exporter.optimise_jamp(dict(all_element)) + self.assertEqual(result_def, reference_def) + self.assertEqual(result, reference) + + def test_optimise_jamp_stored_zero(self): + """An entry which is present but zero must be ignored, like the scan + looking the columns up in the matrix does.""" + + # the zero has to be the last entry of its line: the scan divides by + # the entry it starts from, so a zero followed by a non zero raises + all_element = {} + for i in range(4): + all_element[(i, 0)] = complex(1) + all_element[(i, 1)] = complex(2) + all_element[(i, 2)] = complex(0) + reference, reference_def = self.reference_optimise_jamp( + dict(all_element)) + exporter = export_v4.ProcessExporterFortranSA() + exporter.myjamp_count = 0 + result, result_def = exporter.optimise_jamp(dict(all_element)) + self.assertEqual(result_def, reference_def) + self.assertEqual(result, reference) + # column 2 is never picked up as a sub-expression + self.assertTrue(result_def) + self.assertTrue(all(2 not in (j1, j2) + for _, j1, j2, _, _ in result_def)) + + def test_optimise_jamp_no_saving(self): + """A matrix where no sub-expression is reused is returned as is.""" + + exporter = export_v4.ProcessExporterFortranSA() + exporter.myjamp_count = 0 + all_element = {(0, 0): complex(1), (1, 1): complex(2)} + result, defs = exporter.optimise_jamp(dict(all_element)) + self.assertEqual(defs, []) + self.assertEqual(result, all_element) + + #=========================================================================== + # Orbit equivariant optimisation + #=========================================================================== + @staticmethod + def symmetric_matrix(seed, nb_line, nb_col, density): + """A matrix which really is invariant under a permutation of its lines + and of its columns: the entries are drawn on one half of the lines and + the permutation is used to fill the other half. Returns the matrix and + the symmetry in the form optimise_jamp expects. + + The line permutation exchanges the two halves, and the column one + reverses the columns, with a sign on one pair of columns in three. The + sign only depends on the pair, so that applying the permutation twice + really gives the identity.""" + + rng = random.Random(seed) + values = [1, -1, 2, -2] + half = nb_line // 2 + + rowperm = [0] * (nb_line + 1) + for i in range(1, half + 1): + rowperm[i] = i + half + rowperm[i + half] = i + action = {} + for j in range(1, nb_col + 1): + image = nb_col + 1 - j + action[j] = (image, -1 if min(j, image) % 3 == 0 else 1) + + all_element = {} + for i in range(1, half + 1): + for j in range(1, nb_col + 1): + if rng.random() < density: + all_element[(i, j)] = complex(rng.choice(values)) + # M[rowperm[i], action[j]] = sign * M[i, j] + for (i, j), value in list(all_element.items()): + image, sign = action[j] + all_element[(rowperm[i], image)] = sign * value + + # the matrix really is invariant, otherwise the test would not be + # testing what it says it is + for (i, j), value in all_element.items(): + image, sign = action[j] + assert all_element.get((rowperm[i], image), 0) == sign * value + + symmetry = {'rowperms': [rowperm], 'actions': [action], + 'nb_line': nb_line, 'line_reps': list(range(1, half + 1))} + return all_element, symmetry + + @staticmethod + def rebuild(defs, new_mat): + """The matrix the definitions and the remaining terms stand for.""" + + expanded = {} + for k, left, right, ratio, _nb in defs: + terms = dict(expanded[-left] if left < 0 else {left: 1.}) + other = expanded[-right] if right < 0 else {right: 1.} + for amp, coefficient in other.items(): + terms[amp] = terms.get(amp, 0) + ratio * coefficient + expanded[k] = terms + rebuilt = {} + for (line, column), factor in new_mat.items(): + terms = expanded[-column] if column < 0 else {column: 1.} + for amp, coefficient in terms.items(): + rebuilt[(line, amp)] = rebuilt.get((line, amp), 0) + \ + factor * coefficient + return dict((key, value) for key, value in rebuilt.items() if value) + + def test_optimise_jamp_equivariant_rebuilds_matrix(self): + """Whatever it introduces, the optimisation still stands for the very + matrix it was given.""" + + exporter = export_v4.ProcessExporterFortranSA() + for seed, nb_line, nb_col, density in [(1, 8, 12, 0.6), + (2, 12, 20, 0.5), + (3, 16, 24, 0.4)]: + all_element, symmetry = self.symmetric_matrix(seed, nb_line, + nb_col, density) + result, defs = exporter.optimise_jamp(dict(all_element), + symmetry=symmetry) + self.assertTrue(defs) + rebuilt = self.rebuild(defs, result) + self.assertEqual(sorted(rebuilt), sorted(all_element)) + for key, value in all_element.items(): + self.assertAlmostEqual(rebuilt[key], value) + + def test_optimise_jamp_greedy_tail(self): + """The plain scan run once the orbit rounds stall has to leave the + matrix standing for the same thing, and to leave the lines shorter than + the orbit rounds alone did.""" + + exporter = export_v4.ProcessExporterFortranSA() + for seed, nb_line, nb_col, density in [(1, 8, 12, 0.6), + (2, 12, 20, 0.5), + (3, 16, 24, 0.4)]: + all_element, symmetry = self.symmetric_matrix(seed, nb_line, + nb_col, density) + exporter.jamp_greedy_tail = False + orbit_only, orbit_defs = exporter.optimise_jamp( + dict(all_element), symmetry=symmetry) + exporter.jamp_greedy_tail = True + result, defs = exporter.optimise_jamp(dict(all_element), + symmetry=symmetry) + rebuilt = self.rebuild(defs, result) + self.assertEqual(sorted(rebuilt), sorted(all_element)) + for key, value in all_element.items(): + self.assertAlmostEqual(rebuilt[key], value) + self.assertTrue(len(defs) >= len(orbit_defs)) + self.assertTrue(len(result) <= len(orbit_only)) + + def test_jamp_definition_levels(self): + """A definition has to land after both of the ones it uses, and + nothing of a level may use anything else of that level.""" + + defs = [(1, 5, 7, 1., 0), (2, -1, 9, 1., 0), (3, 4, 6, 1., 0), + (4, -2, -3, 1., 0)] + levels = export_v4.ProcessExporterFortran.jamp_definition_levels(defs) + self.assertEqual(levels, [[1, 3], [2], [4]]) + + def test_optimise_jamp_equivariant_is_orbit_closed(self): + """The invariant the orbit version is there for: the image of every + definition under every permutation of the symmetry is a definition + again. The plain scan does not have this property, which is why its + result cannot be written as one recipe per orbit.""" + + exporter = export_v4.ProcessExporterFortranSA() + for seed, nb_line, nb_col, density in [(1, 8, 12, 0.6), + (2, 12, 20, 0.5), + (3, 16, 24, 0.4)]: + all_element, symmetry = self.symmetric_matrix(seed, nb_line, + nb_col, density) + # the plain scan run at the end is deliberately outside the orbit + # structure, so it is off while that structure is checked + exporter.jamp_greedy_tail = False + result, defs = exporter.optimise_jamp(dict(all_element), + symmetry=symmetry) + exporter.jamp_greedy_tail = True + known = set((left, right, ratio) + for _k, left, right, ratio, _nb in defs) + self.assertTrue(known) + image = export_v4.ProcessExporterFortran.jamp_operation_image + for action in exporter.jamp_orbits['actions']: + for operation in known: + self.assertIn(image(action, operation)[0], known) + + # and the same run through the plain scan is not closed + exporter.myjamp_count = 0 + _plain, plain_defs = exporter.optimise_jamp(dict(all_element)) + self.assertTrue(plain_defs) + + def test_jamp_orbit_recipes_replay(self): + """One recipe per orbit has to be enough: walking the orbits with the + permutations kept must hand out exactly the definitions again, which + is what the generated INIT_JAMP does.""" + + exporter = export_v4.ProcessExporterFortranSA() + for seed, nb_line, nb_col, density in [(1, 8, 12, 0.6), + (2, 12, 20, 0.5), + (3, 16, 24, 0.4)]: + all_element, symmetry = self.symmetric_matrix(seed, nb_line, + nb_col, density) + # one recipe per orbit only describes what the orbit rounds found + exporter.jamp_greedy_tail = False + _result, defs = exporter.optimise_jamp(dict(all_element), + symmetry=symmetry) + exporter.jamp_greedy_tail = True + recipes = exporter.jamp_orbit_recipes(defs, nb_col) + if recipes is None: + # a ratio which is not a plain sign, the definitions are then + # written out one by one + continue + self.assertEqual(len(recipes['recipes']), + exporter.jamp_orbits['nb_orbit']) + self.assertTrue(len(recipes['recipes']) < len(recipes['defs'])) + self.assertEqual(self.replay_recipes(recipes), + [(left, right, int(ratio.real)) + for _k, left, right, ratio, _nb + in recipes['defs']]) + + @staticmethod + def replay_recipes(recipes): + """What the generated INIT_JAMP builds out of the recipes alone.""" + + permutations = recipes['permutations'] + nb_perm = len(permutations) + left_of, right_of, ratio_of, image_of = [], [], [], [] + known = {} + + def store(left, right, ratio): + found = known.get((left, right, ratio)) + if found is not None: + return found, 1 + found = known.get((right, left, ratio)) + if found is not None: + return found, ratio + left_of.append(left) + right_of.append(right) + ratio_of.append(ratio) + image_of.extend([0] * nb_perm) + known[(left, right, ratio)] = len(left_of) + return len(left_of), 1 + + def act(place, column): + if column > 0: + signed = permutations[place][column - 1] + else: + signed = -image_of[(-column - 1) * nb_perm + place] + return (abs(signed) if column > 0 else -abs(signed), + 1 if signed > 0 else -1) + + for left, right, ratio in recipes['recipes']: + begin = len(left_of) + store(left, right, ratio) + current = begin + while current < len(left_of): + current += 1 + for place in range(nb_perm): + image_left, sign_left = act(place, left_of[current - 1]) + image_right, sign_right = act(place, right_of[current - 1]) + where, swap = store(image_left, image_right, + ratio_of[current - 1] * sign_left + * sign_right) + sign = sign_left * swap + image_of[(current - 1) * nb_perm + place] = \ + where if sign > 0 else -where + return list(zip(left_of, right_of, ratio_of)) diff --git a/tests/unit_tests/loop/test_loop_helas_objects.py b/tests/unit_tests/loop/test_loop_helas_objects.py index 261b091e97..a2b6cf5910 100755 --- a/tests/unit_tests/loop/test_loop_helas_objects.py +++ b/tests/unit_tests/loop/test_loop_helas_objects.py @@ -296,6 +296,11 @@ def check_HME_individual_diag_sanity(self,Amplitude, process,\ amp_number_apparition=[] for jamp in color_amplitudes: amp_number_apparition.extend([a[1] for a in jamp]) + # A four gluon contribution summed into another amplitude is + # dropped from the jamps on purpose, its colour factor being + # carried by the amplitude it was summed into + amp_number_apparition.extend( + myME.get_quartic_amplitude_merges().keys()) diagIndex=0 for i, diag in enumerate(diagSelection): diff --git a/tests/unit_tests/loop/test_loop_induced_output.py b/tests/unit_tests/loop/test_loop_induced_output.py new file mode 100644 index 0000000000..a6d245ed7c --- /dev/null +++ b/tests/unit_tests/loop/test_loop_induced_output.py @@ -0,0 +1,223 @@ +################################################################################ +# +# Copyright (c) 2009 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 +# +################################################################################ + +"""Which output formats a loop-induced ([noborn=...]) process may be sent to. + +A loop-induced process does not reach the exporters the way a [virt=...] one +does. master_interface borrows the MadLoop interface only long enough to +validate the model, then switches *back* to 'MadGraph' and calls +create_loop_induced -- so the process is exported by the ordinary tree-level +output machinery (madgraph_interface.do_output, then ExportV4Factory with +output_type='default', or ExportCPPFactory) even though the amplitude is a +LoopAmplitude and the matrix element a LoopHelasMatrixElement. + +Only the 'madevent' formats have a loop-induced exporter to route to. Every +other format used to hand the loop matrix element to a tree-level exporter and +die deep inside it -- 'output standalone' with an IndexError in write_check_sa, +'output matrix' with "wavefunction_rank has not been computed", 'output mg7' +with a KeyError on the first loop leg, which the mg7 exporter's edge-name map +does not contain. They now refuse the process up front and point at +[sqrvirt=...], which stays in the MadLoop interface and yields the very same +matrix element as a standalone MadLoop output. + +The last two tests are the important ones to keep green: the refusal keys off +the amplitude being a LoopAmplitude, and [virt=]/[sqrvirt=] amplitudes are +LoopAmplitudes too -- they are spared only because they never reach these two +factories. If that ever stops being true, the escape hatch the error message +recommends would be refused along with everything else. +""" + +from __future__ import absolute_import + +import os +import shutil +import sys +import tempfile + +root_path = os.path.split(os.path.dirname(os.path.realpath(__file__)))[0] +sys.path.append(os.path.join(root_path, os.path.pardir, os.path.pardir)) + +import tests.unit_tests as unittest + +import madgraph.interface.master_interface as MGCmd +import madgraph.iolibs.export_v4 as export_v4 +import madgraph.loop.loop_diagram_generation as loop_diagram_generation + +from madgraph import InvalidCmd + +pjoin = os.path.join + +# The cheapest loop-induced process there is: no tree-level diagram exists for +# g g > h in loop_sm, so [noborn=QCD] gives the quark-loop amplitude alone. +LOOP_INDUCED_PROCESS = 'g g > h [noborn=QCD]' +# The same matrix element, generated the way the error message recommends. +SQRVIRT_PROCESS = 'g g > h [sqrvirt=QCD]' +# An ordinary virtual correction, for good measure. +VIRTUAL_PROCESS = 'u u~ > d d~ [virt=QCD]' + + +#=============================================================================== +# TestLoopInducedOutput +#=============================================================================== +class TestLoopInducedOutput(unittest.TestCase): + """The output formats a loop-induced process may and may not be sent to.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix='loop_induced_output') + + def tearDown(self): + shutil.rmtree(self.tmpdir, ignore_errors=True) + + #=========================================================================== + # helpers + #=========================================================================== + def get_interface(self, process=LOOP_INDUCED_PROCESS): + """A MasterCmd with 'process' generated, ready to be output.""" + + interface = MGCmd.MasterCmd() + interface.no_notification() + interface.exec_cmd('import model loop_sm', printcmd=False, precmd=True) + interface.exec_cmd('generate %s' % process, printcmd=False, precmd=True) + return interface + + def assert_output_refused(self, format): + """'output ' must raise InvalidCmd, and say what to do instead. + + Anything that is not an InvalidCmd -- an IndexError, a KeyError, a + MadGraph5Error from inside a tree-level exporter -- means the loop + matrix element reached an exporter that cannot write it. + """ + + interface = self.get_interface() + out_dir = pjoin(self.tmpdir, format.replace(' ', '_')) + try: + interface.exec_cmd('output %s %s -f' % (format, out_dir), + printcmd=False, precmd=True) + except InvalidCmd as error: + self.assertTrue('sqrvirt' in str(error), + "'output %s' refused %s without pointing at " + "[sqrvirt=]: %s" % (format, LOOP_INDUCED_PROCESS, + error)) + return + except Exception as error: + raise AssertionError( + "'output %s' crashed on %s with %s: %s" + % (format, LOOP_INDUCED_PROCESS, type(error).__name__, error)) + raise AssertionError( + "'output %s' silently accepted %s; no exporter for that format can " + "write a LoopHelasMatrixElement" % (format, LOOP_INDUCED_PROCESS)) + + def assert_output_succeeds(self, process, format='standalone'): + """'output ' must go through, and write a MadLoop directory.""" + + interface = self.get_interface(process) + out_dir = pjoin(self.tmpdir, 'ok') + interface.exec_cmd('output %s %s -f' % (format, out_dir), + printcmd=False, precmd=True) + # A MadLoop output, not a tree-level one: this file only exists when a + # loop exporter ran. + self.assertTrue( + os.path.exists(pjoin(out_dir, 'Cards', 'MadLoopParams.dat')), + '%s did not produce a MadLoop output' % process) + + #=========================================================================== + # loop-induced processes are refused by the formats that cannot serve them + #=========================================================================== + def test_standalone_factory_refuses_loop_induced(self): + """ExportV4Factory must not hand a loop-induced process to the + tree-level standalone exporter.""" + + interface = self.get_interface() + self.assertTrue(isinstance(interface._curr_amps[0], + loop_diagram_generation.LoopAmplitude), + "%s did not produce a LoopAmplitude" % LOOP_INDUCED_PROCESS) + + interface._export_format = 'standalone' + interface._export_dir = pjoin(self.tmpdir, 'factory') + try: + exporter = export_v4.ExportV4Factory(interface, False, + group_subprocesses=False, + cmd_options={}) + except InvalidCmd: + return + raise AssertionError( + "ExportV4Factory returned %s for a loop-induced process; a " + "tree-level exporter cannot write a LoopHelasMatrixElement" + % type(exporter).__name__) + + def test_output_standalone_refuses_loop_induced(self): + """'output standalone' used to die with an IndexError in write_check_sa.""" + + self.assert_output_refused('standalone') + + def test_output_matrix_refuses_loop_induced(self): + """'output matrix' shares the standalone branch of the factory; it used + to die with "wavefunction_rank has not been computed".""" + + self.assert_output_refused('matrix') + + def test_output_mg7_refuses_loop_induced(self): + """'output mg7' -- the default format -- used to die with a KeyError on + the first loop leg. mg7/madmatrix has no MadLoop backend at all.""" + + self.assert_output_refused('mg7') + + def test_refusal_leaves_an_existing_directory_alone(self): + """The refusal comes from the exporter factory, which runs before + copy_template; an already existing output directory must survive it.""" + + out_dir = pjoin(self.tmpdir, 'existing') + os.mkdir(out_dir) + sentinel = pjoin(out_dir, 'sentinel.txt') + open(sentinel, 'w').write('do not delete me\n') + + interface = self.get_interface() + self.assertRaises(InvalidCmd, interface.exec_cmd, + 'output standalone %s -f' % out_dir) + self.assertTrue(os.path.exists(sentinel), + 'the refused output wiped the existing directory') + + #=========================================================================== + # ... but the routes that do work must keep working + #=========================================================================== + def test_output_madevent_still_accepts_loop_induced(self): + """madevent is the format loop-induced processes are *for*. + + It has the LoopInducedExporterMEGroup / ...MENoGroup exporters, so it + must go straight through the refusal above. + """ + + interface = self.get_interface() + out_dir = pjoin(self.tmpdir, 'me') + interface.exec_cmd('output madevent %s -f' % out_dir, + printcmd=False, precmd=True) + self.assertTrue( + os.path.isdir(pjoin(out_dir, 'SubProcesses', 'MadLoop5_resources')), + 'madevent did not produce a loop-induced output') + + def test_sqrvirt_standalone_output_still_works(self): + """The escape hatch the error message recommends must actually work. + + [sqrvirt=] gives a LoopAmplitude just like [noborn=] does; it is spared + only because master_interface keeps it in the MadLoop interface, which + passes output_type='madloop' and never reaches the refusing branch. + """ + + self.assert_output_succeeds(SQRVIRT_PROCESS) + + def test_virt_standalone_output_still_works(self): + """Same for an ordinary [virt=] process.""" + + self.assert_output_succeeds(VIRTUAL_PROCESS) diff --git a/tests/unit_tests/madevent/test_hel_recycle.py b/tests/unit_tests/madevent/test_hel_recycle.py new file mode 100644 index 0000000000..e36fac1aaa --- /dev/null +++ b/tests/unit_tests/madevent/test_hel_recycle.py @@ -0,0 +1,207 @@ +############################################################################## +# +# Copyright (c) 2010 The MadGraph Development team and Contributors +# +# This file is a part of the MadGraph 5 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 MadGraph license which should accompany this +# distribution. +# +# For more information, please visit: http://madgraph.phys.ucl.ac.be +# +################################################################################ +"""How the helicity-recycled color stage reads the amplitudes. + +AMP is helicity major in the recycled matrix element, so a color flow line +either indexes it in place, AMP(K,i), or reads a row that has been gathered out +of it contiguously, AMPK(i,HRL). Which one it is has to be the same decision in +the rewritten lines and in the loop the driver template opens around them, so +both come from set_gather_lines and are checked together here. + +Also the fixed-form line wrapping the recycled matrix element is emitted +through, which is what decides whether those lines are legal fortran at all.""" + +from __future__ import absolute_import +import os +import shutil +import tempfile +import unittest + +import madgraph.madevent.hel_recycle as hel_recycle + + +class TestAmpGather(unittest.TestCase): + """The size gate of the contiguous amplitude gather, and the two shapes of + generated code that hang off it.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix='hr_gather') + + def tearDown(self): + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def recycler(self, ngraphs, ncomb): + """A recycler whose template announces ngraphs amplitudes and whose + good helicity list is ncomb long -- the two the gate is a function + of.""" + + template = os.path.join(self.tmpdir, 'template_matrix1.f') + with open(template, 'w') as fsock: + fsock.write(' PARAMETER (NGRAPHS=%d) \n' % ngraphs) + obj = hel_recycle.HelicityRecycler( + [str(i + 1) for i in range(ncomb)]) + obj.set_template(template) + return obj + + def test_template_ngraphs(self): + self.assertEqual(self.recycler(510, 8).template_ngraphs(), 510) + # no template to read: the gate has nothing to decide on + obj = self.recycler(510, 8) + obj.set_template(os.path.join(self.tmpdir, 'absent.f')) + self.assertEqual(obj.template_ngraphs(), 0) + + def test_gate_is_on_the_size_of_amp(self): + """AMP is NCOMB x NGRAPHS complex*16, and only its total size decides: + either factor can be the large one.""" + + limit = hel_recycle.GATHER_MIN_BYTES // 16 + for ngraphs, ncomb in [(limit // 8, 8), (8, limit // 8)]: + obj = self.recycler(ngraphs, ncomb) + obj.set_gather_lines() + self.assertTrue(obj.amp_gather, (ngraphs, ncomb)) + obj = self.recycler(ngraphs, ncomb - 1) + obj.set_gather_lines() + self.assertFalse(obj.amp_gather, (ngraphs, ncomb - 1)) + + def test_no_template_never_gathers(self): + obj = self.recycler(0, 4096) + obj.set_gather_lines() + self.assertFalse(obj.amp_gather) + + def test_plain_loop_reads_amp_in_place(self): + """Below the gate nothing changes: the holes render the very loop the + template used to carry, and the color flows index AMP by helicity.""" + + obj = self.recycler(45, 20) + obj.set_gather_lines() + self.assertEqual(obj.template_dict['hr_gather_decl'], '') + self.assertEqual(obj.template_dict['hr_gather_open'], 'K = 1, NCOMB') + self.assertEqual(obj.template_dict['hr_gather_close'], 'K') + self.assertEqual(obj.add_indices('JAMP(1,1) = AMP(31) - AMP(1)'), + 'JAMP(1,1) = AMP( K,31) - AMP( K,1)') + + def test_gathered_loop_reads_the_lane(self): + """Above it the block loop wraps a row loop, and every amplitude read + moves to the gathered lane -- the color flows and the AMP2 lines + alike.""" + + obj = self.recycler(4096, 128) + obj.set_gather_lines() + decl = obj.template_dict['hr_gather_decl'] + self.assertIn('PARAMETER (NHRBLK=%d)' % hel_recycle.GATHER_BLOCK, decl) + self.assertIn('COMPLEX*16 AMPK(NGRAPHS,NHRBLK)', decl) + # same storage class as AMP, so that it stays thread private if the + # !$OMP PARALLEL of SMATRIX1_MULTI is ever compiled in + self.assertNotIn('SAVE', decl) + opened = obj.template_dict['hr_gather_open'] + self.assertTrue(opened.startswith('KB = 1, NCOMB, NHRBLK')) + self.assertIn('AMPK(I,HRL) = AMP(KB+HRL-1,I)', opened) + # K is no longer a loop variable but still what every rewritten line + # uses, so the row loop has to assign it + self.assertIn('K = KB + HRL - 1', opened) + # one ENDDO is the template's own, closing the row loop + self.assertEqual(obj.template_dict['hr_gather_close'], + 'HRL\n ENDDO ! KB') + self.assertEqual(obj.add_indices('JAMP(1,1) = AMP(31) - AMP(1)'), + 'JAMP(1,1) = AMPK(31,HRL) - AMPK(1,HRL)') + self.assertEqual( + obj.add_indices('AMP2(1)=AMP2(1)+AMP(1)*DCONJG(AMP(1))'), + 'AMP2(1)=AMP2(1)+AMPK(1,HRL)*DCONJG(AMPK(1,HRL))') + + def test_only_amp_is_rewritten(self): + """TMP_JAMP, JAMP and the AMPBUF of the table-emitted color flows all + contain the three letters, and none of them is the amplitude array.""" + + for obj in (self.recycler(45, 20), self.recycler(4096, 128)): + obj.set_gather_lines() + for line in ['TMP_JAMP(3) = TMP_JAMP(1) + TMP_JAMP(2)', + 'JAMP(1,1) = JAMP(2,1)', + 'AMPBUF(NGRAPHS+ITMP) = AMPBUF(TMP_JAMP_A(ITMP))']: + self.assertEqual(obj.add_indices(line), line) + + def test_a_bracketed_index_survives(self): + """With the color flow definitions emitted as operand tables, the + gather the exporter writes into matrix_orig.f reads AMP at a table + lookup rather than at a literal.""" + + obj = self.recycler(45, 20) + obj.set_gather_lines() + self.assertEqual(obj.add_indices('AMPBUF(ITMP) = AMP(IDX(ITMP))'), + 'AMPBUF(ITMP) = AMP( K,IDX(ITMP))') + obj = self.recycler(4096, 128) + obj.set_gather_lines() + self.assertEqual(obj.add_indices('AMPBUF(ITMP) = AMP(IDX(ITMP))'), + 'AMPBUF(ITMP) = AMPK(IDX(ITMP),HRL)') + + +class TestDoMultiline(unittest.TestCase): + """do_multiline breaks a statement over fixed-form continuation lines. + + A continuation line is attached to whatever statement precedes it, so a + physical line which holds nothing but blanks must never be emitted: the + continuation after it would silently extend the previous statement.""" + + # the Kleiss-Kuijf color flow JAMPs of g g > g g g on the DDM basis: long + # enough to wrap and with no space of their own to wrap at, so the only + # candidate split point is inside the statement's indentation + JAMPF = ' JAMPF(2,1)=+2D0*(-IMAG1*JAMP(3,1)-IMAG1*JAMP(4,1)' \ + '-IMAG1*JAMP(6,1))' + + def physical_lines(self, line): + return hel_recycle.do_multiline(line).split('\n') + + def assertWrapIsValid(self, line): + lines = self.physical_lines(line) + for i, physical in enumerate(lines): + self.assertLessEqual(len(physical), 132, + 'physical line %d is too long' % i) + if i: + self.assertTrue(physical.lstrip().startswith('$'), + 'continuation line %d lost its marker' % i) + for i, physical in enumerate(lines[:-1]): + self.assertTrue(physical.strip(), + 'physical line %d is blank, so the continuation ' + 'which follows it joins the previous statement' % i) + # and nothing of the statement is lost on the way + rebuilt = ''.join(p.lstrip()[1:] if i else p + for i, p in enumerate(lines)) + self.assertEqual(rebuilt.replace(' ', ''), line.replace(' ', '')) + + def test_no_blank_physical_line_without_a_space_to_wrap_at(self): + """A statement whose only space is its indentation wraps mid-token""" + + self.assertWrapIsValid(self.JAMPF) + self.assertEqual(len(self.physical_lines(self.JAMPF)), 2) + + def test_short_line_is_untouched(self): + """Nothing happens below the limit""" + + short = ' JAMPF(1,1)=+2D0*(+IMAG1*JAMP(6,1))' + self.assertEqual(hel_recycle.do_multiline(short), short) + + def test_wraps_at_a_space_when_there_is_one(self): + """The usual case still breaks at the last space that fits""" + + line = ' JAMP(2,1) = (-1.000000000000000D+00)*AMP( K,8)+' \ + '(-1.000000000000000D+00)*AMP( K,11)+(-1.0D+00)*TMP_JAMP(20)' + self.assertWrapIsValid(line) + self.assertTrue(self.physical_lines(line)[0].endswith(' ')) + + def test_every_wrap_width_around_the_limit_is_valid(self): + """Sweep the statement length across the wrap width""" + + for pad in range(40): + line = ' JAMPF(2,1)=+2D0*(' + 'X' * pad + ')' + self.assertWrapIsValid(line) diff --git a/tests/unit_tests/various/test_reweight_interface.py b/tests/unit_tests/various/test_reweight_interface.py new file mode 100644 index 0000000000..0035b15684 --- /dev/null +++ b/tests/unit_tests/various/test_reweight_interface.py @@ -0,0 +1,115 @@ +################################################################################ +# +# Copyright (c) 2009 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 +# +################################################################################ +"""Unit tests for the reweight interface helpers.""" +from __future__ import absolute_import + +import unittest + +import madgraph.interface.reweight_interface as rwgt_interface + + +class FakeEvent(object): + """Only what _pdg_for_me_call touches: the concrete per-leg PDGs.""" + + def __init__(self, pdgs): + self.pdgs = list(pdgs) + + def get_pdg(self, momenta): + return list(self.pdgs) + + +class FakeModel(dict): + """Stand-in for a model carrying a merged_particles map.""" + + def __init__(self, merged): + dict.__init__(self) + self['merged_particles'] = merged + + +class TestPdgForMeCall(unittest.TestCase): + """The merged-particle labels carried by the generated process must be + converted back to the concrete PDGs of the event before they are handed to + the fortran, for BOTH signs of the merged code. + + merged_particles is keyed by the positive code only, so the membership test + has to be done on abs(). A subprocess whose grouped legs are all + anti-particles -- g q~ > w+ q~, get_pdg_order [21,-81,24,-81] -- used to + keep its -81 labels, which the fortran flavor mapping resolves to "no + flavour": SMATRIXHEL returned an exact 0 (raising "Invalid matrix element") + and GET_DENSITY returned an all-zero density matrix. + """ + + # {merged code: members}, as apply_flavor_grouping builds it: POSITIVE keys + MERGED = {81: [1, 2, 3, 4], 82: [11, 13]} + + def setUp(self): + self.obj = rwgt_interface.ReweightInterface.__new__( + rwgt_interface.ReweightInterface) + self.obj.merged_particles = None + # __del__ calls do_quit; keep it a no-op on this bare instance + self.obj.exitted = True + self.model = FakeModel(self.MERGED) + + def call(self, orig_order, event_pdgs, model=None): + event = FakeEvent(event_pdgs) + model = self.model if model is None else model + return self.obj._pdg_for_me_call(event, orig_order, None, model) + + def test_negative_merged_labels_are_resolved(self): + """g q~ > w+ q~ -- the regression: every grouped leg is an antiparticle + so both merged codes are NEGATIVE.""" + # process legs [21,-81,24,-81], event is g d~ > w+ u~ + out = self.call(((21, -81), (24, -81)), [21, -1, 24, -2]) + self.assertEqual(out, [21, -1, 24, -2]) + + def test_positive_merged_labels_are_resolved(self): + """g q > w+ q -- positive merged codes, the case that always worked.""" + out = self.call(((21, 81), (24, 81)), [21, 2, 24, 1]) + self.assertEqual(out, [21, 2, 24, 1]) + + def test_mixed_sign_merged_labels_are_resolved(self): + """q q~ > w+ g -- one leg of each sign.""" + out = self.call(((81, -81), (24, 21)), [2, -1, 24, 21]) + self.assertEqual(out, [2, -1, 24, 21]) + + def test_negative_lepton_merged_label_is_resolved(self): + """The same for the charged-lepton group (82), to pin that the fix is + not specific to the jet code.""" + out = self.call(((-82, 82), (24, -24)), [-11, 13, 24, -24]) + self.assertEqual(out, [-11, 13, 24, -24]) + + def test_no_merged_leg_keeps_the_process_order(self): + """A process with no grouped leg must keep orig_order untouched, so the + legs stay in the order the matrix element expects.""" + out = self.call(((21, 21), (24, -24)), [21, 21, -24, 24]) + self.assertEqual(out, [21, 21, 24, -24]) + + def test_no_flavor_grouping_keeps_the_process_order(self): + """Without flavor grouping merged_particles is empty and the event PDGs + must not be substituted (the pre-flavor-grouping behavior).""" + out = self.call(((21, -1), (24, -2)), [21, -1, 24, -2], + model=FakeModel({})) + self.assertEqual(out, [21, -1, 24, -2]) + + def test_falls_back_to_self_merged_particles(self): + """When there is no model to consult (the load_from_pickle path) the + map saved on the instance is used -- with the same sign handling.""" + self.obj.merged_particles = self.MERGED + out = self.call(((21, -81), (24, -81)), [21, -1, 24, -2], model=None) + self.assertEqual(out, [21, -1, 24, -2]) + + +if __name__ == '__main__': + unittest.main()