diff --git a/.github/workflows/acceptancetest.yml b/.github/workflows/acceptancetest.yml index bdc6a0395d..59d2d97f4a 100644 --- a/.github/workflows/acceptancetest.yml +++ b/.github/workflows/acceptancetest.yml @@ -976,3 +976,61 @@ jobs: run: | cd $GITHUB_WORKSPACE ./tests/test_manager.py test_madspin_loop_induced -pA -t0 -l INFO + + + acceptancetest_crossing_bundle: + # Crossing symmetry (tests/acceptance_tests/test_standalone_cross_symmetry.py) + # and the madmatrix OpenMP build, run one after the other. Each keeps its own + # step, run even when an earlier one failed. + # + # NOTE: test_manager.py exits 1 when a test is SKIPPED, and the crossing + # tests self-skip without gfortran / f2py / g++: restore-pip-cache (meson, + # ninja, f2py_compiler) is what keeps these steps green. + 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 + + # ---- crossing, static ---- + # Generated code / python objects only, no compilation: the crossing + # partition, the multi-channel config map (a mis-paired map leaves every + # ME and the cross section correct and only degrades the sampling, so no + # xsec comparison catches it), the colour-flow code, and the outputs + # that cannot decode a crossing. + - name: "crossing: partition / config map / colour-flow code / unsupported outputs" + if: ${{ !cancelled() }} + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py -pA TestCrossingPartition TestCrossingConfigMap TestColorFlowCode TestCrossingUnsupportedOutput -t0 -l INFO + + # ---- crossing, fortran standalone ---- + # A crossed SMATRIX call must reproduce the process it crosses into, the + # density matrix must follow the crossing, and the C-parity helicity + # de-duplication must hold where it engages and refuse itself elsewhere. + - name: "crossing: fortran standalone + C-parity good-helicity dedup" + if: ${{ !cancelled() }} + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py -pA TestStandaloneCrossSymmetry TestGoodHelCParityDedup -t0 -l INFO + + # ---- crossing, madmatrix ---- + # `output standalone` (incl. the per-event mixed-crossing SIMD page) and + # the `check crossing` subcommand over both exporters. + - name: "crossing: madmatrix backend and check crossing" + if: ${{ !cancelled() }} + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py -pA TestStandaloneMg7CrossSymmetry TestCheckCrossingCommand -t0 -l INFO + + # ---- madmatrix OpenMP ---- + # The CPU sigmaKin loop is an 'omp parallel for default( none )': every + # variable it uses must sit in its shared() clause or CPPProcess.cc does + # not compile. OpenMP is opt-in (USEOPENMP=1) and off on Darwin, so + # nothing else ever builds it. + - name: "test_standalone_mg7_openmp" + if: ${{ !cancelled() }} + 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 0195fe0f33..11496a1076 100644 --- a/.github/workflows/acceptancetest_madevent.yml +++ b/.github/workflows/acceptancetest_madevent.yml @@ -569,3 +569,64 @@ jobs: cd $GITHUB_WORKSPACE ./tests/test_manager.py test_madevent_merged_flavor_uq test_madevent_flavor_zjj -pA -t0 -l INFO + + acceptancetest_madevent_crossing_bundle: + # Crossing symmetry end to end in madevent, plus the density reweighting + # that splits over cores. Run one after the other; each keeps its own step, + # run even when an earlier one failed. + # + # restore_heptools: the default LO PDF is an LHAPDF set (NNPDF4.0), so + # every madevent launch needs lhapdf-config. + runs-on: ubuntu-24.04 + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork == true + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/checkout_mg5 + - uses: ./.github/actions/restore-pip-cache + - uses: ./.github/actions/restore_heptools + + # ---- crossed event labels ---- + # The W+ helicity of p p > w+ j (a crossed massive vector) and the colour + # flow of u u~ > u u~ (98/2 asymmetric, so a swapped label shows). + - name: "crossing: crossed helicity and colour-flow labels written to the LHE" + if: ${{ !cancelled() }} + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py -pA TestMadeventCrossingHelicity TestMadeventColorFlowRatio -t0 -l INFO + + # ---- within-group router colour selection (~4 min) ---- + # A router must RESELECT the colour flow with its own mask, not relabel + # its base's pick. Only an event-level comparison sees it: the cross + # section agreed to 0.02% while ~10% of a flavour class carried a flow + # the uncrossed build never picks. + - name: "crossing: within-group router colour selection" + if: ${{ !cancelled() }} + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py -pA TestMadeventRouterColorSelection -t0 -l INFO + + # ---- crossing cross sections ---- + # Crossing-routed vs --use_crossing=False, same seed: p p > w+ j, w+ > j j + # (decay chain on a crossed production) and p p > t t~ j j (the only + # integration of the cross-GROUP router). + - name: "crossing: cross-section regressions (decay chain + inclusive)" + if: ${{ !cancelled() }} + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py -pA TestMadeventDecayChainCrossing TestMadeventInclusiveCrossingXsec -t0 -l INFO + + # ---- folded layout + reweight labels ---- + - name: "crossing: folded subprocesses + merged anti-particle labels in reweight" + if: ${{ !cancelled() }} + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py test_standalone_crossing_folds_qqx_subprocess test_reweight_merged_antiparticle_labels -pA -t0 -l INFO + + # ---- density, multicore ---- + # The event file is split across jobs; each writes the average density + # matrix of its chunk and the mother interface must recombine them. + - name: "test_density_mode_multicore" + if: ${{ !cancelled() }} + 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 8ccd3627cf..59fb153ab1 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -445,7 +445,8 @@ jobs: unittest_new_coverage: - # New unit tests added in 3.7.1: q_polynomial and hepmc_parser + # New unit tests added in 3.7.1: q_polynomial and hepmc_parser, and the + # reweight density / merged-label ones added with crossing symmetry runs-on: ubuntu-24.04 if: github.event_name == 'push' || github.event.pull_request.head.repo.fork == true permissions: @@ -453,12 +454,23 @@ jobs: steps: - uses: actions/checkout@v5 + - uses: ./.github/actions/restore-pip-cache - name: test q_polynomial and hepmc_parser modules run: | cd $GITHUB_WORKSPACE ./tests/test_manager.py test_q_polynomial test_hepmc_parser -t0 + # TestAverageDensityMatrix: the average-density-matrix helpers shared by + # DensityInterface and do_reweight's multicore recombination. + # TestPdgForMeCall: merged-particle labels, both signs, resolved to the + # event's concrete PDGs before the fortran call. Pure python. + - name: reweight density / merged-label unit tests + if: ${{ !cancelled() }} + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py TestAverageDensityMatrix TestPdgForMeCall -t0 + unittest_write_model: # runs alone: excluded from the shared unittest jobs due to side effects diff --git a/MadSpin/decay.py b/MadSpin/decay.py index d85417b2c7..e0f7af4dda 100755 --- a/MadSpin/decay.py +++ b/MadSpin/decay.py @@ -75,6 +75,29 @@ MAX_COMPAT_FLAVS = 500 +_USE_CROSSING_FLAG = re.compile(r'\s--use_crossing(=\S*)?(?=\s|$)') + +def without_crossing(commandline): + """Pin --use_crossing=False on every generate / add process of + `commandline` (';'-separated commands, as MadSpin hands them to exec_cmd). + + MadSpin reaches each matrix element per event flavor, through the entry + points taking a FLAVOR array (GET_DENSITY, SMATRIX), and finds it by the + processes the matrix element lists: neither can see a crossing folded onto + a base. Its production lines are copied from the banner's proc card, so a + --use_crossing=True there came back here and folded those subprocesses + away. Any flag already on the line is dropped first; [...] (perturbative) + lines are left alone, crossing is never applied to them. + """ + out = [] + for cmd in commandline.split(';'): + head = cmd.strip() + if head.startswith(('generate', 'add process')) and '[' not in head: + cmd = _USE_CROSSING_FLAG.sub('', cmd).rstrip() + \ + ' --use_crossing=False' + out.append(cmd) + return ';'.join(out) + class MadSpinError(MadGraph5Error): pass @@ -3292,7 +3315,7 @@ def generate_all_matrix_element(self): else: commandline += 'add process %s; ' % proc - commandline = commandline.replace('add process', 'generate',1) + commandline = without_crossing(commandline.replace('add process', 'generate',1)) logger.info(commandline) mgcmd.exec_cmd(commandline, precmd=True) @@ -3365,7 +3388,7 @@ def generate_all_matrix_element(self): if not proc.strip().startswith(('add','generate')): proc = 'add process %s' % proc commandline += self.get_proc_with_decay(proc, decay_text, mgcmd._curr_model, self.options) - commandline = commandline.replace('add process', 'generate',1) + commandline = without_crossing(commandline.replace('add process', 'generate',1)) else: for key in decay_text_correlated: for proc in processes: @@ -3376,7 +3399,7 @@ def generate_all_matrix_element(self): else: one_decay = ', '.join(decay_text_correlated[key]) commandline += self.get_proc_with_decay(proc, one_decay, mgcmd._curr_model, self.options) - commandline = commandline.replace('add process', 'generate',1) + commandline = without_crossing(commandline.replace('add process', 'generate',1)) logger.info(commandline) mgcmd.exec_cmd(commandline, precmd=True) # remove decay with 0 branching ratio. @@ -3443,7 +3466,7 @@ def generate_all_matrix_element(self): proc = proc.split("@",1)[0] commandline+="add process %s @%i --no_warning=duplicate;" % (proc,i) i+=1 - commandline = commandline.replace('add process', 'generate',1) + commandline = without_crossing(commandline.replace('add process', 'generate',1)) mgcmd.exec_cmd(commandline, precmd=True) # remove decay with 0 branching ratio. mgcmd.remove_pointless_decay(self.banner.param_card) @@ -5415,7 +5438,7 @@ def fill_all_me(self, prod_or_decay): # legacy options 'onshell_v1' and 'madspin_v1' store both the production and the decay in a single folder if self.options['spinmode'] in ['onshell_v1', 'madspin_v1']: commandline += self.get_decay_command() - commandline = commandline.replace('add process', 'generate',1) + commandline = without_crossing(commandline.replace('add process', 'generate',1)) mgcmd.exec_cmd(commandline, precmd=True) commandline = 'output standalone_fortran %s --prefix=int' % pjoin(path_me, ms_me_subdir) @@ -5423,7 +5446,7 @@ def fill_all_me(self, prod_or_decay): mgcmd.exec_cmd(commandline, precmd=True) fill_all_me(self, "production") else: - commandline_production = commandline.replace('add process', 'generate',1) + commandline_production = without_crossing(commandline.replace('add process', 'generate',1)) commandline_production += 'output standalone_fortran %s --prefix=int --density=1' % pjoin(path_me, ms_me_subdir) logger.info(commandline_production) @@ -5434,7 +5457,7 @@ def fill_all_me(self, prod_or_decay): commandline_decay = self.get_decay_command() commandline_decay += 'output standalone_fortran %s --prefix=int --density=1 -f' % pjoin(path_me, ms_me_decay_subdir) #we add -f, else it would ask us if we want to clean the folder madspin_decay and madspin_me - commandline_decay = commandline_decay.replace('add process', 'generate',1) + commandline_decay = without_crossing(commandline_decay.replace('add process', 'generate',1)) logger.info(commandline_decay) mgcmd.exec_cmd(commandline_decay, precmd=True) diff --git a/Template/LO/Source/.make_opts b/Template/LO/Source/.make_opts index 28ac9bb19d..f6bc1bb8cb 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/SubProcesses/makefile b/Template/LO/SubProcesses/makefile index 5720f300ac..a32e054837 100644 --- a/Template/LO/SubProcesses/makefile +++ b/Template/LO/SubProcesses/makefile @@ -43,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 \ @@ -82,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 @@ -107,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 68966f31bf..e74757c89a 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 c374728ea2..21a534657f 100755 --- a/aloha/aloha_writers.py +++ b/aloha/aloha_writers.py @@ -1032,8 +1032,34 @@ 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. + # A dual momentum (onium p-wave) carries derivatives + # next to its value, and DBLE keeps it dual: take + # the branch on the value alone. + if aloha.dual_mode: + p2sign = '-'.join('P%s(%d)%%comp(0)**2' + % (self.outgoing, mu) + for mu in range(4)) + else: + p2sign = p2 + out.write(' if (dble(%(p2)s).gt.0d0) then\n' + % {'p2': p2sign}) + 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: @@ -2336,8 +2362,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') @@ -2869,8 +2906,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 4a9b5e18cc..0fa57c5f62 100644 --- a/aloha/template_files/Makefile_F +++ b/aloha/template_files/Makefile_F @@ -43,5 +43,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/madgraph/core/base_objects.py b/madgraph/core/base_objects.py index 083d61bd6a..919fc1c4b2 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]]: diff --git a/madgraph/core/diagram_generation.py b/madgraph/core/diagram_generation.py index 9f663b10db..eac5b5a6c8 100755 --- a/madgraph/core/diagram_generation.py +++ b/madgraph/core/diagram_generation.py @@ -883,6 +883,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""" @@ -909,6 +916,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): @@ -926,7 +936,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""" @@ -2119,24 +2130,59 @@ 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, + or carries a polarized leg or a bound state. + + 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 \ + any(l.get('polarization') or l.get('onium') + for l in decay.get('legs')) 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 breaks + # crossing (see _decays_break_crossing) -- 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, @@ -2414,7 +2460,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, @@ -2659,11 +2706,29 @@ 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'): + # Recording a crossing hands the process to the crossing + # machinery of its base, and the exporter does not write + # that machinery for a polarized leg or a bound state (see + # export_v4.breaks_crossing_symmetry): recorded there, the + # crossed process would be lost. Reuse the diagrams instead + # and keep it as a matrix element of its own. + this_merge = merge_crossing + if this_merge == 'record' and \ + any(l.get('polarization') or l.get('onium') + for l in process.get('legs')): + this_merge = False try: crossed_index = success_procs.index(sorted_legs) # The relabeling of legs for loop amplitudes is cumbersome @@ -2676,7 +2741,7 @@ def get_flavor(id, fsleg): # No crossing found, just continue pass else: - if not merge_crossing: + if not this_merge: # Found crossing - reuse amplitude amplitude = MultiProcess.cross_amplitude(\ amplitudes[crossed_index], @@ -2689,6 +2754,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 this_merge == '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 9f3281c4b1..426bd4a3ec 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] @@ -925,6 +935,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() @@ -4092,6 +4108,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 @@ -4102,6 +4123,10 @@ 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 @@ -4112,6 +4137,8 @@ def default_setup(self): 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 # Cache for get_amplitude_slots(), the recycled AMP array self.amplitude_slots = None @@ -4140,6 +4167,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): @@ -4147,7 +4177,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): @@ -4172,6 +4203,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() @@ -5498,6 +5533,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""" @@ -5727,6 +5835,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. @@ -5751,6 +5900,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() @@ -5798,6 +5951,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 = {} @@ -5836,8 +5994,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 @@ -6032,6 +6200,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() @@ -6085,6 +6257,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 @@ -6096,6 +6315,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'): @@ -6104,6 +6331,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 @@ -6129,34 +6377,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)) @@ -6164,7 +6410,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) @@ -6173,12 +6419,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): diff --git a/madgraph/interface/amcatnlo_interface.py b/madgraph/interface/amcatnlo_interface.py index af62d048d0..05e658e932 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): @@ -1057,10 +1063,19 @@ 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') and not extended_cmd.is_question_answer(l)] 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 12f087882c..fdec6b606b 100755 --- a/madgraph/interface/common_run_interface.py +++ b/madgraph/interface/common_run_interface.py @@ -227,6 +227,14 @@ def check_set(self, args): # handled (and reported) by do_set: never an error return + 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' % \ @@ -2380,6 +2388,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 @@ -4738,6 +4754,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) @@ -7258,7 +7275,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 f0319604da..e287a1e487 100755 --- a/madgraph/interface/madgraph_interface.py +++ b/madgraph/interface/madgraph_interface.py @@ -627,6 +627,7 @@ def help_output(self): logger.info(" --hel_recycling=False: [madevent] forbids helicity recycling optimization") logger.info(" --mask=False: [madevent|standalone_fortran] disable flavor-mask optimization for grouped/merged flavors (default:True).") logger.info(" --prefix=int|proc: [standalone_fortran] prefix matrix-element routine names (int: M_, proc: process name); generates f2py python-linkable routines.") + logger.info(" --use_crossing=True: [standalone_fortran|standalone] write this output WITH the crossing machinery (off by default: madspace does not support crossing yet). Left off, the crossed subprocesses folded onto their base at generation are written back as their own directories.") logger.info(" Examples:",'$MG:color:GREEN') logger.info(" output",'$MG:color:GREEN') logger.info(" output standalone_fortran MYRUN -f",'$MG:color:GREEN') @@ -666,6 +667,20 @@ 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") + logger.info(" standalone_fortran): standalone_fortran (fortran/f2py) or") + logger.info(" standalone (madmatrix). Requires gfortran+f2py") + logger.info(" (standalone_fortran) or a C++ compiler (standalone).") + logger.info(" For standalone, --simd picks the vectorisation width:") + logger.info(" auto (default), scalar, simd_128, simd_256, avx512y, simd_512;") + logger.info(" and --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 --simd=simd_256 --precision=d",'$MG:color:GREEN') logger.info("o precision:",'$MG:color:GREEN') logger.info(" syntax: check precision m|f|v [m|f|v ...] process_definition [--nb_event=X] [--energy=]") logger.info(" Evaluate the madmatrix standalone output built in each of the given") @@ -1099,7 +1114,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") @@ -1299,7 +1317,17 @@ 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_fortran (the default, + # fortran/f2py) or standalone (madmatrix). + '--exporter':'standalone_fortran', + # 'check crossing --exporter=standalone' vectorisation (SIMD) + # width: auto (default), scalar, simd_128, simd_256, + # avx512y, simd_512 -- the madmatrix cpu_mode tokens. + '--simd':'auto', + # 'check crossing --exporter=standalone' float precision: + # m mixed (default), d double, f float. + '--precision':'m'} if args[0] == 'precision': user_options['--nb_event'] = '1000000' @@ -2616,7 +2644,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']) @@ -2753,6 +2782,7 @@ def complete_check(self, text, line, begidx, endidx, formatting=True): categories=False)}, formatting) 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=', @@ -2764,6 +2794,25 @@ def complete_check(self, text, line, begidx, endidx, formatting=True): options.append('--nb_event=') if cms_options: options.extend(cms_options) + if crossing_check_mode: + # 'check crossing' only understands --energy, --exporter and (for + # the madmatrix 'standalone') --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): @@ -3063,7 +3112,7 @@ 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=', '--jamp_orbit=', '--t_strategy=', '--vector_size=4', '--nb_warp=1', - '--mask=False', '--prefix=']): + '--mask=False', '--prefix=', '--use_crossing=True', '--use_crossing=False']): "Complete the output command" possible_format = list(self._export_formats) @@ -3502,7 +3551,7 @@ def _tutorial_opts(self): _switch_opts = ['mg5','aMC@NLO','ML5'] _check_opts = ['full', 'timing', 'stability', 'profile', 'permutation', 'gauge','lorentz', 'brs', 'cms', 'flavor', 'language', - 'precision'] + 'crossing', 'precision'] _import_formats = ['model_v4', 'model', 'proc_v4', 'command', 'banner'] _install_opts = ['Delphes', 'ExRootAnalysis', 'update', 'Golem95', 'QCDLoop', 'maddm', 'maddump', @@ -3523,6 +3572,18 @@ def _tutorial_opts(self): _export_formats = _v4_export_formats + ['aloha', 'matchbox_cpp', 'matchbox', 'mg7_v5', 'mg7', 'standalone'] + # 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_fortran' is the fortran standalone and 'standalone' the + # madmatrix one (the names flipped in PR #64 -- test membership EXACTLY, + # 'standalone' is a prefix of every other standalone_* format). + # '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_fortran', 'standalone', + 'standalone_rw') _set_options = ['group_subprocesses', 'ignore_six_quark_processes', 'stdout_level', @@ -3536,6 +3597,7 @@ def _tutorial_opts(self): 'max_npoint_for_channel', 'max_t_for_channel', 'zerowidth_tchannel', + 'zerowidth_external', 'default_unset_couplings', 'nlo_mixed_expansion', 'color_basis', @@ -3622,6 +3684,7 @@ def _tutorial_opts(self): '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, 'color_basis': 'auto', @@ -3646,6 +3709,16 @@ def _tutorial_opts(self): _curr_helas_model = None _curr_exporter = None _second_exporter = None + # UI flag --use_crossing; see do_add. DEFAULT OFF: madspace does not + # support crossing yet, so the shipped default must be the un-crossed + # output for every mode. Pass --use_crossing=True to opt in. + _use_crossing = False + # Sticky: an explicit --use_crossing=False on ANY line of the current + # process definition keeps it off, even if a later line asks for it. + _use_crossing_off = False + # 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 = False _done_export = False _curr_decaymodel = None @@ -3752,6 +3825,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, counter=0): @@ -3787,20 +3886,76 @@ def do_add(self, line, counter=0): standalone_only = False if '--standalone' in args: standalone_only = True - merge_crossing = True - args.remove('--standalone') + args.remove('--standalone') - merge_crossing = False - if '--no_crossing' in args: - merge_crossing = True - args.remove('--no_crossing') + # Crossing symmetry is OFF by default (madspace does not support it + # yet). --use_crossing (bare) or --use_crossing=True turns it on, + # --use_crossing=False is the default. --standalone does not affect it. + use_crossing = self.pop_use_crossing_flag(args) + # The flag has to be popped HERE, before check_add sees `args`, but it + # is resolved into self._use_crossing further down -- after check_add. + # See the comment there. # Check the validity of the arguments self.check_add(args) if args[0] == 'model': return self.add_model(args[1:]) - + + # Resolve what THIS line means for the definition as a whole. With the + # default off, the old `and` accumulator below could never be lifted + # (False and True is False), so an explicit --use_crossing=True was + # silently ignored. Instead: an explicit True switches it on, an + # explicit False switches it off for good (a multi-line definition must + # not end up half crossed), and a line with no flag inherits what the + # definition already chose -- which starts off. + # + # AFTER check_add, and that is load-bearing: with no model imported yet + # check_generate imports the Standard Model for the user, and do_import + # calls clean_process(), which resets exactly these attributes. Setting + # them earlier left the LOCAL merge_crossing below enabled while + # self._use_crossing went back to False -- so the crossings were folded + # onto their base at generation and the exporter, which reads the + # attribute, then wrote no decoder for them. `p p > j j + # --use_crossing=True` came out as 3 subprocess directories covering 15 + # of its 65 flavor columns, with the other 50 unreachable and an + # extended FLAV_IDX returning 0 in silence -- but only in a script that + # had not imported a model of its own first, which is why it survived. + if use_crossing is False: + self._use_crossing_off = True + elif use_crossing is True: + self._use_crossing = True + if getattr(self, '_use_crossing_off', False): + self._use_crossing = False + use_crossing = self._use_crossing + # 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 + + # self._use_crossing is the definition-wide choice resolved just above + # (and reset per definition by clean_process/do_generate); the exporter + # reads that attribute, `merge_crossing` here only drives generation. + # special option for 1->N to avoid generation of kinematically forbidden #decay. if args[-1].startswith('--optimize'): @@ -5061,6 +5216,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' (madmatrix; ignored by the fortran backend). + 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' (madmatrix; ignored by the fortran backend). + 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"+ @@ -5314,6 +5499,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_fortran') + 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 "+ @@ -5774,6 +5977,14 @@ def clean_process(self): 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 = False + self._use_crossing_off = False + self._output_use_crossing = False # Reset _done_export, since we have new process self._done_export = False # Also reset _export_format and _export_dir @@ -11186,19 +11397,46 @@ 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') @@ -11751,6 +11989,17 @@ 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) @@ -11876,6 +12125,20 @@ def do_output(self, line): 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) @@ -12025,6 +12288,240 @@ 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. + + This used to carve out squared-order processes as well: their matrix + element is written from matrix_standalone_splitOrders_v4.inc, which had + no crossing machinery, so folding into it dropped the crossed + subprocesses outright. That template folds now + (fill_crossing_replace_dict_so), so the carve-out is gone and the + answer is the format again. The other two folding formats never needed + it: standalone_rw writes the same fortran template, and the + mg7/cudacpp exporter has no split-orders variant at all. + """ + crossed = [amp for amp in amps if 'crossed_processes' in amp + and amp.get('crossed_processes')] + if not crossed: + return False + return not self._output_folds_crossings() + + 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 set_color_basis_mode(self, *exporters): """Set the color basis used for fully adjoint (multi-gluon) processes. @@ -12068,22 +12565,29 @@ def _export(self, nojpeg = False, main_file_name = "", group_processes=True, """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') @@ -12148,6 +12652,100 @@ 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 the + # madmatrix 'standalone') 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). + # 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). + # Asked OUTSIDE the format gate below, because the format + # is not the whole answer: a folding backend still cannot + # fold a process carrying a squared order, whose matrix + # element is written from a template with no decoder. See + # _crossing_needs_expansion. + if self._crossing_needs_expansion(non_dc_amps): + non_dc_amps = \ + self._expand_recorded_crossings(non_dc_amps) + + if not self._output_folds_crossings(): + # 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(\ @@ -12184,8 +12782,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 @@ -12235,6 +12840,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 @@ -12432,7 +13055,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: @@ -12766,7 +13394,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 ab46e6e85b..d1864e25db 100755 --- a/madgraph/interface/master_interface.py +++ b/madgraph/interface/master_interface.py @@ -640,6 +640,9 @@ def 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 4e305dba3f..973f13b244 100755 --- a/madgraph/interface/reweight_interface.py +++ b/madgraph/interface/reweight_interface.py @@ -117,7 +117,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) @@ -1595,6 +1600,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""" @@ -1634,19 +1661,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: @@ -1678,10 +1719,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) # aMC@NLO writes its LHE with the partons on the Monte-Carlo mass shell # (add_write_info.f, put_on_MC_mshell_in / put_on_MC_mshell_Hevout) @@ -1725,7 +1763,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") @@ -1754,14 +1799,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) @@ -1926,10 +1982,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: @@ -1940,15 +2016,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: @@ -2191,7 +2258,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 ------------------------------------------------ @@ -2424,6 +2491,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: @@ -2490,8 +2559,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 @@ -2563,8 +2634,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""" @@ -2745,7 +3026,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""" @@ -2978,15 +3405,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): @@ -3166,22 +3587,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": @@ -3289,10 +3696,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) # same Monte-Carlo-mass projection as in ReweightInterface, and for the # same reason: this path boosts and rotates the momenta before the @@ -3398,6 +3802,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 232c320744..62f9c3954f 100755 --- a/madgraph/iolibs/export_cpp.py +++ b/madgraph/iolibs/export_cpp.py @@ -705,6 +705,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 +940,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 @@ -982,15 +988,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'] = \ @@ -1070,7 +1079,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 @@ -1178,6 +1191,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(\ @@ -1189,6 +1206,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) % \ @@ -1389,6 +1407,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""" @@ -1400,6 +1698,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() @@ -1568,9 +1870,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)) @@ -2634,6 +2938,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, @@ -2753,7 +3061,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``: @@ -2832,6 +3244,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) @@ -2844,7 +3258,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 OFF) 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) @@ -2862,7 +3287,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 @@ -2880,10 +3306,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 @@ -3191,6 +3619,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 @@ -3220,6 +3649,18 @@ def generate_subprocess_directory( merge_same_topologies=self.opt.get('merge_same_topologies', True) ) + # Enable the crossing machinery (extended flavor id) when the process was + # generated with --use_crossing (default OFF) 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) @@ -3616,6 +4057,12 @@ 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 (the madmatrix standalone) 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 # No C++ exporter has a MadLoop backend (the mg7 one cannot even index the diff --git a/madgraph/iolibs/export_mg7.py b/madgraph/iolibs/export_mg7.py index 83973868db..52d609b20f 100644 --- a/madgraph/iolibs/export_mg7.py +++ b/madgraph/iolibs/export_mg7.py @@ -395,6 +395,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: @@ -415,8 +448,11 @@ def get_subprocess_info(self, proc_dir, lib_me_path): [[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. @@ -485,7 +521,17 @@ def get_subprocess_info(self, proc_dir, lib_me_path): "path": proc_dir, "flavors": flavors, "qcd_power": qcd_power, + # 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_v4.py b/madgraph/iolibs/export_v4.py index 876fcb2de2..9666d54486 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -86,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 @@ -328,6 +418,11 @@ def split_order_tables(matrix_element): # would leave the color matrix at zero and the matrix element with it. COLOR_MATRIX_ENCODING_TEMPLATES = frozenset(( 'matrix_standalone_v4.inc', + # --hel_recycling rewrites SMATRIX/MATRIX into this one and appends the + # rest of matrix_standalone_v4.inc verbatim, INIT_CF included, so the + # answer color_matrix_encoding_allowed gives for the template it is + # selected through (matrix_standalone_v4.inc) holds for it too + 'matrix_standalone_hel_orig_v4.inc', 'matrix_standalone_v4_onia.inc', 'matrix_standalone_v4_onia_pwave.inc', 'matrix_madevent_v4.inc', @@ -377,6 +472,12 @@ class ProcessExporterFortran(VirtualExporter, 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""" @@ -384,12 +485,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() @@ -634,6 +736,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. @@ -776,13 +961,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: @@ -795,11 +1024,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] @@ -1164,6 +1421,35 @@ def write_matrix_element_v4(self): """ pass + def _check_crossing_support(self): + """Note that this output cannot read folded crossings. + + `--use_crossing` (OFF 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.""" @@ -2188,11 +2474,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 @@ -2241,7 +2538,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]: @@ -2543,6 +2840,108 @@ 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) + flip_data - DATA for FLIP(NCOMB) (C-parity partner row) + """ + 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] + if matrix_element.get_nonia() > 0: + # A bound state is one helicity digit, its 2J+1 states, in place of + # its two constituents -- the rows get_helicity_matrix enumerates. + # No onium template reads these tables; they only have to encode. + constituents = [] + for pair in matrix_element.get_onia_pairs(): + j = ext[pair[0] - 1].get('onium').get('J') + states[pair[0] - 1] = list(range(-j, j + 1)) + constituents.append(pair[1]) + for i in sorted(constituents, reverse=True): + del states[i - 1] + nstate = [len(s) for s in states] + nexternal = len(states) + 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) + + # C-parity partner of every row: the configuration with EVERY helicity + # negated. In code space that is a digit-wise complement, so it is known + # here and need not be rediscovered by an O(NCOMB^2 * NEXTERNAL) search + # over the materialized NHEL table at run time. + # + # FLIP(i) = i means "no distinct partner", which is what every consumer + # reads as "this row is not part of a C-symmetric pair, keep the + # de-duplication off". Two ways to land there, both deliberate: a row + # that negates to itself (all helicities 0), and a row whose negation is + # not in the allowed set -- the polarized case, where a leg keeps its + # full radix but only some states are selected. + code_to_row = {code: i + 1 for i, code in enumerate(allowed)} + flip = [] + for i, row in enumerate(matrix_element.get_helicity_matrix()): + neg, ok = 0, True + for k, val in enumerate(row): + if -val not in states[k]: + ok = False + break + neg = neg * nstate[k] + states[k].index(-val) + flip.append(code_to_row.get(neg + 1, i + 1) if ok else i + 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), + 'flip_data': self._fortran_data_stmt('FLIP', flip)} + def get_ic_line(self, matrix_element): """Return the IC definition line coming after helicities, required by switchmom in madevent""" @@ -2714,6 +3113,7 @@ def get_color_data_lines(self, matrix_element, n=128): return ["DATA %%(proc_prefix)sDenom/%(denom)i/" % \ {'denom': denominator}] + ret_list = [] my_cs = color.ColorString() denominator = max(matrix_element.get('color_matrix').get_line_denominators()) @@ -2972,6 +3372,1658 @@ def set_color_flow_lines(self, matrix_element, replace_dict, ncolor): 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 + # Leg polarization ({0}/{T}/...) selects helicity STATES on a named leg, + # and a crossing moves legs between the initial and the final state, so + # the selection would have to be re-read through the crossing before the + # per-row polarization gate (IS_BORN_HEL_SELECTED) could mean anything. + # Beam polarization is already refused outright against crossing (see + # common_run_interface); this is the same decision for the per-leg case, + # taken at generation so no crossing is ever recorded for such a process + # rather than emitted and then mis-consulted at run time + # (MultiProcess.generate_multi_amplitudes keeps it unfolded). + if any(leg.get('polarization') for leg in process.get('legs')): + return True + # A bound state: MATRIX projects the constituents onto the Fock state + # itself, and its templates (matrix_standalone_v4_onia*.inc) carry no + # crossing machinery. Same decision, same place. + if any(leg.get('onium') for leg in process.get('legs')): + 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 (NOT 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['flip_data'] = hel_data['flip_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_so(self, matrix_element, replace_dict, + use_crossing): + """Fill the crossing holes of matrix_standalone_splitOrders_v4.inc. + + Same job as fill_crossing_replace_dict, against a SMATRIX that differs + structurally: ANS and T are vectors over the squared split orders, and + MATRIX is a subroutine rather than a function. So the split-orders + template gets its own hole set rather than sharing the default one -- + the shapes do not line up, and pretending they do is how a template + ends up with a hole it cannot fill. + + With crossing OFF every hole reproduces the code that was written + before any of this existed, byte for byte, which is what keeps the FKS + and MadLoop borns (written from this same template, and always + uncrossed -- a perturbative process breaks crossing symmetry) exactly + as they were. + + Requires proc_prefix, nflav and den_factor_line to be set already. + """ + prefix = replace_dict['proc_prefix'] + + if not use_crossing: + replace_dict.update({ + 'so_cross_decl': '', + 'so_entry_guard': + ' IF (FLAV_IDX.LT.1 .OR. FLAV_IDX.GT.NFLAV) THEN\n' + ' ANS(:) = 0d0\n' + ' RETURN\n' + ' ENDIF', + 'so_cross_decode': '', + 'so_cross_apply': '', + 'so_csym_decl': '', + 'so_csym_reset': '', + 'so_csym_incr': '', + # No crossing -> every call is uncrossed, so the plain NTRY is + # the C-parity scan counter and no call has to be excluded. + 'so_csym_ntry': 'NTRY', + 'so_dedup_cross': '', + 'so_goodhel_gate': + ' IF (GOODHEL(IHEL,FLAV_USE) .OR. NTRY(FLAV_USE)' + ' .LT. 20 .OR.USERHEL.NE.-1) THEN', + 'so_matrix_call': + ' CALL %sMATRIX(P ,NHEL(1,IHEL),JC(1),' + 'FLAV_USE, T)' % prefix, + 'so_goodhel_train': + ' IF (BUFF .NE. 0D0 .AND. .NOT. ' + 'GOODHEL(IHEL,FLAV_USE)) THEN\n' + ' GOODHEL(IHEL,FLAV_USE)=.TRUE.\n' + ' ENDIF', + 'so_iden_line': + 'C IDEN carries the identical-particle factor of the' + ' representative\nC flavor; BROKEN_SYM corrects it for' + ' the actual one.\n' + ' DO I=1,NSQAMPSO\n' + ' ANS(I)=ANS(I)/DBLE(IDEN)*%sBROKEN_SYM(FLAVOR)\n' + ' ENDDO' % prefix, + 'so_getamp_guard': '', + 'so_crossing_routines': '', + 'so_pdg_function': '', + }) + return + + replace_dict.update({ + 'so_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/NHELUSE/ICUSE\nC are the crossed' + ' momenta, helicity table and NSF flags, built once per 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 NHELUSE(NEXTERNAL,NCOMB)\n' + ' INTEGER ICUSE(NEXTERNAL)\n' + ' INTEGER DUMFLAV\n' + 'C GHIDX is the identity row whose shared GOODHEL bit gates' + ' the current\nC crossed row; XGPERM/XGSGN are the' + ' crossing\'s slot permutation and NSF\nC signs, fetched' + ' once per call.\n' + ' INTEGER GHIDX\n' + ' INTEGER XGPERM(NEXTERNAL), XGSGN(NEXTERNAL), XGDUM' + % {'p': prefix}, + # An extended index is legal here; only the lower bound is fixed. + 'so_entry_guard': + ' IF (FLAV_IDX.LT.1) THEN\n' + ' ANS(:) = 0d0\n' + ' RETURN\n' + ' ENDIF', + 'so_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' + ' IDENUSE = %(p)sGET_SPINCOL_CROSS(CROSSUSE)\n' + ' IF (IDENUSE.EQ.0) THEN\n' + ' ANS(:) = 0d0\n' + ' RETURN\n' + ' ENDIF' % {'p': prefix}, + 'so_cross_apply': + 'C Apply the crossing ONCE, here, rather than once per' + ' helicity: it is a\nC fixed slot permutation, identical' + ' for every row, so the whole NHEL table\nC goes through in' + ' one sweep together with the momenta and the NSF flags.\n' + ' CALL %(p)sGET_CROSS_PERM(FLAV_IDX, XGPERM, XGSGN, XGDUM)\n' + ' IF (CROSSUSE.NE.0) THEN\n' + ' CALL %(p)sAPPLY_CROSSING_TABLE(FLAV_IDX, NCOMB, P,' + ' NHEL,\n' + ' & JC, PUSE, NHELUSE, ICUSE, DUMFLAV)\n' + ' ENDIF' % {'p': prefix}, + 'so_csym_decl': + 'C NTRY_CSYM counts only the uncrossed (cross 0) calls: the' + ' C-parity pairing\nC is a base-row negation, which a' + ' crossing does not preserve.\n' + ' INTEGER NTRY_CSYM(NFLAV)\n' + ' DATA NTRY_CSYM/NNTRY_FLAV*0/', + 'so_csym_reset': ' NTRY_CSYM(i) = 0', + 'so_csym_incr': + ' IF(USERHEL.EQ.-1.AND.FLAV_IDX.LE.NFLAV)\n' + ' & NTRY_CSYM(FLAV_USE)=NTRY_CSYM(FLAV_USE)+1', + 'so_csym_ntry': 'NTRY_CSYM', + 'so_dedup_cross': ' .AND. FLAV_IDX.LE.NFLAV', + 'so_goodhel_gate': + 'C GOODHEL is shared by every crossing of a flavor, but a' + ' crossing permutes\nC and flips helicities, so CROSS_GHIDX' + ' sends crossed row IHEL to the identity\nC row that gates' + ' it. GHIDX=0 means not filterable -> compute it.\n' + ' CALL %(p)sCROSS_GHIDX(CROSSUSE, XGPERM, XGSGN,\n' + ' & NHEL(1,IHEL), GHIDX)\n' + ' IF (GHIDX.EQ.0 .OR. GOODHEL(GHIDX,FLAV_USE) .OR.' + ' NTRY(FLAV_USE) .LT. 20 .OR.USERHEL.NE.-1) THEN' + % {'p': prefix}, + 'so_matrix_call': + ' IF (CROSSUSE.EQ.0) THEN\n' + ' CALL %(p)sMATRIX(P ,NHEL(1,IHEL),JC(1),' + 'FLAV_USE, T)\n' + ' ELSE\n' + ' CALL %(p)sMATRIX(PUSE,NHELUSE(1,IHEL),' + 'ICUSE(1), FLAV_USE, T)\n' + ' ENDIF' % {'p': prefix}, + 'so_goodhel_train': + 'C Train the SHARED filter through the same map, so GOODHEL' + ' always stores\nC the identity pattern whatever crossing is' + ' being evaluated.\n' + ' IF (BUFF .NE. 0D0 .AND. GHIDX.NE.0 .AND. .NOT.' + ' GOODHEL(GHIDX,FLAV_USE)) THEN\n' + ' GOODHEL(GHIDX,FLAV_USE)=.TRUE.\n' + ' ENDIF', + 'so_iden_line': + 'C Uncrossed: IDEN carries the representative' + ' identical-particle factor and\nC BROKEN_SYM corrects it' + ' per flavor. Crossed: BROKEN_SYM\'s tables describe\n' + 'C the uncrossed final state, so rebuild the denominator as' + ' initial state\nC spin*color (per crossing) times the' + ' identical final state factor of the\nC actual crossed' + ' flavors.\n' + ' IF (CROSSUSE.EQ.0) THEN\n' + ' DO I=1,NSQAMPSO\n' + ' ANS(I)=ANS(I)/DBLE(IDEN)*%(p)sBROKEN_SYM(FLAVOR)\n' + ' ENDDO\n' + ' ELSE\n' + ' DO I=1,NSQAMPSO\n' + ' ANS(I)=ANS(I)/DBLE(IDENUSE*%(p)sGET_IDENT_CROSS(\n' + ' & CROSSUSE, FLAVOR))\n' + ' ENDDO\n' + ' ENDIF' % {'p': prefix}, + 'so_getamp_guard': + 'C Contract guard: an extended FLAV_IDX (one carrying a' + ' crossing) reaching\nC this routine would be silently' + ' truncated to its flavor part and give the\nC uncrossed' + ' amplitude. Fail loudly and return zero instead.\n' + ' IF (FLAV_IDX.LT.1 .OR. FLAV_IDX.GT.NFLAV) THEN\n' + " WRITE(*,*) 'ERROR: GET_AMP got FLAV_IDX', FLAV_IDX\n" + " WRITE(*,*) 'GET_AMP needs a reduced index and crossed" + " P/NHEL/IC.'\n" + ' DO AMP_I = 1, NGRAPHS\n' + ' AMP(AMP_I) = (0D0, 0D0)\n' + ' ENDDO\n' + ' RETURN\n' + ' ENDIF', + }) + # Both blocks are already built, by fill_crossing_replace_dict and by + # the GET_PDG_FOR_FLAVOR writer: this template just opts into them. + # They are reached through holes of their own rather than the default + # template's names so that a split-orders file written WITHOUT crossing + # gets neither -- GET_PDG_FOR_FLAVOR only exists to decode a crossing. + replace_dict['so_crossing_routines'] = replace_dict['crossing_routines'] + replace_dict['so_pdg_function'] = replace_dict['flavor_pdg_function'] + + 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'], + 'flip_data': hel_data['flip_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).""" @@ -3544,6 +5596,7 @@ def format(frac): return res_list, len(defs) + 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 @@ -5138,6 +7191,11 @@ class ProcessExporterFortranSA(ProcessExporterFortran): f2py_matrix_splitter = "f2py_splitter.py" jamp_optim = 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 @@ -5149,6 +7207,12 @@ class ProcessExporterFortranSA(ProcessExporterFortran): # 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""" @@ -5160,8 +7224,120 @@ def __init__(self, *args,**opts): self.format = 'standalone_fortran' 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). + + For the split-orders variant the dividing line is the crossing, and it + is meant to be readable as such: it carries the parts of the default + template that mean something without one (the canonical helicity + encoder/decoder feeding PROCESS_NHEL, the C-parity de-duplication, the + flavor-aware denominator accessor GET_NHEL_IDX) and none of the parts + that exist only to decode an extended FLAV_IDX (the _IDX/_CROSSED + density stack, GET_PDG_FOR_FLAVOR, the crossing routines themselves -- + note fill_crossing_replace_dict fills holes this template does not + have, and use_crossing_ic excludes split orders outright). The one + further difference is the external helicity label: the row number + here, the canonical code there, because MadLoop passes a row. + test_splitorders_template_carries_the_non_crossing_standalone_api pins + all of it. + + --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. + + A bound state (onium) replaces the default template by its onium + variant, which carries none of the crossing or density entry points. + No other template has an onium variant, so any other choice is refused. + """ + if self.opt['export_format'] == 'standalone_msP': + template = 'matrix_standalone_msP_v4.inc' + elif self.opt['export_format'] == 'standalone_msF': + template = 'matrix_standalone_msF_v4.inc' + elif matrix_element.get('processes')[0].get('split_orders'): + if self.opt['export_format'] in ('madloop_matchbox', 'matchbox'): + template = 'matrix_standalone_matchbox_splitOrders_v4.inc' + else: + template = 'matrix_standalone_splitOrders_v4.inc' + else: + template = self.matrix_template + + if matrix_element.get_nonia() > 0: + # MATRIX projects the constituents itself, so the HELAS calls run + # on P_ONIA / NHEL_ONIA / IC_ONIA / FLAVOR_ONIA, which only the + # onium templates declare (MadSpin's msP/msF, matchbox and split + # orders have none). + if template != 'matrix_standalone_v4.inc': + raise MadGraph5Error( + "Bound states (onia) are only supported by the plain " + "standalone matrix element, but this process selected " + "'%s'. Split orders and the MadSpin/matchbox outputs " + "have no onium template." % template) + if matrix_element.get_npwave() == 0: + return 'matrix_standalone_v4_onia.inc' + return 'matrix_standalone_v4_onia_pwave.inc' + return 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. + + It is a plain substring search, so a template comment that names a + routine it does NOT define makes this answer True for it. Templates + therefore describe a deliberately omitted entry point rather than + naming it. + """ + 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] + + def matrix_template_has_pdg_decoder(self, matrix_element): + """True when the matrix element file defines GET_PDG_FOR_FLAVOR. + + Two templates emit it, through holes of their own: the default one + always (%(flavor_pdg_function)s), the split-orders one only when the + crossing machinery is written (%(so_pdg_function)s -- without a + crossing there is nothing for it to decode). Asking for one hole name + would answer 'no' for the other template even though the routine is + right there, which is how the crossing demonstration went missing from + a folded split-orders output that could perfectly well run it. + """ + return any(self.matrix_template_provides(matrix_element, marker) + for marker in ('%(flavor_pdg_function)s', + '%(so_pdg_function)s')) + + # template name -> text, so the lookup above costs one read per output + _matrix_template_cache = {} + def copy_template(self, model): """Additional actions needed for setup of Template """ @@ -5344,6 +7520,11 @@ 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') @@ -5361,14 +7542,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)): @@ -5512,6 +7697,23 @@ def write_f2py_splitter(self): else: flavor_repair_function = '' + # 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: @@ -5547,11 +7749,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 = '' @@ -5580,6 +7789,7 @@ def write_f2py_splitter(self): 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), @@ -5608,9 +7818,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 MadGraph7\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 @@ -5781,6 +8041,12 @@ def color_dim_from_particle(p): iden = compute_iden_from_pdgs(ids, ninitial, self.model) self.prefix_info[(tuple(ids), proc.get('id'))] = [proc_prefix, proc.get_tag(), ncomb, iden, ninitial, nflav] + # 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() @@ -5789,10 +8055,15 @@ def color_dim_from_particle(p): 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_fortran': 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( @@ -5803,16 +8074,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': @@ -5894,7 +8167,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'] @@ -5995,10 +8268,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 @@ -6063,7 +8341,7 @@ 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) @@ -6093,9 +8371,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 OFF, see + # MadGraphCmd._use_crossing); see fill_crossing_replace_dict. + if 'use_crossing' not in self.opt: + self.opt['use_crossing']=False + + # 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. @@ -6116,13 +8420,43 @@ 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.get_matrix_template(matrix_element) in ( + 'matrix_standalone_v4.inc', + 'matrix_standalone_splitOrders_v4.inc')) + and self.opt['export_format'] not in ('standalone_msP', + 'standalone_msF', + 'matchbox', + 'madloop_matchbox')) try: # Extract helas calls helas_calls = fortran_model.get_matrix_element_calls(\ @@ -6131,6 +8465,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) @@ -6151,9 +8486,19 @@ 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['flip_data'] = hel_data['flip_data'] + replace_dict['hel_allow_data'] = hel_data['hel_allow_data'] # Extract overall denominator # Averaging initial state color, spin, and identical FS particles @@ -6243,15 +8588,23 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, # The original driver still works and is compiled with 'make' while # the splitOrders one is compiled with 'make check_sa_born_splitOrders' if self.opt['export_format'] not in ['standalone_msP', 'standalone_msF']: - 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) @@ -6276,14 +8629,11 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, replace_dict['jamp_tmp_decl'] = \ " COMPLEX*16 TMP_JAMP(%i)" % replace_dict['nb_temp_jamp'] - 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') + 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: @@ -6293,28 +8643,11 @@ 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" if matrix_element.get_nonia() > 0: # A bound state: MATRIX projects its constituents itself, so the - # HELAS calls run on the constituent kinematics/helicities. Only - # the onium templates declare those arrays, so this has to come - # after every other choice of template -- and a template without - # an onium variant (MadSpin's msP/msF, matchbox, split orders) - # cannot be used at all: it would write HELAS calls on P_ONIA / - # NHEL_ONIA / IC_ONIA / FLAVOR_ONIA that it never declares. - if matrix_template != 'matrix_standalone_v4.inc': - raise MadGraph5Error( - "Bound states (onia) are only supported by the plain " - "standalone matrix element, but this process selected " - "'%s'. Split orders and the MadSpin/matchbox outputs " - "have no onium template." % matrix_template) - if matrix_element.get_npwave() == 0: - matrix_template = 'matrix_standalone_v4_onia.inc' - else: - matrix_template = 'matrix_standalone_v4_onia_pwave.inc' + # HELAS calls run on the constituent kinematics/helicities (the + # onium template was chosen by get_matrix_template). for old, new in [('P(0', 'P_ONIA(0'), ('NHEL(', 'NHEL_ONIA('), ('IC(', 'IC_ONIA('), ('FLAVOR(', 'FLAVOR_ONIA(')]: replace_dict['helas_calls'] = replace_dict['helas_calls'].replace(old, new) @@ -6361,9 +8694,67 @@ 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) + + # ... and the split-orders template's own crossing holes, which need + # both of the blocks above, so this comes last. + self.fill_crossing_replace_dict_so(matrix_element, replace_dict, + use_crossing) + + # 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() @@ -6385,8 +8776,834 @@ 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 shared GET_MATRIX, one gathered row at a + time, so the recycled build carries no color sum of its own and inherits + whatever the color side gains. (It used to have a batched BLAS-3 path and + a folded color matrix; main dropped both -- b2b6d13ef/bfc5a0814 and + 9f8b1b334 -- so this is the only path.) + """ + prefix = rd['proc_prefix'] + 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"] + return '\n'.join(decl), '\n'.join(clear + gather + [ + " CALL %sGET_MATRIX(JAMP, TS(HRROW(KK+HRL-1)))" % prefix, + " ENDDO", + " ENDDO"]) + + # 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) + + 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', False) 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_has_pdg_decoder(matrix_element): + 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_fortran': @@ -6404,7 +9621,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) @@ -6459,6 +9695,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) + # An onium process needs its own driver: its SMATRIX takes the # reshuffled momenta as an extra argument, and the driver has to pull in # the LDME/onium-mass common blocks (ldme.inc) that pmass.inc refers to. @@ -6527,7 +9771,11 @@ 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 @@ -6536,6 +9784,26 @@ class ProcessExporterFortranMatchBox(ProcessExporterFortranSA): # matchbox needs the color flow information support_ddm_color_basis = False + + # 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 @@ -6545,7 +9813,7 @@ def color_data_prefix(self, replace_dict): return '' - @staticmethod + @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.""" @@ -7209,6 +10477,11 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model,proc_id replace_dict['broken_sym_function'] = \ self._make_broken_sym_fortran_function(bs_func_name, sym_data) + # C-parity partner row of every helicity config: generated data, so + # the matrix element does not rediscover it with an + # O(NCOMB^2 * NEXTERNAL) search over NHEL at run time. + replace_dict['flip_data'] = \ + self._helstate_data(matrix_element)['flip_data'] replace_dict['template_file'] = os.path.join(_file_path, \ 'iolibs/template_files/%s' % self.matrix_file) replace_dict['template_file2'] = '' @@ -7284,6 +10557,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 @@ -7568,6 +10861,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): @@ -7822,6 +11204,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 @@ -8109,10 +11495,12 @@ def finalize(self, matrix_elements, history, mg5options, flaglist, second_export #os.chdir(old_pos) #=========================================================================== + # 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 \ @@ -8148,7 +11536,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) @@ -8158,12 +11586,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 @@ -8374,6 +11807,10 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, else: tmpl = self.matrix_file + # C-parity partner row of every helicity config (generated data -- + # see the note at the other write_matrix_element_v4). + replace_dict['flip_data'] = \ + self._helstate_data(matrix_element)['flip_data'] replace_dict['template_file'] = pjoin(_file_path, \ 'iolibs/template_files/%s' % tmpl) replace_dict['template_file2'] = pjoin(_file_path, \ @@ -8443,12 +11880,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'): @@ -8502,6 +12462,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 @@ -8580,8 +12560,16 @@ 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'] + replace_dict['flip_data'] = hel_data['flip_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 @@ -8619,6 +12607,12 @@ def write_auto_dsig_file(self, writer, matrix_element, proc_id = ""): + # 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() @@ -9544,6 +13538,212 @@ def write_driver(self, writer, ncomb, n_grouped_proc, v5=True, onia=False): 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, or carries a bound state: the flows + then run through the Fock state's colour, one slot per constituent pair + (get_leshouche_lines writes those), not one per leg.""" + if not matrix_element.get('color_basis') or matrix_element.get_nonia(): + 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 #=========================================================================== @@ -9801,14 +14001,327 @@ class ProcessExporterFortranMEGroup(ProcessExporterFortranME): 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 #=========================================================================== @@ -9886,17 +14399,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. @@ -9918,19 +14624,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) @@ -9956,7 +14667,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) @@ -9988,7 +14700,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), @@ -10026,6 +14738,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: @@ -10174,7 +14890,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""" @@ -10269,11 +14986,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 @@ -10576,11 +15419,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): @@ -13929,8 +18782,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'), diff --git a/madgraph/iolibs/gen_infohtml.py b/madgraph/iolibs/gen_infohtml.py index c6f18581b9..49847bd737 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 2d87433ba8..fea2d60457 100755 --- a/madgraph/iolibs/helas_call_writers.py +++ b/madgraph/iolibs/helas_call_writers.py @@ -395,9 +395,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 @@ -1232,6 +1235,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): @@ -1393,14 +1401,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): @@ -1866,6 +1890,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): @@ -1893,6 +1921,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. @@ -1933,50 +1989,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/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 4ae50c96b8..4e5feaab49 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 @@ -222,7 +222,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 @@ -397,9 +397,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 @@ -453,7 +453,7 @@ C comparison false) and IPROC = 0. See the scalar branch. %(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 @@ -560,22 +560,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 @@ -584,19 +584,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 ece1959b92..1e9819ad3e 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 6b7b477d7d..20f391e838 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 LOGICAL READPS C @@ -147,6 +160,8 @@ PROGRAM DRIVER write (*,*) "-----------------------------------------------------------------------------" enddo +%(crossing_example)s + if (%(use_density)s)then do I=1, MAXFLAVOR write (*,*) "==== density matrix for flavor", I, @@ -213,8 +228,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/check_sa_splitOrders.f b/madgraph/iolibs/template_files/check_sa_splitOrders.f index f0ee277013..93a8c6b3e4 100644 --- a/madgraph/iolibs/template_files/check_sa_splitOrders.f +++ b/madgraph/iolibs/template_files/check_sa_splitOrders.f @@ -4,6 +4,13 @@ PROGRAM DRIVER C IT USES A SIMPLE PHASE SPACE GENERATOR C Fabio Maltoni - 3rd Febraury 2007 C ************************************************************************** +C coupl.inc declares TYPE(FLV_COUPLING) entries, whose type comes from +C model_object. Without this the driver does not compile at all +C ("Symbol 'flv_1' has no IMPLICIT type"), which nothing noticed because +C `make` builds check_sa.f only -- check_sa_born_splitOrders is a target +C of its own. check_sa.f has carried the same line since the flavored +C couplings landed. + use model_object IMPLICIT NONE C C CONSTANTS 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 1796b8e362..a9360256cc 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 3e7e8525e3..b0bc936180 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..f30f9eea11 --- /dev/null +++ b/madgraph/iolibs/template_files/hel_warmup_v4.inc @@ -0,0 +1,429 @@ + 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) + LOGICAL CSYM +%(flip_data)s +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 FLIP is generated data (the C-parity partner of every row). A +C self-paired row has no distinct partner, so the whole de-duplication is +C refused rather than applied to part of the table. + CSYM = .TRUE. + DO IHEL=1,NCOMB + 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 MadGraph7 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 3bf4565cd7..6a86727d2e 100644 --- a/madgraph/iolibs/template_files/madevent_makefile_source +++ b/madgraph/iolibs/template_files/madevent_makefile_source @@ -82,6 +82,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/check_sa.cc b/madgraph/iolibs/template_files/madmatrix/check_sa.cc index cf5955aa28..745635ced0 100644 --- a/madgraph/iolibs/template_files/madmatrix/check_sa.cc +++ b/madgraph/iolibs/template_files/madmatrix/check_sa.cc @@ -415,7 +415,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; @@ -424,10 +428,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; @@ -780,6 +790,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_blas_loop.inc b/madgraph/iolibs/template_files/madmatrix/color_sum_blas_loop.inc index 6de73a4f45..4181314c01 100644 --- a/madgraph/iolibs/template_files/madmatrix/color_sum_blas_loop.inc +++ b/madgraph/iolibs/template_files/madmatrix/color_sum_blas_loop.inc @@ -6,18 +6,18 @@ // 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 < cNGoodHel; ighel++ ) + for( int ighel = 0; ighel < %(sigmakin_hel_bound)s; ighel++ ) { - const int ihel = cGoodHel[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 ); + 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(), cNGoodHel, ievt00 ); + 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(), cNGoodHel, ievt00 ); + 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 1f3a062e17..838108c30e 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/process_cc.inc b/madgraph/iolibs/template_files/madmatrix/process_cc.inc index fc47bc7156..e8c2bf32e0 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_function_definitions.inc b/madgraph/iolibs/template_files/madmatrix/process_function_definitions.inc index 2237352944..179eb50fd2 100644 --- a/madgraph/iolibs/template_files/madmatrix/process_function_definitions.inc +++ b/madgraph/iolibs/template_files/madmatrix/process_function_definitions.inc @@ -177,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 @@ -266,7 +267,7 @@ namespace mg5amcCpu int CPPProcess::flavorPDG( int iflavor, int ipar ) { - return flavorPDGs[iflavor][ipar]; +%(flavorpdg_body)s } //-------------------------------------------------------------------------- @@ -550,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 */ @@ -582,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; @@ -590,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 //-------------------------------------------------------------------------- @@ -628,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; } //-------------------------------------------------------------------------- diff --git a/madgraph/iolibs/template_files/madmatrix/process_sigmaKin_function.inc b/madgraph/iolibs/template_files/madmatrix/process_sigmaKin_function.inc index 8d488e353a..32516e6653 100644 --- a/madgraph/iolibs/template_files/madmatrix/process_sigmaKin_function.inc +++ b/madgraph/iolibs/template_files/madmatrix/process_sigmaKin_function.inc @@ -99,8 +99,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 @@ -119,36 +119,36 @@ #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 -%(cpp_blas_helicity_loop)s 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_amp_sv jamp_sv[nParity * %(jamp_ncolor)s] = {}; // 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_amp_sv jamp_sv[nParity * %(jamp_ncolor)s] = {}; // 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 - }%(cpp_blas_helicity_loop_end)s +%(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; @@ -157,12 +157,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; @@ -295,7 +295,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 * static_cast( broken_symmetry_factor( iflavorVec[ievt0] ) ) / static_cast( helcolDenominators[0] ); +%(sigmakin_denominator)s if( storeChannelWeights ) // fix segfault #892 (not 'channelIds[0] != 0') { // The numerators have already been accumulated over all good helicities in place (running sum diff --git a/madgraph/iolibs/template_files/madmatrix/umami.cc b/madgraph/iolibs/template_files/madmatrix/umami.cc index 2fa3614640..3efa9f0e20 100644 --- a/madgraph/iolibs/template_files/madmatrix/umami.cc +++ b/madgraph/iolibs/template_files/madmatrix/umami.cc @@ -529,31 +529,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 2b130f18c1..84a6e0c436 100644 --- a/madgraph/iolibs/template_files/makefile_sa_f_sp +++ b/madgraph/iolibs/template_files/makefile_sa_f_sp @@ -14,7 +14,13 @@ PROG_SPLITORDERS = check_sa_born_splitOrders LINKLIBS = -L$(LIBDIR) -ldhelas -lmodel 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 +31,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 +63,14 @@ 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 +# 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..cc943521fd --- /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 MadGraph7 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 fc45e1568c..7f2412cfec 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,31 @@ 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 DEDUP, CSYM_DONE + INTEGER CSYMBAD(MAXFLAVPERPROC,MAXSPROC) + INTEGER NCSCAN(MAXFLAVPERPROC,MAXSPROC) + COMMON/BLOCK_CSYM/CSYMBAD,NCSCAN + SAVE CSYM_DONE + DATA CSYM_DONE/.FALSE./ +%(flip_data)s + 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 +115,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) @@ -128,9 +154,25 @@ C 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 + IF (.NOT.CSYM_DONE) THEN +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 + CSYM_DONE = .TRUE. + 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 @@ -149,17 +191,70 @@ C ---------- ! 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) + 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=MATRIX%(proc_id)s(%(me_matrix_args)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 +271,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 +292,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) %(beam_polarization)s c Always one helicity at a time @@ -226,7 +321,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 +349,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 +371,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) C %(info_lines)s C @@ -316,6 +413,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 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 696e60c77a..e66bfcb7ea 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,7 +98,8 @@ ${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 @@ -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 @@ -229,6 +238,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 @@ -244,6 +254,7 @@ C INTEGER DENOM, CF_INDEX COMMON /%(proc_prefix)scolor_matrix%(proc_id)s/ CF,DENOM COMPLEX*16 AMP(NCOMB,NGRAPHS), JAMP(NCOLOR,NAMPSO) +${hr_gather_decl} %(jampflow_decl)s type(aloha) W(NWAVEFUNCS) C Needed for v4 models @@ -291,9 +302,10 @@ C Rebuild the FLAVOR(NEXTERNAL) array from the threaded flavor index. %(flavor_mask_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} %(jampflow_lines)s TS(K) = 0.D0 @@ -313,6 +325,7 @@ ${jamp_lines} ENDDO ! I ENDDO ! M TS(K) = TS(K) / DENOM + ${dead_row_if} if(sde_strat.eq.1) then ${amp2_lines} endif @@ -325,7 +338,8 @@ ${jamp_lines} enddo enddo Enddo - ENDDO ! K + ${dead_row_endif} + ENDDO ! ${hr_gather_close} END diff --git a/madgraph/iolibs/template_files/matrix_madevent_v4.inc b/madgraph/iolibs/template_files/matrix_madevent_v4.inc index 42491000da..ab8c233afc 100644 --- a/madgraph/iolibs/template_files/matrix_madevent_v4.inc +++ b/madgraph/iolibs/template_files/matrix_madevent_v4.inc @@ -61,6 +61,29 @@ 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) + REAL*8 TSMAX + LOGICAL DEDUP + INTEGER CSYMBAD(MAXFLAVPERPROC), NCSCAN(MAXFLAVPERPROC) + COMMON/BLOCK_CSYM/CSYMBAD,NCSCAN +%(flip_data)s INTEGER FLAVOR(NEXTERNAL) INTEGER FLAVOR_FOR_SYM(NEXTERNAL) C Per-row FLAVOR lookup used by BROKEN_SYM. The IFLAV-indexed FLAVOR @@ -100,6 +123,8 @@ C BEGIN CODE C ---------- CALL GET_FLAVOR%(proc_id)s(IFLAV, FLAVOR) NTRY(IFLAV)=NTRY(IFLAV)+1 +C FLIP(I) -- the C-parity partner of each helicity row, every helicity +C negated -- is generated DATA (see _helstate_data); nothing to build. DO I=1,NEXTERNAL JC(I) = +1 ENDDO @@ -120,9 +145,20 @@ 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 IF (GOODHEL(I,IFLAV) .OR. NTRY(IFLAV) .LE. MAXTRIES.OR.(ISUM_HEL.NE.0)) THEN +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=MATRIX%(proc_id)s(P,NHEL(1,I),IFLAV, IVEC) %(beam_polarization)s IF (ISUM_HEL.NE.0) then @@ -130,8 +166,43 @@ c WRITE(HEL_BUFF,'(20I5)') (0,I=1,NEXTERNAL) 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 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..930aabbe7f --- /dev/null +++ b/madgraph/iolibs/template_files/matrix_standalone_crossing_v4.inc @@ -0,0 +1,398 @@ + 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 +C TAU, not sigma: the helicity slots are NOT permuted, only sign-flipped, +C and the sign is carried by IC above (HELAS builds its spinors from +C nhel*nsf). IC stays aligned with the MOMENTA -- it is also the NSF +C direction flag -- so slot XK gets P_IN(PERM(XK)) and NHEL_IN(XK), and the +C effective helicity is NHEL_IN(XK)*SGN(XK). That is the same map the +C recycled optim and the madmatrix lanes can realise, so one good-helicity +C filter now describes every backend (see CROSS_GHIDX). + DO XR = 1, NROW + DO XK = 1, NEXTERNAL + NHEL(XK,XR) = NHEL_IN(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 +C TAU: sign-flip in place, no slot permutation -- the gate has to live in +C the same space as what APPLY_CROSSING_TABLE actually evaluates. + DO K=1,NEXTERNAL + TGT(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 7c04cb38f2..36011e94b3 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..e46d2214c9 --- /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 MadGraph7 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_splitOrders_v4.inc b/madgraph/iolibs/template_files/matrix_standalone_splitOrders_v4.inc index a6f7516b81..11caba4be4 100644 --- a/madgraph/iolibs/template_files/matrix_standalone_splitOrders_v4.inc +++ b/madgraph/iolibs/template_files/matrix_standalone_splitOrders_v4.inc @@ -144,18 +144,66 @@ 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. Without crossing the two are +C the same; with crossing FLAV_IDX also carries a crossing code and only +C the reduced index may travel down to MATRIX / GET_AMP. + INTEGER FLAV_USE +%(so_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 helicity loop below): +C FLIP(IHEL) is the row with every helicity negated (built once by the +C generator, an involution); TSTORE caches the per-squared-order |M|^2 +C vector within a scan point; DEDUP turns the reuse on in the fast phase +C 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 whose |M|^2 differ at any scan point +C clears it for good. Halving the whole loop (rather than an arbitrary +C subset of rows) is what keeps any per-row accumulation uniformly +C scaled, so the same rule is used by every backend. +C The scan is counted separately from NTRY only when the crossing +C machinery is written out: a crossing permutes and sign-flips the +C helicities, so a base-row negation is no longer the C-parity partner and +C a CROSSED call must not advance the scan (nor use its verdict). Without +C crossing every call is uncrossed and NTRY is that counter. +C The pair test runs per squared-order component, not on their sum: two +C components could in principle cancel into an equal total, and the +C doubling below is applied component by component. + INTEGER FLIP(NCOMB) + LOGICAL CSYM(NFLAV), DEDUP + REAL*8 TSTORE(NSQAMPSO,NCOMB) + REAL*8 TSMAX +%(flip_data)s + DATA CSYM/NNTRY_FLAV*.TRUE./ +%(so_csym_decl)s %(den_factor_line)s C C GLOBAL VARIABLES C +C Materialized by FILL_NHEL, which is called below before the loop that +C reads it (a DATA statement may not initialise a COMMON outside a BLOCK +C DATA, which is what this template used to rely on). INTEGER NHEL(NEXTERNAL,NCOMB) -%(helicity_lines)s COMMON/%(proc_prefix)sPROCESS_NHEL/NHEL +C DELIBERATE DIVERGENCE from matrix_standalone_v4.inc, which publishes +C the canonical code list through a common and matches USERHEL against +C it. (The routine names it and the other entry points this template +C leaves out are spelled in the commit message and in +C test_splitorders_template_carries_the_non_crossing_standalone_api, not +C here: matrix_template_provides answers by searching the template text, +C so naming a routine in a comment would make it claim the routine +C exists.) +C Here the external helicity label stays the ROW NUMBER, because that is +C what this template's callers pass: MadLoop hands SMATRIXHEL_SPLITORDERS +C its own USERHEL, which the EW-Sudakov driver fills from +C `do chosen_hel=1,SDK_GET_NCOMB()` (Template/NLO/SubProcesses/ +C check_sudakov.f) -- a row index into the same enumeration. The two +C labels coincide for every unpolarized process (the code of row i is +C exactly i); they differ only under a {0}/{L}-style restriction, and +C relabelling there would silently move the Born of a polarized NLO run. INTEGER USERHEL DATA USERHEL/-1/ COMMON/%(proc_prefix)sHELUSERCHOICE/USERHEL @@ -179,6 +227,8 @@ c--------- if (HELRESET) then do i=1,NFLAV NTRY(i) = 0 + CSYM(i) = .true. +%(so_csym_reset)s enddo do i=1,NCOMB do j=1,NFLAV @@ -195,16 +245,22 @@ C ---------- 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 - ANS(:) = 0d0 - RETURN - ENDIF - CALL %(proc_prefix)sGET_FLAVOR(FLAV_IDX, FLAVOR) - IF(USERHEL.EQ.-1) NTRY(FLAV_IDX)=NTRY(FLAV_IDX)+1 +C 1..NFLAV GOODHEL/NTRY arrays. With the crossing machinery written out an +C index ABOVE NFLAV is legal -- it names a crossing -- so only the lower +C bound is checked there and the crossing decode below rejects the rest. +%(so_entry_guard)s + FLAV_USE = MOD(FLAV_IDX-1, NFLAV) + 1 +%(so_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() + IF(USERHEL.EQ.-1) NTRY(FLAV_USE)=NTRY(FLAV_USE)+1 +%(so_csym_incr)s DO IHEL=1,NEXTERNAL JC(IHEL) = +1 ENDDO +%(so_cross_apply)s ANS(:) = 0d0 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 @@ -215,14 +271,54 @@ C For this reason, we simply remove the filterin when there is only three ex IF (NEXTERNAL.LE.3) THEN GOODHEL(:,:) = .True. ENDIF +C C-parity de-duplication is only safe for the plain unpolarized helicity +C sum of the UNCROSSED process and only once its own scan has settled. +C ... and never with a polarised beam: a row and its C-parity flip carry +C OPPOSITE beam helicities, so after the beam weight they are not equal. + DEDUP = %(so_csym_ntry)s(FLAV_USE).GE.20 .AND. CSYM(FLAV_USE) + & .AND. USERHEL.EQ.-1 .AND. POLARIZATIONS(0,0).EQ.-1%(so_dedup_cross)s + & .AND. ABS(BEAMPOL(1)).LE.1D0 .AND. ABS(BEAMPOL(2)).LE.1D0 +C A crossed flavor evaluates the BASE helicity table, so NHEL(JJ,IHEL) is +C not the crossed beam's helicity: refused rather than guessed, as in +C matrix_standalone_v4.inc. + IF (FLAV_IDX.GT.NFLAV.AND.NINITIAL.EQ.2.AND. + & (ABS(BEAMPOL(1)).GT.1D0.OR.ABS(BEAMPOL(2)).GT.1D0)) THEN + WRITE(*,*) 'ERROR: beam polarisation is not supported for a', + & ' crossed flavor index (FLAV_IDX=',FLAV_IDX,')' + STOP 1 + ENDIF 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 +%(so_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 - CALL %(proc_prefix)sMATRIX(P ,NHEL(1,IHEL),JC(1),FLAV_IDX, T) -C Support for polarised beam, see matrix_standalone_v4.inc +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. +%(so_matrix_call)s +C Scan phase: cache |M|^2 to test the C-parity partner below -- +C BEFORE the beam weight, which makes a row and its flip differ +C by design. + IF (%(so_csym_ntry)s(FLAV_USE).LT.20%(so_dedup_cross)s) THEN + DO I=1,NSQAMPSO + TSTORE(I,IHEL)=T(I) + ENDDO + ENDIF +C Fast phase: the representative carries its skipped partner's +C identical contribution, component by component. DEDUP is off +C whenever a beam is polarised (see its definition). + IF (DEDUP.AND.IHEL.LT.FLIP(IHEL)) THEN + DO I=1,NSQAMPSO + T(I)=T(I)+T(I) + ENDDO + ENDIF +C Support for polarised beam, see matrix_standalone_v4.inc. NHEL(JJ,IHEL) +C is the beam's own helicity only for an uncrossed flavor; a crossed one is +C refused before the helicity loop. IF (NINITIAL.EQ.2) THEN DO JJ=1,NINITIAL IF (ABS(BEAMPOL(JJ)).LE.1D0) CYCLE @@ -244,15 +340,49 @@ C Support for polarised beam, see matrix_standalone_v4.inc ENDIF BUFF=BUFF+T(I) ENDDO - IF (BUFF .NE. 0D0 .AND. .NOT. GOODHEL(IHEL,FLAV_IDX)) THEN - GOODHEL(IHEL,FLAV_IDX)=.TRUE. - ENDIF +%(so_goodhel_train)s ENDIF ENDIF ENDDO +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), +C and because the verdict is all-or-nothing that turns the reuse off for the +C whole flavor. There is no way to reach the fast phase without having run +C this block: the two are gated on the same counter (NTRY<20 here, +C NTRY>=20 for DEDUP), which nothing but this routine advances. + IF (USERHEL.EQ.-1.AND.%(so_csym_ntry)s(FLAV_USE).LT.20 + & .AND.POLARIZATIONS(0,0).EQ.-1%(so_dedup_cross)s) 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. So also require the difference to be +C significant against TSMAX, the largest |M|^2 of this scan point. + TSMAX=0D0 + DO IHEL=1,NCOMB + DO I=1,NSQAMPSO + IF (ABS(TSTORE(I,IHEL)).GT.TSMAX) TSMAX=ABS(TSTORE(I,IHEL)) + ENDDO + 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 + DO I=1,NSQAMPSO + IF (ABS(TSTORE(I,IHEL)-TSTORE(I,FLIP(IHEL))).GT. + & 1D-6*(ABS(TSTORE(I,IHEL))+ABS(TSTORE(I,FLIP(IHEL)))) + & .AND.ABS(TSTORE(I,IHEL)-TSTORE(I,FLIP(IHEL))).GT. + & 1D-12*TSMAX) THEN + CSYM(FLAV_USE)=.FALSE. + ENDIF + ENDDO + ENDIF + ENDDO + ENDIF ANS(0)=0.0d0 +%(so_iden_line)s DO I=1,NSQAMPSO - ANS(I)=ANS(I)/DBLE(IDEN)*%(proc_prefix)sBROKEN_SYM(FLAVOR) IF (CHOSEN_SO_CONFIGS(I)) THEN ANS(0)=ANS(0)+ANS(I) ENDIF @@ -277,6 +407,11 @@ C Support for polarised beam, see matrix_standalone_v4.inc SUBROUTINE %(proc_prefix)sMATRIX(P,NHEL,IC,FLAV_IDX,RES) use model_object 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_SPLITORDERS applies the +C crossing once, before its helicity loop; this routine never decodes +C anything. +C %(info_lines)s C C Returns amplitude squared summed/avg over colors @@ -351,24 +486,73 @@ c write (*,*) " -> col.ave. |M|^2 for HEL=[", NHEL ,"] = ", %(proc_prefix) END - SUBROUTINE %(proc_prefix)sGET_NHEL(IDEN_STAR,NHEL_STAR) + SUBROUTINE %(proc_prefix)sGET_NHEL(IDEN_STAR,NHEL_STAR) C CONSTANTS C CF2PY INTENT(OUT) :: NHEL_STAR CF2PY INTENT(OUT) :: IDEN_STAR - INTEGER NEXTERNAL - PARAMETER (NEXTERNAL=%(nexternal)d) - INTEGER NCOMB - PARAMETER ( NCOMB=%(ncomb)d) - - INTEGER NHEL(NEXTERNAL,NCOMB),NHEL_STAR(NEXTERNAL,NCOMB) - INTEGER IDEN,IDEN_STAR - - %(helicity_lines)s - %(den_factor_line)s - IDEN_STAR = IDEN - NHEL_STAR = NHEL - END + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER NCOMB + PARAMETER ( NCOMB=%(ncomb)d) + + INTEGER NHEL(NEXTERNAL,NCOMB) + COMMON/%(proc_prefix)sPROCESS_NHEL/NHEL + INTEGER NHEL_STAR(NEXTERNAL,NCOMB) + INTEGER IDEN,IDEN_STAR +%(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_SPLITORDERS +C ACTUALLY divides by for FLAV_IDX_IN, rather than the static IDEN. +C +C The static IDEN is the averaging/symmetry factor of the *representative* +C flavor. SMATRIX_SPLITORDERS never uses it bare: it applies +C IDEN/BROKEN_SYM(FLAVOR), BROKEN_SYM correcting the identical-particle +C count of the representative to that of the actual flavor. A caller that +C reads GET_NHEL and multiplies ANS by it to recover the raw helicity/color +C sum -- the natural thing to do -- is therefore wrong for every +C non-representative flavor of a merged matrix element. +C +C GET_NHEL is deliberately left alone: its signature and its value are +C what existing callers expect. +C +%(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 (see the +C guard at the top of SMATRIX_SPLITORDERS); report a 0 denominator rather +C 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) @@ -410,6 +594,9 @@ C COMPLEX*16 DUM0,DUM1 DATA DUM0, DUM1/(0d0, 0d0), (1d0, 0d0)/ double precision bwcutoff + INTEGER NFLAV + PARAMETER (NFLAV=%(nflav)d) + INTEGER AMP_I %(flavor_mask_decl)s C C GLOBAL VARIABLES @@ -419,6 +606,7 @@ C C C bwcutoff=15 ! use if $ syntax is defined in the process +%(so_getamp_guard)s %(flavor_mask_setup)s %(helas_calls)s %(amp2_lines)s @@ -623,6 +811,10 @@ C G = 2* DSQRT(ALPHAS*pi) call UPDATE_AS_PARAM() ENDIF +C The NHEL table is materialized at runtime (see FILL_NHEL), so it has to +C be filled here too: a caller reaching the density matrix before any +C SMATRIX evaluation would otherwise read a table of zeros. + CALL %(proc_prefix)sFILL_NHEL() DO IHEL =1, NB_NHEL THISNHEL(:) = NHEL(:, IHEL) @@ -715,6 +907,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 @@ -736,11 +930,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 @@ -881,6 +1081,78 @@ C ---------- RETURN 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)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 representation, kept for the density-matrix and +C python (f2py) interfaces. +C +C The inverse (code from helicity values) that matrix_standalone_v4.inc +C also carries is deliberately NOT written here: nothing in the tree calls +C it -- the crossing routines that would are not part of this template, and +C they inline their own encode anyway. A split-orders matrix.f is written +C once per MadLoop / FKS born as well as per standalone subprocess, so a +C dead routine there is paid for many times over. + IMPLICIT NONE + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER NCOMB + PARAMETER ( NCOMB=%(ncomb)d) + INTEGER NHEL(NEXTERNAL,NCOMB) + COMMON/%(proc_prefix)sPROCESS_NHEL/NHEL +C A DATA statement may not initialise a COMMON outside a BLOCK DATA, so +C the list is a local DATA here and the table it decodes into is filled on +C the first call -- every reader is ordered behind that call. +C matrix_standalone_v4.inc additionally publishes this list through a +C common of its own, because there the external helicity label IS the +C code; here it is the row number (see SMATRIX_SPLITORDERS), so that +C common would have no reader. + 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 + + +%(so_crossing_routines)s + + %(broken_sym_function)s @@ -888,3 +1160,6 @@ C ---------- %(flavor_array_function)s + + +%(so_pdg_function)s diff --git a/madgraph/iolibs/template_files/matrix_standalone_v4.inc b/madgraph/iolibs/template_files/matrix_standalone_v4.inc index 349bc70060..9ac943985e 100644 --- a/madgraph/iolibs/template_files/matrix_standalone_v4.inc +++ b/madgraph/iolibs/template_files/matrix_standalone_v4.inc @@ -94,10 +94,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), NTRY_CSYM(NFLAV) + LOGICAL CSYM(NFLAV), DEDUP + REAL*8 TSTORE(NCOMB) + REAL*8 TSMAX +%(flip_data)s + DATA CSYM/NNTRY_FLAV*.TRUE./ + DATA NTRY_CSYM/NNTRY_FLAV*0/ C C GLOBAL VARIABLES @@ -109,7 +132,13 @@ 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 Published by FILL_NHEL, which owns the single DATA for this list and is +C called below before the loop that reads it. Kept in a COMMON rather than +C given a second DATA of its own: the table is NCOMB long and grows with +C the multiplicity, and one definition cannot fall out of step with itself. + INTEGER HELCODE(NCOMB) + COMMON/%(proc_prefix)sHELCODE/HELCODE %(den_factor_line)s INTEGER POLARIZATIONS(0:NEXTERNAL,0:5) @@ -127,6 +156,10 @@ 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 @@ -139,19 +172,27 @@ 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() + 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 @@ -165,21 +206,62 @@ 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). +C ... and never with a polarised beam: a row and its C-parity flip carry +C OPPOSITE beam helicities, so once the beam weight below is applied they +C no longer have equal |M|^2 and doubling the representative is wrong. + 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 + & .AND. ABS(BEAMPOL(1)).LE.1D0 .AND. ABS(BEAMPOL(2)).LE.1D0 +C A polarised beam is only defined against the beam's own leg, and a +C crossed flavor evaluates the BASE helicity table (tau: the slots are not +C permuted, the sign rides on IC), so NHEL(JJ,IHEL) below is not the crossed +C beam's helicity. Refused rather than guessed -- the same decision as for +C a polarised leg (breaks_crossing_symmetry) and for beam polarisation in +C the run card (common_run_interface). + IF (FLAV_IDX.GT.NFLAV.AND.NINITIAL.EQ.2.AND. + & (ABS(BEAMPOL(1)).GT.1D0.OR.ABS(BEAMPOL(2)).GT.1D0)) THEN + WRITE(*,*) 'ERROR: beam polarisation is not supported for a', + & ' crossed flavor index (FLAV_IDX=',FLAV_IDX,')' + STOP 1 + 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. 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 (USERHEL.EQ.-1.OR.USERHEL.EQ.HELCODE(IHEL)) 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. Cached BEFORE the beam-polarisation +C weight: the scan compares the bare |M|^2 of a row and its +C flip, which beam polarisation would make differ by design. + 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. DEDUP is off whenever a beam is +C polarised (see its definition), so this never doubles a row +C whose flip carries the opposite beam helicity. + IF (DEDUP.AND.IHEL.LT.FLIP(IHEL)) T=T+T C Support for polarised beam. Same reweighting of the initial-state C helicity sum as madevent's matrix.f and as the v1 MadSpin msP/msF C templates. Inert (and skipped) unless /to_beampol/ has been filled: C |BEAMPOL| runs from 1 (unpolarised) to 2 (fully polarised), so C anything at or below 1 -- including a zero-filled common block -- C means "no polarisation". 1 -> N matrix elements are left alone: their -C leg 1 is a decaying resonance, not a beam. +C leg 1 is a decaying resonance, not a beam. NHEL(JJ,IHEL) is the beam's +C own helicity only for an uncrossed flavor; a crossed one is refused +C before the helicity loop. IF (NINITIAL.EQ.2) THEN DO JJ=1,NINITIAL IF (ABS(BEAMPOL(JJ)).LE.1D0) CYCLE @@ -193,13 +275,43 @@ C leg 1 is a decaying resonance, not a beam. 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 @@ -221,6 +333,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 @@ -293,20 +409,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) @@ -324,6 +510,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 @@ -332,11 +520,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)/ @@ -350,6 +542,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 @@ -489,12 +693,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) @@ -502,7 +747,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) @@ -518,7 +763,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 @@ -530,6 +775,13 @@ C c LOCAL INTEGER I,IHEL,IPART,JJ 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 DOUBLE PRECISION POLFACT C INTEGER NHEL(NEXTERNAL,NB_NHEL) @@ -560,18 +812,46 @@ 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 +C Crossed flavor (FLAV_USE differs from FLAV_IDX) with a polarised beam: +C THISNHEL below is the base helicity table, not the crossed beam's, so the +C beam weight would be read off the wrong leg. Refused, as in SMATRIX. + IF (FLAV_IDX.NE.FLAV_USE.AND.NINITIAL.EQ.2.AND. + & (ABS(BEAMPOL(1)).GT.1D0.OR.ABS(BEAMPOL(2)).GT.1D0)) THEN + WRITE(*,*) 'ERROR: beam polarisation is not supported for a', + & ' crossed flavor index (FLAV_IDX=',FLAV_IDX,')' + STOP 1 + 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) C Support for polarised beam: reweight the initial-state helicity C sum exactly as SMATRIX_PROD does in the v1 path. Skipped unless C the process has two incoming legs -- the same shared library C also holds the 1 -> N decay matrix elements, whose leg 1 is the -C decaying resonance and not a beam. +C decaying resonance and not a beam. THISNHEL(JJ) is the beam's own +C helicity only for an uncrossed flavor; a crossed one is refused +C before this loop (see the beam-polarisation guard above). POLFACT = 1D0 IF (NINITIAL.EQ.2) THEN DO JJ=1,NINITIAL @@ -597,12 +877,45 @@ C in BLOCK DATA BEAMPOL_DEFAULT. 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) @@ -610,7 +923,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 @@ -623,7 +936,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 @@ -632,18 +1035,14 @@ c PARAMETER (NGRAPHS=%(ngraphs)d) INTEGER NCOLOR PARAMETER (NCOLOR=%(ncolor)d) - INTEGER IC(NEXTERNAL) 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) @@ -656,16 +1055,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) @@ -679,6 +1068,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 @@ -848,6 +1238,95 @@ 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) +C The published copy. A DATA statement may not initialise a COMMON outside +C a BLOCK DATA, so the list stays a local DATA here and is copied out on the +C first call -- every reader is ordered behind that call. + INTEGER HELCODE(NCOMB) + COMMON/%(proc_prefix)sHELCODE/HELCODE + INTEGER I, K, THIS(NEXTERNAL) + LOGICAL DONE + SAVE DONE +%(hel_allow_data)s + DATA DONE /.FALSE./ + IF (DONE) RETURN + DO I=1,NCOMB + HELCODE(I) = HELALLOW(I) + 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 @@ -855,3 +1334,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 9353645fbc..cafd11ddd2 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 31c75e8b10..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,21 +761,32 @@ 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. + '''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. - 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): @@ -670,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 @@ -729,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: @@ -738,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: @@ -869,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','')) @@ -960,28 +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) - # A split which leaves nothing but blanks on the current line -- - # the only space is the statement's own indentation, as for the - # space-free Kleiss-Kuijf JAMPF lines -- emits an empty physical - # line, and the continuation which follows it is then attached to - # the *previous* statement. Break mid-token instead. - if split_at <= 0 or not remaining[:split_at+1].strip(): + 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 4979cd666e..3f124dad05 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -4706,7 +4706,10 @@ def default_setup(self): 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 4366806b16..37be463f32 100755 --- a/madgraph/various/misc.py +++ b/madgraph/various/misc.py @@ -2318,9 +2318,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 d8ae233fe4..23c2d475ab 100755 --- a/madgraph/various/process_checks.py +++ b/madgraph/various/process_checks.py @@ -3903,6 +3903,742 @@ 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_fortran' is the fortran default (f2py); 'standalone' is the madmatrix one (names as of PR #64; 'standalone_cpp' is gone) -- +# the C++ / cudacpp-CPU-SIMD standalones. +CROSSING_EXPORTERS = ('standalone_fortran', 'standalone') + +# Vectorisation (SIMD) choices for the standalone (madmatrix) backend; each +# is a madmatrix.mk BACKEND token (SUPPORTED_CPU_BACKENDS, plus 'auto'); the +# makefile rejects anything else, the pre-PR #64 cpp spellings included. 'auto' lets +# madmatrix pick the widest instruction set the host CPU supports. Only used by +# the standalone crossing backend; ignored by the others. +MG7_SIMD_CHOICES = ('auto', 'scalar', 'simd_128', 'simd_256', 'avx512y', 'simd_512') + +# Floating-point precision choices for the standalone (madmatrix) 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 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 + + + +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_fortran' + needs_matrix_element = False + + def __init__(self, options=None): + # options accepted for a uniform backend signature; --simd only applies + # to the madmatrix backend ('standalone'). + 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) + + +# ── cudacpp CPU-SIMD standalone (madmatrix) ───────────────────────────── +# 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 (madmatrix) crossing backend. + + The vectorisation width is selectable via options['simd'] (see + MG7_SIMD_CHOICES): it is passed to the madmatrix build as + 'BACKEND=', 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' + 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; 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; 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=%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_fortran': _FortranCrossingBackend, + 'standalone': _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'`` (madmatrix) has 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_fortran') + 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 2793246d93..8c59821f1b 100644 --- a/madmatrix/model_handling.py +++ b/madmatrix/model_handling.py @@ -2161,6 +2161,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') @@ -2194,11 +2198,20 @@ def get_sigmaKin_lines(self, color_amplitudes, write=True): # 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) + self.read_template_file(self.blas_helicity_loop_template) \ + % replace_dict replace_dict['cpp_blas_helicity_loop_end'] = \ '\n#endif // MGONGPU_CPP_HAS_BLAS' @@ -2219,11 +2232,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 @@ -2382,7 +2403,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): @@ -2404,12 +2434,128 @@ 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 # The build rules live in SubProcesses/; SubProcesses/makefile # itself is the dispatcher that fans out over all the P* directories. # NB: this symlink is overwritten by the 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, "..", self.p_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""" @@ -2625,7 +2771,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={} @@ -2676,6 +2824,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() @@ -2908,6 +3086,698 @@ 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 * static_cast( broken_symmetry_factor( iflavorVec[ievt0] ) )' + ' / static_cast( 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', + } + # Standalone mode: P*/makefile points at the wrapper that also builds check_sa.exe # (see ProcessExporterMadMatrixStandalone in output.py) @@ -3390,13 +4260,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()): @@ -3605,13 +4475,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 0e2e4347f9..12b3066dcd 100644 --- a/madmatrix/output.py +++ b/madmatrix/output.py @@ -82,6 +82,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 diff --git a/models/template_files/fortran/makefile_madevent b/models/template_files/fortran/makefile_madevent index b6991afbaa..15131c90c3 100644 --- a/models/template_files/fortran/makefile_madevent +++ b/models/template_files/fortran/makefile_madevent @@ -61,6 +61,12 @@ ldme.inc: onia_read.inc: touch onia_read.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 df720b03a7..d2f5dcd319 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 @@ -237,6 +238,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, @@ -627,7 +629,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 """ @@ -817,7 +821,12 @@ 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, which a crossing-on + # output would fold into a base directory. Crossing is off by default, + # so this only states the choice explicitly; the folded matrix element + # is checked by the crossing and consistency suites. + self.do('generate p p > w+ w- j j QCD=0 --use_crossing=False') self.do('output standalone_fortran %s ' % self.out_dir) sub_root = os.path.join(self.out_dir, 'SubProcesses') @@ -857,10 +866,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 @@ -874,9 +884,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( @@ -939,6 +963,75 @@ 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 (--use_crossing=True) counterpart of the tests below. + + test_standalone_flavor_mask and test_standalone_wwjj both pass + --use_crossing=False because they open one specific subprocess directory, + which a 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_fortran %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) + + # Both states are pinned: crossing is OFF by default (madspace does + # not support it yet), and this test is precisely about the + # difference between the two, so neither arm may inherit it. + crossed = build('--use_crossing=True', '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. @@ -963,7 +1056,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): @@ -1332,6 +1430,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 (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 %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 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_split_orders_interference(self): """standalone (madmatrix) must return the squared-order contribution asked for. @@ -1437,7 +1639,11 @@ 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: the gluino exchanged in the t/u channels carries no + # width -- it is an external field (zerowidth_external) and its momentum + # is spacelike (zerowidth_tchannel), either rule alone drops it. With + # both set to False this reads 6.4739191, the value that keeps it. + self.assertAlmostEqual(float(me_groups.group('value')), 6.4739329,5) # Cross-check standalone (madmatrix) against standalone_fortran for # this massive BSM process. The Fortran ./check auto-bumps the CM energy @@ -1467,7 +1673,8 @@ def test_standalone_cpp(self): f_default = me_re.search(open(f_log).read()) self.assertTrue(f_default, 'standalone_fortran produced no matrix element') - self.assertAlmostEqual(float(f_default.group('value')), 6.4739191, 5) + # same value as the C++ one above, gluino propagator width dropped + self.assertAlmostEqual(float(f_default.group('value')), 6.4739329, 5) # Reference value at the explicit above-threshold energy. f_e_log = os.path.join(f_dir, 'check_e.log') subprocess.call('./check %s' % energy, @@ -2075,7 +2282,10 @@ def test_quarkonium_standalone(self): ['g g > chic1(1|3P11) chib0(1|3P01)', 3.132275172481691e-16], ['g g > hc(1|1P11) g', 2.3637208371566567e-12], ['u u~ > a Jpsi(1|3P08) QCD=99 QED=99', 3.6650612158421924e-11], - ['u a > Upsilon(1|3S11) u chib2(1|3P21) QCD=99 QED=99', 1.819597262304262e-20], + # a t-channel Z: its width is dropped where the momentum is + # spacelike (zerowidth_tchannel, the default); keeping it gives + # 1.819597262304262e-20 + ['u a > Upsilon(1|3S11) u chib2(1|3P21) QCD=99 QED=99', 1.81950515062415e-20], ] for process in process_list: mg_cmd.exec_cmd('generate %s ' % process[0]) @@ -2168,7 +2378,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) @@ -3110,87 +3331,120 @@ def test_density_mode_user_interface(self): self.assertAlmostEqual(rho_avg[i][j].imag, rho_avg_ref[i][j].imag, places=3, msg=msg) - 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','madgraph'), - '/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}") - - # previously PDF was nn23lo1 (lhaid 230000) with this reference matrix - # [[0.3670142422790588, 1.7429098337870793e-07-3.933851109770078e-05j, ...], - # ... diag(0.36701424, 0.13298576, 0.13298576, 0.36701424), off-diag 0.11514190 / 0.06344293] - rho_avg_ref = [[(0.3688357054745634+0j), (2.488456321669277e-07+8.149451446891586e-05j), (-2.488456322029901e-07-8.149451420327119e-05j), (0.1177535354898135-0j)], - [(2.488456321669277e-07-8.149451446891586e-05j), (0.13116429452559822+0j), (0.0635907988356563-0j), (-2.488456322029923e-07+8.149451420327103e-05j)], - [(-2.488456322029901e-07+8.149451420327119e-05j), (0.0635907988356563+0j), (0.13116429452559822+0j), (2.488456321669272e-07-8.149451446891567e-05j)], - [(0.1177535354898135+0j), (-2.488456322029923e-07-8.149451420327103e-05j), (2.488456321669272e-07+8.149451446891567e-05j), (0.3688357054745633+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 - - - # On a mismatch print the whole measured matrix, not just the first - # element that differs: re-referencing this (a PDF change moves every - # entry) otherwise needs one run per element. - msg = 'measured rho_avg = %r' % (rho_avg,) - 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, msg=msg) #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, msg=msg) + logfile = pjoin(self.tmpdir, 'test_density_mode_multicore_generation.log') + subprocess.call([sys.executable, pjoin(MG5DIR, 'bin', 'madgraph'), + 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): @@ -3244,9 +3498,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)): @@ -3256,17 +3508,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) @@ -3427,10 +3679,13 @@ def test_density_mode_decay1(self): # legs it names: it used to count LHE lines, status-2 ones included, so # the status-2 top of this decay chain shifted it onto t (the resonance # line) + W+ instead of b + t~. The previous numbers were in that frame. - density_ref = [(0.0025697944663450214+0j), (0.00022583206322766304+0.0002724023671240153j), (0.03458000936877068-0.003111931365020864j), - (0.003535839291923179+0.0031321719755733556j), (0.002495767955975634+0j), (0.002308240777864438-0.002551169179285921j), - (0.03333888343971259-0.0016718211497894133j), (0.5116911466377606+0j), (0.04414462084336885+0.031496541741920014j), - (0.48324329093991875+0j)] + # and again by the crossing branch: the internal top propagator loses + # its width (it is an external field there, zerowidth_external) and the + # canonical NHEL encoder removed a small C-parity asymmetry. + density_ref = [(0.002572819258503627+0j), (0.00022287625051417933+0.000269373544283043j), (0.034620381554042186-0.0031148240475356633j), + (0.003493349987547038+0.0030943536658021155j), (0.002492794223097223+0j), (0.0022714731159374324-0.0025077711911968975j), + (0.03329882105505325-0.001669052089828375j), (0.5122804070927276+0j), (0.043564252872336236+0.03090169904947277j), + (0.48265397942567173+0j)] event_of_reference = """ @@ -3458,12 +3713,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.5059543230761125 + purity_ref = 0.505810869888376 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.045797020165311494 + magic_ref = 0.04539434702919854 magic_check = rho_instance.Magic_Mixed() self.assertAlmostEqual(magic_ref, magic_check, places=7) @@ -3525,13 +3780,16 @@ def test_density_mode_decay2(self): # legs it names: it used to count LHE lines, status-2 ones included, so # the status-2 top of this decay chain shifted it onto b + W+ instead of # W+ + t~. The previous numbers were in that frame. - density_ref = [(0.03463053018280333+0j), (0.004239768679509782-6.107094544726455e-06j), (-0.09316199935947835-0.0642487039633136j), - (-0.012210969227695868-0.007878922646928346j), (-0.002124188268916235-0.02297486682150409j), (0.003995508129090404+0.0004157871358009479j), - (0.022117113597568305+0j), (-0.00501677525377969-0.007670018167320941j), (-0.05782633417873876-0.041591356606323786j), - (-0.0345671307549972-0.028610030448355993j), (-0.012725299997169235-0.018055903954330738j), (0.37490761451895765+0j), - (0.029901925820799556-0.012796932628922494j), (0.034730980413616615+0.048482547570504436j), (-0.010620049324088833+0.0038059883057455558j), - (0.23499754842424148+0j), (0.1492214808480949+0.014281754024531749j), (0.04586173454409796+0.00814618965628759j), - (0.1307089935801734+0j), (0.003896314151841825+0.020473298367979682j), (0.20263819969625585+0j)] + # and again by the crossing branch: the internal top propagator loses + # its width (it is an external field there, zerowidth_external) and the + # canonical NHEL encoder removed a small C-parity asymmetry. + density_ref = [(0.03462856093584298+0j), (0.004222692552048597-3.914851804525198e-06j), (-0.09315136922763956-0.06424541106056061j), + (-0.012161343076623666-0.007852907982114157j), (-0.0021548339817954705-0.02299216606257348j), (0.004000989959849888+0.0004271687621753295j), + (0.022124992142446936+0j), (-0.004976106543712062-0.007632363502328344j), (-0.05784211938393767-0.041606353301745905j), + (-0.034560622408768275-0.028594347672533223j), (-0.012757048104007053-0.018079926753134273j), (0.374857868204696+0j), + (0.029720084211203073-0.012771984245079535j), (0.03485290974186369+0.0484732992793134j), (-0.01064461469034376+0.0037920989793522506j), + (0.2350541853144974+0j), (0.14918389804980686+0.014260521941388894j), (0.045994627672498976+0.008149821823945263j), + (0.1306812643391364+0j), (0.0037983745012122867+0.02048399076640624j), (0.20265312906338015+0j)] #1) here we check that the density matrix is computed properly @@ -3542,14 +3800,16 @@ 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, [0.00013081840995776112, 0.00023375814738743806, 0.10060517756525891, 0.1278032913859491, 0.25544825447210895, 0.5157787000193378] + flag_ref, eigval_ref = False, [1.30947427e-04, 2.33695634e-04, + 1.00468437e-01, 1.27838949e-01, + 2.55651384e-01, 5.15676588e-01] 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.3577366329048327 + purity_ref = 0.35771674847320956 purity_check = rho_instance.Get_Purity() self.assertAlmostEqual(purity_ref, purity_check, places=7) @@ -3629,10 +3889,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): @@ -3646,17 +3903,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) @@ -3977,10 +4234,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 @@ -4004,8 +4261,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) @@ -4026,10 +4287,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 @@ -4060,8 +4321,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) @@ -4113,8 +4378,12 @@ def check_aloha_file(self): TMP5 = (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 = 1D0/(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 = 1D0/(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2 - M3 * (M3 + $ -CI* W3)) + ELSE + DENOM = 1D0/(P3(0)**2-P3(1)**2-P3(2)**2-P3(3)**2 - M3**2) + ENDIF V3%W(1)= DENOM*(-2D0 * CI)*(COUP2*(OM3*-1D0/2D0 * P3(0)*(TMP2 $ +2D0*(TMP5))+(+1D0/2D0*(F1 % W(1)*F2 % W(3)+F1 % W(2)*F2 % W(4)) $ +F1 % W(3)*F2 % W(1)+F1 % W(4)*F2 % W(2)))+1D0/2D0*(COUP1*(F1 % diff --git a/tests/acceptance_tests/test_cmd_reweight.py b/tests/acceptance_tests/test_cmd_reweight.py index 7dd01d3068..ad6feed45d 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..3c0041f8d5 --- /dev/null +++ b/tests/acceptance_tests/test_standalone_cross_symmetry.py @@ -0,0 +1,4277 @@ +################################################################################ +# +# Copyright (c) 2009 The MadGraph7 Development team and Contributors +# +# This file is a part of the MadGraph7 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 MadGraph7 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' +# ... and its crossing partner under the same constraint, so a whole merged +# split-orders table can be swept through the crossing (see +# test_split_orders_merged_flavor_crossing_every_flavor). +PROC_MERGED_QQX_GQQX_SO = '_quark _anti_quark > g _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 + # TAU, not sigma: the matrix element sign-flips the helicity IN PLACE and + # does not permute the slots (APPLY_CROSSING_TABLE), so the map relating a + # crossed row to its identity row is ic[k]*nhel[k,h) -- no perm[] indexing. + # This is the same map the recycled optim and the madmatrix lanes realise, + # which is the point: one good-helicity relation now describes every + # backend. tau is a clean bijection (each leg's states are closed under + # negation), so this stays an EQUALITY rather than a containment. + tau = {} + for h in range(ncomb): + cfg = tuple(ic[k] * nhel[k, h] for k in range(nexternal)) + hp = row_of.get(cfg) + assert hp is not None, 'cross %%d: tau is not a row bijection' %% cross + tau[h + 1] = hp + expected = {tau[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 != tau(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)) +''' + + + +def _pin_crossing(options, on=True): + """Return `options` with the crossing choice stated explicitly. + + Crossing is OFF by default in MG5 (madspace does not support it yet). This + suite is *about* crossing, so nothing in it may lean on the shipped default + in either direction: a caller that already passed --use_crossing=... keeps + its choice, everyone else gets it pinned here. Flipping the product default + must never silently turn one of these tests into a test of the other mode. + """ + if '--use_crossing' in options: + return options + return ('%s --use_crossing=%s' % (options, on)).strip() + +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, _pin_crossing(options))).strip()) + self.cmd.exec_cmd('output standalone_fortran %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_split_orders_merged_flavor_crossing_every_flavor(self): + """The merged-flavor crossing sweep, on the split-orders template. + + The twin of test_merged_flavor_crossing_every_flavor, and here for the + same reason as test_split_orders_density_diagonal_matches_smatrix: + matrix_standalone_splitOrders_v4.inc is a SEPARATE template with its own + SMATRIX, so making the default one cross correctly says nothing about + it. It carried no crossing machinery at all until + fill_crossing_replace_dict_so, while the generator happily folded the + crossed subprocesses onto their base -- 50 of the 65 flavor columns of + `p p > j j QCD^2==4` had no entry point left, and the extended FLAV_IDX + that names them returned 0 in silence. + + Sweeping the whole merged table is what makes this bite: the crossed + denominator is rebuilt per flavor (GET_SPINCOL_CROSS * + GET_IDENT_CROSS), so a denominator taken from the representative flavor + instead of the actual one shows up as a clean factor 2 on the rows + where the two final quarks differ. + """ + merged_a = self._generate(PROC_MERGED_QQX_GQQX_SO, 'Proc_so_cross_a', + split_orders=True) + merged_b = self._generate(PROC_MERGED_QG_QQQX_SO, 'Proc_so_cross_b', + split_orders=True) + # Guard the premise twice over: the split-orders template, and the + # crossing machinery actually written into it. + code_a = self._matrix_code(merged_a) + self.assertIn('SMATRIX_SPLITORDERS', code_a, + 'Expected %s to use the split-orders template; this test ' + 'would otherwise just retest the default one' + % PROC_MERGED_QQX_GQQX_SO) + self.assertIn('GET_CROSS_PERM', code_a, + 'The split-orders matrix.f carries no crossing ' + 'machinery, so an extended FLAV_IDX cannot be decoded ' + 'and every assertion below would compare zeros') + nflav_a = self._read_nflav(merged_a) + self.assertGreater(nflav_a, 1, + 'Expected a merged multi-flavor matrix element, got ' + 'NFLAV=%s' % nflav_a) + momenta = self._phase_space_2to3() + + unmapped = [] + checked = 0 + 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) + self.assertNotEqual( + crossed_value, 0.0, + 'Crossed flavor %s evaluated to exactly zero, which is what ' + 'a matrix element with no crossing decoder returns for an ' + 'extended FLAV_IDX' % flav) + scale = max(abs(crossed_value), abs(reference), 1e-99) + self.assertLessEqual( + abs(crossed_value - reference) / scale, self.tolerance, + 'split-orders 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)) + checked += 1 + + self.assertFalse(unmapped, + 'Crossed flavors with no counterpart in %s: %s' + % (PROC_MERGED_QG_QQQX_SO, unmapped)) + self.assertGreater(checked, 1, + 'Only %s flavor compared; the sweep is the point' + % checked) + + 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 TAU, the sign-only map every backend can realise (the invariant + the shared good-helicity filter 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 --use_crossing=True' % process) + self.cmd.exec_cmd('output standalone_fortran %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_fortran'): + 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_fortran': + 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_mg7(self): + """standalone (madmatrix) 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') + 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 madmatrix --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', '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, _pin_crossing(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_fortran', 'standalone'): + 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_crossing_breaking_process_keeps_every_subprocess(self): + """A process the exporter will not cross must not be folded either. + + A polarized leg breaks crossing symmetry (export_v4 + .breaks_crossing_symmetry), so the fortran standalone writes no + crossing machinery for it. Its crossings used to be recorded at + generation all the same: p p > z{0} j came out as the single g q + directory, the q q~ and g q~ subprocesses reachable from nowhere. The + same holds for a polarized leg inside a decay chain. Both must now come + out exactly as an uncrossed generation does. + """ + for proc in ('p p > z{0} j', 'p p > z j, z > e+{L} e-'): + with self.subTest(process=proc): + tag = 'pol_dc' if ',' in proc else 'pol' + on = self._output('standalone_fortran', '%s_on' % tag, + process=proc) + off = self._output('standalone_fortran', '%s_off' % tag, + process=proc, + options='--use_crossing=False') + subs_on = self._subprocesses(on) + self.assertEqual(subs_on, self._subprocesses(off)) + # Guard the guard: both sides would agree if both had lost + # the crossed subprocesses. + self.assertGreater(len(subs_on), 1, + 'expected several subprocesses, got %s' + % subs_on) + + 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_fortran', '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 TestStandaloneMg7CrossSymmetry(unittest.TestCase): + """standalone (madmatrix) must reproduce the crossing. + + The crossing reproduction test 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_madmatrix(self, process, name, options='', + out_options='', color_basis=None): + """Write the standalone (madmatrix) 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, _pin_crossing(options))).strip()) + cmd.exec_cmd(('output standalone %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 --use_crossing=True') + cmd.exec_cmd('output standalone %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_madmatrix(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_madmatrix(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_madmatrix(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_madmatrix(PROC_QQ_GG, 'qqgg_on') + off_dir = self._output_madmatrix(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_madmatrix(PROC_QQ_GG, 'qqgg_genoff', + options='--use_crossing=False') + out_dir = self._output_madmatrix(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_madmatrix(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 --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') + 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 --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') + 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 --use_crossing=True' % 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 --use_crossing=True\n' + 'output madevent %s -f\n' + 'launch\n' + 'set nevents 1000\n' + 'set iseed 777\n' % outdir) + subprocess.call([sys.executable, pjoin(MG5DIR, 'bin', 'madgraph'), 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' % (_pin_crossing(options), outdir)) + subprocess.call([sys.executable, pjoin(MG5DIR, 'bin', 'madgraph'), 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, _pin_crossing(options), outdir, + self.NEVENTS, self.SEED)) + subprocess.call([sys.executable, pjoin(MG5DIR, 'bin', 'madgraph'), card]) + results = pjoin(outdir, 'SubProcesses', 'results.dat') + self.assertTrue( + os.path.isfile(results), + 'madevent produced no results for %s (%s)' + % (options or 'the pinned (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 --use_crossing=True' % 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 --use_crossing=True' % 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~ --use_crossing=True\n' + 'output madevent %s -f\n' + 'launch\n' + 'set nevents 2000\n' + 'set iseed 909\n' % outdir) + subprocess.call([sys.executable, pjoin(MG5DIR, 'bin', 'madgraph'), 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, _pin_crossing(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', 'madgraph'), 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, _pin_crossing(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', 'madgraph'), 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' % _pin_crossing(options), + 'add process u u~ > g g %s\n' % _pin_crossing(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', 'madgraph'), 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..a04abc1078 --- /dev/null +++ b/tests/acceptance_tests/test_standalone_hel_recycling.py @@ -0,0 +1,264 @@ +################################################################################ +# +# Copyright (c) 2009 The MadGraph7 Development team and Contributors +# +# This file is a part of the MadGraph7 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 MadGraph7 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_fortran --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_fortran %s -f' % self.std_dir) + self.do('output standalone_fortran %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 2879fc16e7..3966e9aca6 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,113 @@ 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 (the madmatrix 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_fortran %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')) + # -- Reference: plain fortran standalone (crossing machinery off) ------- + self.do('generate %s --use_crossing=False' % process) + generated_process = self.cmd._curr_amps[0].get('process') - standalone_rows, printed_phase_space = self._run_standalone(standalone_dir) seeded_phase_space = self._get_seeded_phase_space(generated_process) + + ref_root = pjoin(self.tmpdir, 'standalone_plain') + self.do('output standalone_fortran %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 --use_crossing=True' % process) + sacross_root = pjoin(self.tmpdir, 'standalone_crossing') + self.do('output standalone_fortran %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 --use_crossing=True' % 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 (madmatrix 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', 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 +225,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 (madmatrix) 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_madmatrix') + self.do('generate %s --use_crossing=True' % process) + try: + self.do('output standalone %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 (madmatrix) 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 +356,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 +400,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 +460,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/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 3e9698412d..b54ce8045b 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 LOGICAL READPS C @@ -141,9 +154,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) @@ -151,6 +164,8 @@ PROGRAM DRIVER write (*,*) "-----------------------------------------------------------------------------" enddo + + if (.false.)then do I=1, MAXFLAVOR write (*,*) "==== density matrix for flavor", I, @@ -183,7 +198,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) @@ -222,8 +237,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 c16268d8a1..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 MadGraph7 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%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/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 2f145b5d10..dab80534f4 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 5e1846db9f..4f1dcc1ff7 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 d68b9f95b6..495df2d4e6 100755 --- a/tests/parallel_tests/test_aloha.py +++ b/tests/parallel_tests/test_aloha.py @@ -4052,7 +4052,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 @@ -4172,7 +4175,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]))))) @@ -4204,7 +4210,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]))))) @@ -4232,7 +4241,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]))))) @@ -4282,7 +4294,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)) @@ -4316,7 +4331,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)) @@ -4350,7 +4368,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_diagram_generation.py b/tests/unit_tests/core/test_diagram_generation.py index 95b54b936f..00ae400603 100755 --- a/tests/unit_tests/core/test_diagram_generation.py +++ b/tests/unit_tests/core/test_diagram_generation.py @@ -57,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) @@ -128,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)) 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 f6ebfa14c4..0264c98d8c 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 @@ -922,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') @@ -943,10 +946,12 @@ class DDMColorFlowMG7Test(unittest.TestCase): 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): + 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.""" + 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') @@ -956,7 +961,7 @@ def get_exporter(self, ids, ddm): amplitude = diagram_generation.Amplitude( base_objects.Process({'legs': legs, 'model': model})) matrix_element = helas_objects.HelasMatrixElement(amplitude) - return export_mg7.OneProcessExporterMG7( + return cls( matrix_element, helas_call_writer.CPPUFOHelasCallWriter(model)) finally: color_amp.set_ddm_basis(False) @@ -984,3 +989,32 @@ def test_ddm_active_colors_index_the_color_flows(self): 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 dbd7b1ca39..2a19f8170e 100644 --- a/tests/unit_tests/iolibs/test_export_v4.py +++ b/tests/unit_tests/iolibs/test_export_v4.py @@ -212,8 +212,101 @@ def read(*parts): '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. + 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'): + 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) + + def test_splitorders_template_carries_the_standalone_api(self): + """Which parts of matrix_standalone_v4.inc the split-orders template + deliberately does and does not carry. + + The two drifted apart, and the point of pinning it here is that a + reader can tell an intended difference from an accident. + """ + sa = export_v4.ProcessExporterFortranSA() + process = self.mymatrixelement.get('processes')[0] + saved = process.get('split_orders') + try: + process.set('split_orders', ['QCD', 'QED']) + self.assertEqual('matrix_standalone_splitOrders_v4.inc', + sa.get_matrix_template(self.mymatrixelement)) + + # Carried: the canonical helicity table, the C-parity + # de-duplication, the flavor-aware denominator accessor, and -- + # through holes of its own, filled by + # fill_crossing_replace_dict_so -- the crossing machinery. + for marker in ('DECODE_HEL', 'FILL_NHEL', '%(hel_allow_data)s', + '%(flip_data)s', 'GET_NHEL_IDX', 'GET_DENSITY', + 'GET_ALL_INTER', '%(so_crossing_routines)s', + '%(so_pdg_function)s', '%(so_cross_decode)s'): + self.assertTrue( + sa.matrix_template_provides(self.mymatrixelement, marker), + '%s missing from the split-orders template' % marker) + + # ... so the crossing demonstration check_sa.f writes must be + # emitted against it: it calls GET_PDG_FOR_FLAVOR, which this + # template reaches through so_pdg_function rather than the default + # template's hole, and asking for the wrong hole name is how that + # block went missing from a folded output that could run it. + self.assertTrue( + sa.matrix_template_has_pdg_decoder(self.mymatrixelement)) + + # Left out on purpose. The _IDX / _CROSSED / RESCALE density stack + # takes a crossing-carrying index the FLAVOR-array entry points + # cannot express -- the density here stays uncrossed. HELCODE + # because the external helicity label is the row number, which is + # what MadLoop passes. ENCODE_HEL because nothing anywhere calls + # it. The default template's own crossing holes because this one + # has its own set, shaped for a vector ANS/T. + for marker in ('ENCODE_HEL', 'HELCODE', 'GET_DENSITY_IDX', + 'GET_ALL_INTER_IDX', 'GET_ALL_INTER_CROSSED', + 'GET_INTER_RESCALE', '%(flavor_pdg_function)s', + '%(crossing_routines)s'): + self.assertFalse( + sa.matrix_template_provides(self.mymatrixelement, marker), + '%s unexpectedly in the split-orders template' % marker) + finally: + process.set('split_orders', saved) + - @IOTests.createIOTest() + @IOTests.createIOTest() def testIO_export_matrix_element_v4_standalone(self): """target: matrix.f """ @@ -3682,7 +3775,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() @@ -3691,18 +3788,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) @@ -10132,8 +10238,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) @@ -10141,8 +10251,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): diff --git a/tests/unit_tests/madevent/test_hel_recycle.py b/tests/unit_tests/madevent/test_hel_recycle.py index 06d8edc59a..e36fac1aaa 100644 --- a/tests/unit_tests/madevent/test_hel_recycle.py +++ b/tests/unit_tests/madevent/test_hel_recycle.py @@ -12,14 +12,140 @@ # For more information, please visit: http://madgraph.phys.ucl.ac.be # ################################################################################ -""" Fixed-form line wrapping of the helicity recycled matrix element """ +"""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. diff --git a/tests/unit_tests/madspin/test_madspin.py b/tests/unit_tests/madspin/test_madspin.py index 40cacf1c09..a82d3d964b 100755 --- a/tests/unit_tests/madspin/test_madspin.py +++ b/tests/unit_tests/madspin/test_madspin.py @@ -12821,6 +12821,36 @@ def test_several_pdgs_multiply(self): self.assertEqual(self.factor(production, decays), 0.25) +class TestWithoutCrossing(unittest.TestCase): + """MadSpin replays the proc card's generate lines, so a --use_crossing=True + there would fold the crossed subprocesses onto their base, where MadSpin's + per-flavor lookup cannot reach them. without_crossing pins it off.""" + + def test_replayed_flag_is_overridden(self): + line = ('generate p p > w+* j --use_crossing=True ;' + 'output standalone_fortran X --prefix=int --density=1 ') + self.assertEqual(madspin.without_crossing(line), + 'generate p p > w+* j --use_crossing=False;' + 'output standalone_fortran X --prefix=int --density=1 ') + + def test_every_process_line_is_pinned(self): + line = ('generate t* > b w+ @0 --no_warning=duplicate;' + 'add process p p > t t~, (t > b w+, w+ > e+ ve) --use_crossing;' + 'output standalone_fortran Y -f') + pinned = madspin.without_crossing(line).split(';') + self.assertEqual(pinned[0], 'generate t* > b w+ @0 ' + '--no_warning=duplicate --use_crossing=False') + self.assertEqual(pinned[1], + 'add process p p > t t~, (t > b w+, w+ > e+ ve) ' + '--use_crossing=False') + # the output line is not a generation and is left as it is + self.assertEqual(pinned[2], 'output standalone_fortran Y -f') + + def test_perturbative_line_is_left_alone(self): + line = 'add process p p > t t~ [QCD] ;' + self.assertEqual(madspin.without_crossing(line), line) + + class _DensityBasisModelStub(object): """The two things _density_basis asks the model for: a particle's spin, and name2pdg (through _pure_interference).""" 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..a9f4f9f990 --- /dev/null +++ b/tests/unit_tests/various/test_reweight_interface.py @@ -0,0 +1,115 @@ +################################################################################ +# +# Copyright (c) 2009 The MadGraph7 Development team and Contributors +# +# This file is a part of the MadGraph7 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 MadGraph7 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()