From 7df832bb8f9196912ec37ed1745f89ccb6fc32a0 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Tue, 21 Jul 2026 16:03:45 +0200 Subject: [PATCH 01/43] add chek crossing functionality (with f2py call to fortran) --- madgraph/interface/madgraph_interface.py | 68 +- madgraph/interface/reweight_interface.py | 11 +- madgraph/iolibs/export_v4.py | 671 +++++++++++++++++- madgraph/iolibs/helas_call_writers.py | 33 +- .../template_files/f2py_flavor_dispatch.py | 113 +++ .../template_files/matrix_standalone_f2py.inc | 2 + .../matrix_standalone_splitOrders_v4.inc | 8 + .../template_files/matrix_standalone_v4.inc | 351 ++++++++- madgraph/various/process_checks.py | 445 ++++++++++++ 9 files changed, 1642 insertions(+), 60 deletions(-) diff --git a/madgraph/interface/madgraph_interface.py b/madgraph/interface/madgraph_interface.py index d6320fef5..abf742f1a 100755 --- a/madgraph/interface/madgraph_interface.py +++ b/madgraph/interface/madgraph_interface.py @@ -565,6 +565,13 @@ 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 fortran standalone twice, with the") + logger.info(" crossing symmetry on (--use_crossing=True) and off, then") + logger.info(" compare each subprocess evaluated through the extended") + logger.info(" FLAV_IDX crossing against its independent value.") + logger.info(" Requires gfortran and a working f2py (numpy) toolchain.") + logger.info(" Example: check crossing g u > g u",'$MG:color:GREEN') logger.info("o cms:",'$MG:color:GREEN') logger.info(" Check the complex mass scheme consistency by comparing") logger.info(" it to the narrow width approximation in the off-shell") @@ -2293,7 +2300,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']) @@ -3079,7 +3087,8 @@ class MadGraphCmd(HelpToCmd, CheckValidForCmd, CompleteForCmd, CmdExtended): _tutorial_opts = ['aMCatNLO', 'stop', 'MadLoop', 'MadGraph5'] _switch_opts = ['mg5','aMC@NLO','ML5'] _check_opts = ['full', 'timing', 'stability', 'profile', 'permutation', - 'gauge','lorentz', 'brs', 'cms', 'flavor', 'language'] + 'gauge','lorentz', 'brs', 'cms', 'flavor', 'language', + 'crossing'] _import_formats = ['model_v4', 'model', 'proc_v4', 'command', 'banner'] _install_opts = ['Delphes', 'MadAnalysis4', 'ExRootAnalysis', 'update', 'Golem95', 'QCDLoop', 'maddm', 'maddump', @@ -3213,6 +3222,8 @@ class MadGraphCmd(HelpToCmd, CheckValidForCmd, CompleteForCmd, CmdExtended): _curr_helas_model = None _curr_exporter = None _second_exporter = None + # UI flag --use_crossing (default on); see do_add. + _use_crossing = True _done_export = False _curr_decaymodel = None @@ -3335,20 +3346,41 @@ def do_add(self, line): 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 used by default. --use_crossing (bare) or + # --use_crossing=True keep it on, --use_crossing=False turns it off. + # --standalone does not affect it. + use_crossing = True + for arg in args[:]: + if arg == '--use_crossing': + use_crossing = True + args.remove(arg) + elif arg.startswith('--use_crossing='): + value = arg.split('=', 1)[1] + if value.lower() in ['true', 't', '1', 'yes', 'on']: + use_crossing = True + elif value.lower() in ['false', 'f', '0', 'no', 'off']: + use_crossing = False + else: + raise self.InvalidCmd('--use_crossing expects True or ' + 'False, got \'%s\'' % value) + args.remove(arg) + # Internally the switch is inverted: merge_crossing=True means "do not + # reuse/generate the crossed subprocesses". + merge_crossing = not use_crossing # Check the validity of the arguments self.check_add(args) if args[0] == 'model': return self.add_model(args[1:]) - + + # Remember the choice for the exporter: the crossing machinery is only + # written out in the fortran standalone if every process of the current + # generation asked for it (reset by clean_process/do_generate). + self._use_crossing = self._use_crossing and use_crossing + # special option for 1->N to avoid generation of kinematically forbidden #decay. if args[-1].startswith('--optimize'): @@ -4525,6 +4557,22 @@ 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 + crossing_result = process_checks.check_crossing( + myprocdef, param_card=param_card, options=options, cmd=self) + text = 'Crossing symmetry check (crossing on vs off):\n' + 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 "+ @@ -4968,6 +5016,8 @@ 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) + self._use_crossing = True # Reset _done_export, since we have new process self._done_export = False # Also reset _export_format and _export_dir diff --git a/madgraph/interface/reweight_interface.py b/madgraph/interface/reweight_interface.py index a2ddef2c4..7d3bbd087 100755 --- a/madgraph/interface/reweight_interface.py +++ b/madgraph/interface/reweight_interface.py @@ -1907,15 +1907,16 @@ 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 + # --use_crossing=False 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. + # id_to_path. Only emit it 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', + 'add process --use_crossing=False') commandline = commandline.replace('add process', 'generate',1) logger.info(commandline) try: @@ -2158,7 +2159,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 ------------------------------------------------ diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 3b6dd3b24..a8208dda7 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -187,6 +187,12 @@ class ProcessExporterFortran(VirtualExporter): jamp_optim = False 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""" @@ -194,12 +200,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() @@ -398,6 +405,83 @@ 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') + leg_ids = [leg.get('id') for leg in process.get('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. @@ -540,6 +624,46 @@ 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 #=========================================================================== @@ -931,6 +1055,31 @@ def write_matrix_element_v4(self): """ pass + def _check_crossing_support(self): + """Refuse to export when a crossing was asked for and cannot be given. + + `--use_crossing` (on by default) tells the generation not to write out + the crossed subprocesses separately, because the matrix element is + expected to reach them through an extended FLAV_IDX instead. Only the + fortran standalone implements that decoding: any other exporter would + write a matrix element that silently misses those subprocesses, so it + has to error out and name the way to get a valid output back. + """ + + if self.supports_crossing: + return + if not self.opt.get('use_crossing', False): + return + + raise InvalidCmd( + "The '%s' output does not support crossing symmetry, which the " + "process was generated with. Crossing symmetry is only implemented " + "for the fortran standalone output; every other output needs the " + "crossed subprocesses to be generated explicitly.\n" + "Regenerate the process with --use_crossing=False (e.g. " + "'generate --use_crossing=False') and run the output " + "again." % self.opt.get('export_format', 'unknown')) + def _configure_flavor_mask_from_cmd_options(self): """Honor `--mask=True|False` from the output command line.""" @@ -2062,6 +2211,437 @@ def get_den_factor_line(self, matrix_element): return "DATA IDEN/%2r/" % \ matrix_element.get_denominator_factor() + @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. + """ + if process.get('required_s_channels') or \ + process.get('forbidden_s_channels'): + return True + return any(ProcessExporterFortran.breaks_crossing_symmetry(decay) + for decay in process.get('decay_chains')) + + def fill_crossing_replace_dict(self, matrix_element, replace_dict, + use_crossing): + """Fill the crossing-machinery holes of matrix_standalone_v4.inc. + + The extended FLAV_IDX (a flavor *and* a crossing) and everything + decoding it are only written out when the process was generated with + --use_crossing=True (the default) *and* the process definition pins no + specific s-channel (see breaks_crossing_symmetry). Otherwise the + crossed subprocesses are generated as separate matrix elements instead, + so the crossing machinery would be dead code: the tables, the + APPLY_CROSSING/GET_CROSS_PERM/GET_SPINCOL_CROSS/GET_IDENT_CROSS routines + are not emitted at all and each hole below gets the plain code path + (FLAV_IDX is then a bare flavor index in [1,NFLAV]). + + Requires proc_prefix, nflav and den_factor_line to be set already. + """ + prefix = replace_dict['proc_prefix'] + + if not use_crossing: + replace_dict.update({ + 'crossing_routines': '', + 'iden_cross_lines': '', + 'smatrix_cross_decl': + 'C Generated without crossing symmetry: FLAV_IDX is a plain' + '\nC flavor index, there is no crossing to decode.', + 'smatrix_cross_decode': '', + 'smatrix_cross_apply': '', + 'smatrix_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.update(dict( + (key, value % {'proc_prefix': prefix, + 'den_factor_line': replace_dict['den_factor_line']}) + for key, value in self.CROSSING_SNIPPETS.items())) + 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 + + # (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""", + + '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 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_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 + tables below. + + BASEPID_CROSS_TABLE gives, per slot of the crossed process, the + representative PDG of the particle landing there (conjugated when the + leg swapped between the initial and the final state), which identifies + its flavor group. SRC_CROSS_TABLE gives the FLAVOR entry to read for + that slot: FLAVOR is not permuted by the crossing, so slot k must look + up the position of the original leg that moved into it. Two crossed + final legs are identical iff they share both. + + Both tables are flattened as CROSS*NEXTERNAL + (slot-1). + + A crossing that cannot be applied gets a 0 spin*color entry, which + SMATRIX maps to a null matrix element. + """ + process = matrix_element.get('processes')[0] + model = process.get('model') + legs = 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] + + def particle(pdg): + return model.get('particle_dict')[pdg] + + ninitial = len([leg for leg in legs if not leg.get('state')]) + + spincol = [] + basepid = [] + source = [] + # CROSS = I*(NEXTERNAL+1)+J with I and J both in 0..NEXTERNAL. + for cross in range((nexternal + 1) * (nexternal + 1)): + perm, ic, valid = self.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)] + + # 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] + 1 for slot in range(nexternal)) + + # Sanity: for the identity crossing, spin*color times the identical + # factor of the representative flavor must rebuild the static IDEN, + # else this and get_denominator_factor have drifted apart. + rep_final = [leg_ids[slot] for slot in range(ninitial, nexternal)] + rep_identical = 1 + for pdg in set(rep_final): + rep_identical *= math.factorial(rep_final.count(pdg)) + assert spincol[0] * rep_identical == \ + matrix_element.get_denominator_factor(), \ + 'Crossing denominator disagrees with get_denominator_factor: ' \ + '%s*%s vs %s' % (spincol[0], rep_identical, + matrix_element.get_denominator_factor()) + + return '\n'.join([ + self.format_integer_data_lines('SPINCOL_CROSS_TABLE', spincol), + self.format_integer_data_lines('BASEPID_CROSS_TABLE', basepid), + self.format_integer_data_lines('SRC_CROSS_TABLE', source)]) + + @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).""" @@ -3311,6 +3891,11 @@ class ProcessExporterFortranSA(ProcessExporterFortran): f2py_wrapper_all ="f2py_wrapper_all.inc" f2py_matrix_splitter = "f2py_splitter.py" jamp_optim = 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 # When True, emit per-call IAND(WF_FLAVOR_MASK/AMP_FLAVOR_MASK, # CURRENT_FLAV_BIT) guards in MATRIX so that wavefunctions and amplitudes @@ -4132,6 +4717,18 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, if 'sa_symmetry' not in self.opt: self.opt['sa_symmetry']=False + # --use_crossing of the generate command (default on); see + # fill_crossing_replace_dict. + if 'use_crossing' not in self.opt: + self.opt['use_crossing']=True + + # ... 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. @@ -4159,6 +4756,20 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, 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. + fortran_model.use_crossing_ic = ( + use_crossing + and self.matrix_template == 'matrix_standalone_v4.inc' + and self.opt['export_format'] not in ('standalone_msP', + 'standalone_msF', + 'matchbox', + 'madloop_matchbox') + and not matrix_element.get('processes')[0].get('split_orders')) try: # Extract helas calls helas_calls = fortran_model.get_matrix_element_calls(\ @@ -4167,6 +4778,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) @@ -4333,6 +4945,48 @@ 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) + + # GET_PDG_FOR_FLAVOR (extended FLAV_IDX -> per-leg PDG). Must come after + # fill_crossing_replace_dict, which decides whether it decodes a + # crossing or just reads the table. Only matrix_standalone_v4.inc has + # the hole; the key is set unconditionally since an unused replace_dict + # entry is harmless and the other templates then stay byte-identical. + n_pdg_flav, pdg_flat, antipdg_flat = \ + self._build_flav_pdg_tables(matrix_element) + replace_dict['flavor_pdg_function'] = \ + self._make_flavor_pdg_fortran_function( + replace_dict['proc_prefix'] + 'GET_PDG_FOR_FLAVOR', + n_pdg_flav, pdg_flat, antipdg_flat, + replace_dict['pdg_cross_snippets'], + nexternal_decl=bs_nexternal) + + # f2py entry points taking an extended FLAV_IDX (the only way a python + # caller can request a crossing, and reach GET_DENSITY_IDX / + # GET_ALL_INTER_IDX / GET_NHEL_IDX / GET_PDG_FOR_FLAVOR). Those routines + # only exist in matrix_standalone_v4.inc, so the wrappers are emitted + # only there; the other standalone templates get an empty hole (the + # placeholder lives in the shared matrix_standalone_f2py.inc). The + # snippet is pre-formatted here because the outer '% replace_dict' pass + # does not re-scan an inserted value for further %(...)s. + if matrix_template == 'matrix_standalone_v4.inc': + flav_idx_tmpl = open(pjoin(_file_path, 'iolibs', 'template_files', + 'matrix_standalone_f2py_flav_idx.inc')).read() + nexternal_val = int(replace_dict['nexternal']) + replace_dict['f2py_flav_idx_wrappers'] = flav_idx_tmpl % { + 'proc_prefix': replace_dict['proc_prefix'], + 'nexternal': nexternal_val, + 'nflav': replace_dict['nflav'], + 'ncomb': replace_dict['ncomb'], + 'ncross': (nexternal_val + 1) ** 2, + } + else: + replace_dict['f2py_flav_idx_wrappers'] = '' + replace_dict['template_file'] = pjoin(_file_path, 'iolibs', 'template_files', matrix_template) replace_dict['template_file2'] = pjoin(_file_path, \ 'iolibs/template_files/split_orders_helping_functions.inc') @@ -4483,8 +5137,11 @@ class ProcessExporterFortranMatchBox(ProcessExporterFortranSA): matrix_template = "matrix_standalone_matchbox.inc" - - @staticmethod + # Inherits from the standalone exporter but writes its own template, which + # has no crossing machinery: the capability does not carry over. + supports_crossing = False + + @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.""" @@ -11567,8 +12224,12 @@ 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: 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), '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/helas_call_writers.py b/madgraph/iolibs/helas_call_writers.py index 2910e6a66..864b71e14 100755 --- a/madgraph/iolibs/helas_call_writers.py +++ b/madgraph/iolibs/helas_call_writers.py @@ -1042,6 +1042,11 @@ def __init__(self, argument={}, hel_sum = False, options={}): self.use_flavor_mask = False self.me_n_flavors = 0 self.me_active_flavor_mask = None + # When True the external wavefunction NSF/NSV flag is multiplied by + # IC(i), letting the caller cross a leg between the initial and the + # final state. Only the exporters whose template passes a meaningful + # IC turn this on (see generate_external_wavefunction). + self.use_crossing_ic = False super(FortranUFOHelasCallWriter, self).__init__(argument, options=options) def format_helas_object(self, prefix, number): @@ -1203,14 +1208,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): diff --git a/madgraph/iolibs/template_files/f2py_flavor_dispatch.py b/madgraph/iolibs/template_files/f2py_flavor_dispatch.py index a2b70e503..07c1b5fa5 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/matrix_standalone_f2py.inc b/madgraph/iolibs/template_files/matrix_standalone_f2py.inc index 9f9c015d4..0f37f611b 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_splitOrders_v4.inc b/madgraph/iolibs/template_files/matrix_standalone_splitOrders_v4.inc index c2806387d..1f176c9b9 100644 --- a/madgraph/iolibs/template_files/matrix_standalone_splitOrders_v4.inc +++ b/madgraph/iolibs/template_files/matrix_standalone_splitOrders_v4.inc @@ -668,6 +668,8 @@ c INTEGER I,J,SOL,N INTEGER FLAV_IDX INTEGER %(proc_prefix)sGET_FLAVOR_INDEX + INTEGER %(proc_prefix)sBROKEN_SYM + DOUBLE PRECISION RESCALE C ---------- C BEGIN CODE @@ -689,11 +691,17 @@ C ---------- call %(proc_prefix)sGET_JAMP(AMP,JAMP(1,1,I)) enddo +C GET_INTER only sees JAMPs, so it normalises with the bare static IDEN +C and cannot apply any flavor dependent factor. SMATRIX does +C ANS/IDEN*BROKEN_SYM(FLAVOR); the density matrix must use the same +C normalisation or the sum of its diagonal stops matching SMATRIX. + RESCALE = DBLE(%(proc_prefix)sBROKEN_SYM(FLAVOR)) SOL = 0 DO I = 1, N_COMB DO J= I, N_COMB SOL = SOL +1 call %(proc_prefix)sGET_INTER(JAMP(1,1,I), JAMP(1,1,J), INTER(1,SOL)) + INTER(:,SOL) = INTER(:,SOL) * RESCALE ENDDO ENDDO diff --git a/madgraph/iolibs/template_files/matrix_standalone_v4.inc b/madgraph/iolibs/template_files/matrix_standalone_v4.inc index ada2fd0eb..77386dda8 100644 --- a/madgraph/iolibs/template_files/matrix_standalone_v4.inc +++ b/madgraph/iolibs/template_files/matrix_standalone_v4.inc @@ -86,6 +86,9 @@ 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/ @@ -131,19 +134,24 @@ 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) + IF(USERHEL.EQ.-1) NTRY(FLAV_USE)=NTRY(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 @@ -160,21 +168,23 @@ C For this reason, we simply remove the filterin when there is only three ex 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 (GOODHEL(IHEL,FLAV_USE) .OR. NTRY(FLAV_USE) .LT. 20.OR.USERHEL.NE.-1) THEN + 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 MATRIX/GET_AMP get already crossed arrays and the reduced +C flavor index: the crossing was applied once, above. +%(smatrix_matrix_call)s 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. + IF (T .NE. 0D0 .AND. .NOT. GOODHEL(IHEL,FLAV_USE)) THEN + GOODHEL(IHEL,FLAV_USE)=.TRUE. ENDIF ENDIF ENDIF ENDDO - ANS=ANS/DBLE(IDEN)*%(proc_prefix)sBROKEN_SYM(FLAVOR) +%(smatrix_iden_line)s IF(USERHEL.NE.-1) THEN ANS=ANS*HELAVGFACTOR ELSE @@ -196,6 +206,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 @@ -274,11 +288,80 @@ CF2PY INTENT(OUT) :: IDEN_STAR NHEL_STAR = NHEL END + SUBROUTINE %(proc_prefix)sGET_NHEL_IDX(FLAV_IDX_IN,IDEN_STAR, + & NHEL_STAR) +C Same as GET_NHEL, but reporting the denominator SMATRIX ACTUALLY +C divides by for FLAV_IDX_IN, rather than the static IDEN. +C +C The static IDEN is the averaging/symmetry factor of the uncrossed +C *representative* flavor. SMATRIX never uses it bare: it applies +C IDEN/BROKEN_SYM(FLAVOR) uncrossed (BROKEN_SYM correcting the +C identical-particle count of the representative to that of the actual +C flavor) and GET_SPINCOL_CROSS*GET_IDENT_CROSS when crossed. A caller +C that reads GET_NHEL and multiplies ANS by it to recover the raw +C helicity/color sum -- the natural thing to do, and what makes two +C crossings comparable -- is therefore wrong for every crossed flavor +C and for every non-representative one. +C +C GET_NHEL is deliberately left alone: its signature and its value are +C what existing uncrossed callers expect. +%(nhel_idx_decl)s + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER NCOMB + PARAMETER ( NCOMB=%(ncomb)d) + INTEGER NFLAV + PARAMETER (NFLAV=%(nflav)d) +CF2PY INTENT(IN) :: FLAV_IDX_IN +CF2PY INTENT(OUT) :: NHEL_STAR +CF2PY INTENT(OUT) :: IDEN_STAR + INTEGER FLAV_IDX_IN + INTEGER NHEL_STAR(NEXTERNAL,NCOMB) + INTEGER IDEN_STAR + INTEGER NHI_FLAV, NHI_CROSS + INTEGER FLAVOR(NEXTERNAL) + INTEGER %(proc_prefix)sBROKEN_SYM + + CALL %(proc_prefix)sGET_NHEL(IDEN_STAR, NHEL_STAR) +C An index naming no valid flavor gives a zero matrix element; report a +C 0 denominator rather than a plausible-looking one. + IF (FLAV_IDX_IN .LT. 1) THEN + IDEN_STAR = 0 + RETURN + ENDIF + NHI_FLAV = MOD(FLAV_IDX_IN-1, NFLAV) + 1 + NHI_CROSS = (FLAV_IDX_IN-1) / NFLAV + CALL %(proc_prefix)sGET_FLAVOR(NHI_FLAV, FLAVOR) +%(nhel_idx_body)s + RETURN + END + SUBROUTINE %(proc_prefix)sGET_AMP(P,NHEL,IC,FLAV_IDX,AMP) use model_object C %(process_lines)s C +C CONTRACT (this routine is pure: it decodes nothing). +C +C P(0:3,NEXTERNAL) : momenta, ALREADY crossed. +C NHEL(NEXTERNAL) : helicities, ALREADY crossed (permuted, NOT negated: +C helas uses nh=nhel*nsf, so the sign follows IC). +C IC(NEXTERNAL) : NSF/NSV flag per leg, ALREADY crossed (-1 on a leg +C the crossing moved across). +C FLAV_IDX : flavor index ALREADY reduced to [1,NFLAV]; it must +C NOT be an extended index carrying a crossing. +C +C When the crossing machinery is written out (see %(proc_prefix)sGET_CROSS_PERM below; +C it is left out when the process was generated with --use_crossing=False, +C in which case FLAV_IDX is never extended), the callers inside this file +C (SMATRIX via MATRIX, GET_ALL_INTER_CROSSED) apply the crossing ONCE per +C entry point with %(proc_prefix)sAPPLY_CROSSING / %(proc_prefix)sAPPLY_CROSSING_TABLE and then call +C this routine in their inner loop. An external (f2py) caller holding an +C extended FLAV_IDX must call the public %(proc_prefix)sAPPLY_CROSSING itself first; +C passing the extended index here would otherwise silently return the +C UNCROSSED amplitude, so the range is checked below and violations are +C reported and return AMP=0. +C CF2PY INTENT(OUT) :: AMP CF2PY INTENT(IN) :: NHEL CF2PY INTENT(IN) :: P(0:3,NEXTERNAL) @@ -296,6 +379,8 @@ C PARAMETER (NEXTERNAL=%(nexternal)d) INTEGER NWAVEFUNCS, NCOLOR PARAMETER (NWAVEFUNCS=%(nwavefuncs)d, NCOLOR=%(ncolor)d) + INTEGER NFLAV + PARAMETER (NFLAV=%(nflav)d) REAL*8 ZERO PARAMETER (ZERO=0D0) C @@ -304,11 +389,15 @@ C REAL*8 P(0:3,NEXTERNAL) INTEGER NHEL(NEXTERNAL), IC(NEXTERNAL) INTEGER FLAV_IDX - INTEGER FLAVOR(NEXTERNAL) + COMPLEX*16 AMP(NGRAPHS) C C LOCAL VARIABLES C - COMPLEX*16 AMP(NGRAPHS) +C FLAVOR is rebuilt from FLAV_IDX below and is NOT permuted by any +C crossing: each slot keeps its own flavor-group position, which is what +C the mask indexes. + INTEGER FLAVOR(NEXTERNAL) + INTEGER AMP_I type(aloha) W(NWAVEFUNCS) COMPLEX*16 DUM0,DUM1 DATA DUM0, DUM1/(0d0, 0d0), (1d0, 0d0)/ @@ -322,6 +411,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 @@ -433,12 +534,53 @@ C ZTEMP = DCONJG(JAMP_2(I)) SUBROUTINE %(proc_prefix)sGET_DENSITY(P, POS, N_CHANGING, ALLOW_HEL, N_COMB, FLAVOR, ALPHAS, SCALE2, INTER) +C Entry point taking the full FLAVOR(NEXTERNAL) array (back-compat): +C resolve it to FLAV_IDX and forward to GET_DENSITY_IDX. A crossing can +C only be requested through GET_DENSITY_IDX: an extended FLAV_IDX carries +C a crossing code, which no FLAVOR array can express (GET_FLAVOR_INDEX +C only ever returns 1..NFLAV). + implicit none + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) +CF2PY INTENT(IN) :: P(0:3,%(nexternal)d) +CF2PY INTENT(IN) :: POS(N_CHANGING) +CF2PY INTENT(IN) :: N_CHANGING +CF2PY INTENT(IN) :: ALLOW_HEL(N_CHANGING*N_COMB) +CF2PY INTENT(IN) :: N_COMB +CF2PY INTENT(IN) :: FLAVOR(%(nexternal)d) +CF2PY INTENT(IN) :: ALPHAS +CF2PY INTENT(IN) :: SCALE2 +CF2PY INTENT(OUT) :: INTER(N_COMB*(N_COMB+1)/2) + REAL*8 P(0:3,NEXTERNAL) + INTEGER N_CHANGING, N_COMB + INTEGER POS(*) + INTEGER ALLOW_HEL(*) + INTEGER FLAVOR(NEXTERNAL) + DOUBLE PRECISION ALPHAS, SCALE2 + DOUBLE COMPLEX INTER(*) + INTEGER %(proc_prefix)sGET_FLAVOR_INDEX + + CALL %(proc_prefix)sGET_DENSITY_IDX(P, POS, N_CHANGING, ALLOW_HEL, + & N_COMB, %(proc_prefix)sGET_FLAVOR_INDEX(FLAVOR), ALPHAS, SCALE2, + & INTER) + RETURN + END + + + SUBROUTINE %(proc_prefix)sGET_DENSITY_IDX(P, POS, N_CHANGING, ALLOW_HEL, N_COMB, FLAV_IDX, ALPHAS, SCALE2, INTER) c P momenta c NHEL base of helicity that are not changing c POS(N_CHNGING): position of the changing helicity c n_changing: number of changing helicity c ALLOW_HEL(NCOMB, N_CHANGING): combination of helicity to consider (all jamp computed) c INTER(NCOMB*(NCOMB+1)/2): all interference term (not the symmetric one) +c FLAV_IDX may carry a crossing. It is decoded and applied ONCE here (the +c whole NHEL table, the momenta and the NSF flags in one go) and only +c crossed arrays plus the reduced flavor index travel further down. POS and +c the helicity labels refer to the UNCROSSED (source process) leg ordering +c and are mapped through the crossing permutation here. No helicity flip is +c needed on top: helas folds it into nh=nhel*nsf when the NSF flag of a +c crossed leg is flipped. use model_object implicit none CF2PY INTENT(IN) :: P(0:3,%(nexternal)d) @@ -446,7 +588,7 @@ CF2PY INTENT(IN) :: POS(N_CHANGING) CF2PY INTENT(IN) :: N_CHANGING CF2PY INTENT(IN) :: ALLOW_HEL(N_CHANGING*N_COMB) CF2PY INTENT(IN) :: N_COMB -CF2PY INTENT(IN) :: FLAVOR(%(nexternal)d) +CF2PY INTENT(IN) :: FLAV_IDX CF2PY INTENT(IN) :: ALPHAS CF2PY INTENT(IN) :: SCALE2 CF2PY INTENT(OUT) :: INTER(N_COMB*(N_COMB+1)/2) @@ -462,7 +604,7 @@ C INTEGER N_CHANGING, N_COMB INTEGER POS(*) INTEGER ALLOW_HEL(*) - INTEGER FLAVOR(NEXTERNAL) + INTEGER FLAV_IDX DOUBLE PRECISION ALPHAS, SCALE2 DOUBLE COMPLEX INTER(*) INTEGER NINTER @@ -472,6 +614,13 @@ C c LOCAL INTEGER I,IHEL,IPART DOUBLE PRECISION PI +C Crossed copies, built once (see the crossing block below). + REAL*8 PUSE(0:3,NEXTERNAL) + INTEGER NHELUSE(NEXTERNAL,NB_NHEL) + INTEGER IC(NEXTERNAL), ICUSE(NEXTERNAL) + INTEGER PERM(NEXTERNAL), SGN(NEXTERNAL), CPOS(NEXTERNAL) + INTEGER FLAV_USE, DUMFLAV + DOUBLE PRECISION RESCALE C INTEGER NHEL(NEXTERNAL,NB_NHEL) C put in common block to expose this variable to python interface @@ -494,13 +643,29 @@ 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 +%(density_cross_apply)s DO IHEL =1, NB_NHEL - THISNHEL(:) = NHEL(:, IHEL) + THISNHEL(:) = NHELUSE(:, IHEL) DO IPART=1,N_CHANGING - if(THISNHEL(POS(IPART)).NE.ALLOW_HEL(IPART)) GOTO 10 !BYPASS COMPUTATION FOR HELICITY + if(THISNHEL(CPOS(IPART)).NE.ALLOW_HEL(IPART)) GOTO 10 !BYPASS COMPUTATION FOR HELICITY ENDDO TMP_INTER(:) = 0 - call %(proc_prefix)sGET_ALL_INTER(P, THISNHEL, POS, N_CHANGING, ALLOW_HEL, N_COMB, FLAVOR, TMP_INTER) + call %(proc_prefix)sGET_ALL_INTER_CROSSED(PUSE, THISNHEL, ICUSE, CPOS, N_CHANGING, ALLOW_HEL, N_COMB, FLAV_USE, RESCALE, TMP_INTER) do I = 1, N_COMB*(N_COMB+1)/2 INTER(I) = INTER(I) + TMP_INTER(I) enddo @@ -510,12 +675,45 @@ C end SUBROUTINE %(proc_prefix)sGET_ALL_INTER(P, NHEL, POS, N_CHANGING, ALLOW_HEL, N_COMB, FLAVOR, INTER) +C Entry point taking the full FLAVOR(NEXTERNAL) array (back-compat); see +C GET_DENSITY. Use GET_ALL_INTER_IDX to request a crossing. + implicit none + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) +CF2PY INTENT(IN) :: P(0:3,%(nexternal)d) +CF2PY INTENT(IN) :: NHEL(%(nexternal)d) +CF2PY INTENT(IN) :: POS(N_CHANGING) +CF2PY INTENT(IN) :: N_CHANGING +CF2PY INTENT(IN) :: ALLOW_HEL(N_CHANGING*N_COMB) +CF2PY INTENT(IN) :: N_COMB +CF2PY INTENT(IN) :: FLAVOR(%(nexternal)d) +CF2PY INTENT(OUT) :: INTER(NCOMB*(NCOMB+1)/2) + REAL*8 P(0:3,NEXTERNAL) + INTEGER NHEL(NEXTERNAL) + INTEGER N_CHANGING, N_COMB + INTEGER POS(*) + INTEGER ALLOW_HEL(*) + INTEGER FLAVOR(NEXTERNAL) + DOUBLE COMPLEX INTER(*) + INTEGER %(proc_prefix)sGET_FLAVOR_INDEX + + CALL %(proc_prefix)sGET_ALL_INTER_IDX(P, NHEL, POS, N_CHANGING, + & ALLOW_HEL, N_COMB, %(proc_prefix)sGET_FLAVOR_INDEX(FLAVOR), INTER) + RETURN + END + + + SUBROUTINE %(proc_prefix)sGET_ALL_INTER_IDX(P, NHEL, POS, N_CHANGING, ALLOW_HEL, N_COMB, FLAV_IDX, INTER) c P momenta c NHEL base of helicity that are not changing c POS(N_CHNGING): position of the changing helicity c n_changing: number of changing helicity c ALLOW_HEL(NCOMB, N_CHANGING): combination of helicity to consider (all jamp computed) c INTER((NCOMB*NCOMB+1)/2: all interference term (not the symmetric one) +c FLAV_IDX may carry a crossing: it is decoded and applied ONCE here, and +c GET_ALL_INTER_CROSSED below then only sees crossed arrays. POS is given +c in the UNCROSSED (source process) slot numbering and is mapped through +c the crossing permutation here. implicit none CF2PY INTENT(IN) :: P(0:3,%(nexternal)d) CF2PY INTENT(IN) :: NHEL(%(nexternal)d) @@ -523,7 +721,7 @@ CF2PY INTENT(IN) :: POS(N_CHANGING) CF2PY INTENT(IN) :: N_CHANGING CF2PY INTENT(IN) :: ALLOW_HEL(N_CHANGING*N_COMB) CF2PY INTENT(IN) :: N_COMB -CF2PY INTENT(IN) :: FLAVOR(%(nexternal)d) +CF2PY INTENT(IN) :: FLAV_IDX CF2PY INTENT(OUT) :: INTER(NCOMB*(NCOMB+1)/2) c C @@ -536,7 +734,97 @@ C INTEGER N_CHANGING, N_COMB INTEGER POS(*) INTEGER ALLOW_HEL(*) + INTEGER FLAV_IDX + DOUBLE COMPLEX INTER(*) +c +c LOCAL +c + INTEGER I, IPART + INTEGER IC(NEXTERNAL), ICUSE(NEXTERNAL) + REAL*8 PUSE(0:3,NEXTERNAL) + INTEGER NHELUSE(NEXTERNAL) + INTEGER PERM(NEXTERNAL), SGN(NEXTERNAL), CPOS(NEXTERNAL) + INTEGER FLAV_USE, DUMFLAV + DOUBLE PRECISION RESCALE +C ---------- +C BEGIN CODE +C ---------- +C Unresolved flavor (not an allowed combination): the matrix element and +C therefore all interference terms are zero. + IF (FLAV_IDX.LT.1) THEN + DO I = 1, N_COMB*(N_COMB+1)/2 + INTER(I) = (0d0, 0d0) + ENDDO + RETURN + ENDIF +C RESCALE carries everything flavor / crossing dependent in the +C normalisation; RESCALE=0 marks a crossing that cannot be applied. + CALL %(proc_prefix)sGET_INTER_RESCALE(FLAV_IDX, FLAV_USE, RESCALE) + IF (RESCALE.EQ.0D0) THEN + DO I = 1, N_COMB*(N_COMB+1)/2 + INTER(I) = (0d0, 0d0) + ENDDO + RETURN + ENDIF +%(allinter_cross_apply)s + CALL %(proc_prefix)sGET_ALL_INTER_CROSSED(PUSE, NHELUSE, ICUSE, CPOS, + & N_CHANGING, ALLOW_HEL, N_COMB, FLAV_USE, RESCALE, INTER) + RETURN + END + + + SUBROUTINE %(proc_prefix)sGET_INTER_RESCALE(FLAV_IDX, FLAV_USE, + & RESCALE) +C Split an extended FLAV_IDX and return the factor by which GET_INTER's +C output must be multiplied. +C +C GET_INTER only ever sees JAMPs, so it cannot know the flavor: it +C normalises with the bare static IDEN and everything flavor dependent has +C to be applied by its caller. That is also what keeps the density matrix +C consistent with SMATRIX. RESCALE=0 marks a crossing that cannot be +C applied (zero matrix element). + IMPLICIT NONE + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER NFLAV + PARAMETER (NFLAV=%(nflav)d) + INTEGER FLAV_IDX, FLAV_USE + DOUBLE PRECISION RESCALE INTEGER FLAVOR(NEXTERNAL) + INTEGER %(proc_prefix)sBROKEN_SYM +%(inter_rescale_decl)s + + FLAV_USE = MOD(FLAV_IDX-1, NFLAV) + 1 + CALL %(proc_prefix)sGET_FLAVOR(FLAV_USE, FLAVOR) +%(inter_rescale_body)s + + RETURN + END + + + SUBROUTINE %(proc_prefix)sGET_ALL_INTER_CROSSED(P, NHEL, IC, POS, N_CHANGING, ALLOW_HEL, N_COMB, FLAV_IDX, RESCALE, INTER) +c Inner worker of the density machinery. +c +c CONTRACT: P, NHEL and IC are ALREADY crossed, POS is expressed in the +c CROSSED slot numbering, FLAV_IDX is ALREADY reduced to [1,NFLAV] and +c RESCALE already accounts for BROKEN_SYM / the crossed denominator. The +c callers (GET_ALL_INTER_IDX, GET_DENSITY_IDX) decode and apply the +c crossing once, so nothing is decoded per GET_AMP call here. +c NHEL is overwritten at the POS slots. + implicit none +C +C ARGUMENTS +C + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + REAL*8 P(0:3,NEXTERNAL) + INTEGER NHEL(NEXTERNAL) + INTEGER IC(NEXTERNAL) + INTEGER N_CHANGING, N_COMB + INTEGER POS(*) + INTEGER ALLOW_HEL(*) + INTEGER FLAV_IDX + DOUBLE PRECISION RESCALE DOUBLE COMPLEX INTER(*) c c Intermediate array @@ -545,18 +833,14 @@ c PARAMETER (NGRAPHS=%(ngraphs)d) INTEGER NCOLOR PARAMETER (NCOLOR=%(ncolor)d) - INTEGER IC(NEXTERNAL) DOUBLE COMPLEX AMP(NGRAPHS) DOUBLE COMPLEX, ALLOCATABLE, SAVE :: JAMP(:,:) INTEGER, SAVE :: S_NCOMB = 0 - c c LOCAL c INTEGER I,J,SOL,N - INTEGER FLAV_IDX - INTEGER %(proc_prefix)sGET_FLAVOR_INDEX if (allocated(jamp) .and. S_NCOMB.ne.N_COMB) then deallocate(jamp) @@ -569,16 +853,6 @@ c C ---------- C BEGIN CODE C ---------- - IC(:)=1 - FLAV_IDX = %(proc_prefix)sGET_FLAVOR_INDEX(FLAVOR) -C Unresolved flavor (not an allowed combination): the matrix element and -C therefore all interference terms are zero. - IF (FLAV_IDX.EQ.0) THEN - DO I = 1, N_COMB*(N_COMB+1)/2 - INTER(I) = (0d0, 0d0) - ENDDO - RETURN - ENDIF do I = 1, N_COMB do N = 1, N_CHANGING NHEL(POS(N)) = ALLOW_HEL((I-1)*N_CHANGING+N) @@ -592,6 +866,7 @@ C therefore all interference terms are zero. DO J= I, N_COMB SOL = SOL +1 call %(proc_prefix)sGET_INTER(JAMP(1,I), JAMP(1,J), INTER(SOL)) + INTER(SOL) = INTER(SOL)*RESCALE ENDDO ENDDO @@ -761,6 +1036,9 @@ C ---------- END +%(crossing_routines)s + + %(broken_sym_function)s @@ -768,3 +1046,6 @@ C ---------- %(flavor_array_function)s + + +%(flavor_pdg_function)s diff --git a/madgraph/various/process_checks.py b/madgraph/various/process_checks.py index 18c2b959b..b033cac96 100755 --- a/madgraph/various/process_checks.py +++ b/madgraph/various/process_checks.py @@ -3884,6 +3884,451 @@ 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. + """ + 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')): + return True + 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 + + +def check_crossing(process_definition, param_card=None, options=None, + cmd=FakeInterface()): + """Compare the crossing-enabled and crossing-disabled fortran standalone. + + The process is generated twice and output to fortran standalone (with the + f2py wrapper): + + * ``--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 ``FLAV_IDX`` + (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 ``FLAV_IDX`` reproducing that PDG signature, when one exists) + at the very same phase-space point, giving ``value_crossed``. The two must + agree: this exercises ``APPLY_CROSSING`` / the dynamic NSF / the crossed + averaging denominator against a value computed with none of them. + + 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 json + import tempfile + import madgraph.interface.master_interface as master_interface + + if options is None: + options = {} + energy = float(options.get('energy', 1000.0)) + + 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 standalone; return the list of P* directories.""" + 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 standalone %s -f' % 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))] + # If the user supplied a param_card, use it in place of the model + # default for both the module (initialisemodel) and momenta generation. + if param_card: + shutil.copy(param_card, pjoin(outdir, 'Cards', 'param_card.dat')) + return outdir, pdirs + + 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 = _generate('False', 'reference') + cross_out, cross_pdirs = _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] = _crossing_build_f2py(pdir, env) + if not any(built.get(pdir) for pdir in ref_pdirs) or \ + not any(built.get(pdir) for pdir in cross_pdirs): + # No usable module on either side: signal a skip rather than a fail. + return [{'status': 'build_failed'}] + + # ── 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 + # APPLY_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 + answer = _crossing_run_driver( + pdir, {'mode': 'enumerate', 'card': cross_card}, env) + if not answer: + continue + module_map = {} # find_pdg semantics: lowest cross per PDG + for idx, cross, _flav, pdg in answer['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 + answer = _crossing_run_driver( + pdir, {'mode': 'enumerate', 'card': ref_card}, env) + if not answer: + continue + for idx, cross, _flav, pdg in answer['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] + answer = _crossing_run_driver( + pdir, {'mode': 'evaluate', 'card': ref_card, 'items': items}, + env) + values = answer['values'] if answer else [None] * len(items) + 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] + answer = _crossing_run_driver( + cpdir, {'mode': 'evaluate', 'card': cross_card, + 'items': items}, env) + values = answer['values'] if answer else [None] * len(items) + 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 + 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, + '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 ``FLAV_IDX``). ``output='fail'`` returns the number of failures + instead of the formatted string. + """ + if len(comparison_results) == 1 and \ + comparison_results[0].get('status') == 'build_failed': + msg = ("Could not build the f2py matrix2py module (f2py / numpy build " + "backend unavailable); the crossing check cannot run here.") + 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 = 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) + res_str += '\n' + fixed_string_length(proc, proc_col_size) + \ + " * No matrix element found for a crossing, not checked *" + 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 #=============================================================================== From 077c7373113fa7efd82d9c792ed533ebafd46bc2 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 22 Jul 2026 06:24:07 +0200 Subject: [PATCH 02/43] Extend crossing symmetry to standalone_cpp/mg7 and add check-crossing driver Crossing symmetry lets one generated matrix element evaluate physically related processes by moving legs between the initial and final state, encoded in an extended flavor index (id = cross*nmaxflavor + flavor). - Fortran, C++ and mg7 (madmatrix) exporters emit the crossing tables, the per-event momentum/NSF permutation, the crossed denominator (initial spin*color x identical-final-state factor) and the sigma-permuted good-helicity remap / union. - `check crossing [--exporter=standalone|standalone_cpp|standalone_mg7] [--simd=...]` matches each reference subprocess against the crossing of the collapsed output that reproduces it; help text and auto-completion updated, and the "not checked" message now distinguishes its three causes. - Fix a stack buffer overflow in the mg7 UMAMI SIMD flavor-sorting path: it grouped events into SIMD vectors by the raw extended flavor id into arrays sized nmaxflavor, overflowing them for any crossing (id >= nmaxflavor) and crashing check_sa.exe (SIGABRT/SIGSEGV). Group by the reduced flavor instead and write flavor_indices per event, padding unused lanes with a valid crossing-0 id so the per-event momentum gather never indexes the crossing tables out of range. Validated: `check p p > j j j --exporter=standalone_mg7` now reports 1161/1161 flavor/crossing subprocesses passing with zero "not checked" (g d > d d d~ and g d > d u u~ previously crashed); the standalone cross-symmetry acceptance tests pass. Co-Authored-By: Claude Opus 4.8 --- madgraph/interface/madgraph_interface.py | 64 +- madgraph/iolibs/export_cpp.py | 359 +++- madgraph/iolibs/export_v4.py | 291 ++- madgraph/iolibs/helas_call_writers.py | 83 +- madgraph/iolibs/template_files/check_sa.cpp | 2 + madgraph/iolibs/template_files/check_sa.f | 7 + .../template_files/cpp_process_class.inc | 1 + .../cpp_process_function_definitions.inc | 2 + .../cpp_process_sigmaKin_function.inc | 41 +- .../fortran_matrix_flavor_pdg_fct.inc | 69 + .../process_function_definitions.inc | 8 +- .../madmatrix/process_sigmaKin_function.inc | 2 +- .../iolibs/template_files/madmatrix/umami.cc | 27 +- .../matrix_standalone_crossing_v4.inc | 231 +++ .../matrix_standalone_f2py_flav_idx.inc | 143 ++ .../template_files/matrix_standalone_v4.inc | 6 +- madgraph/various/process_checks.py | 428 +++- madmatrix/model_handling.py | 285 ++- madmatrix/output.py | 6 + .../test_standalone_cross_symmetry.py | 1788 +++++++++++++++++ .../test_standalone_madevent_consistency.py | 8 + tests/unit_tests/iolibs/test_export_cpp.py | 3 +- 22 files changed, 3703 insertions(+), 151 deletions(-) create mode 100644 madgraph/iolibs/template_files/fortran_matrix_flavor_pdg_fct.inc create mode 100644 madgraph/iolibs/template_files/matrix_standalone_crossing_v4.inc create mode 100644 madgraph/iolibs/template_files/matrix_standalone_f2py_flav_idx.inc create mode 100644 tests/acceptance_tests/test_standalone_cross_symmetry.py diff --git a/madgraph/interface/madgraph_interface.py b/madgraph/interface/madgraph_interface.py index abf742f1a..9ec60101a 100755 --- a/madgraph/interface/madgraph_interface.py +++ b/madgraph/interface/madgraph_interface.py @@ -566,12 +566,19 @@ def help_check(self): 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 fortran standalone twice, with the") - logger.info(" crossing symmetry on (--use_crossing=True) and off, then") - logger.info(" compare each subprocess evaluated through the extended") - logger.info(" FLAV_IDX crossing against its independent value.") - logger.info(" Requires gfortran and a working f2py (numpy) toolchain.") + logger.info(" Output the process to a standalone backend twice, with") + logger.info(" the crossing symmetry on (--use_crossing=True) and off,") + logger.info(" then compare each subprocess evaluated through the") + logger.info(" extended flavor-index crossing against its independent") + logger.info(" value. --exporter picks the backend (default standalone):") + logger.info(" standalone (fortran/f2py), standalone_cpp, standalone_mg7.") + logger.info(" Requires gfortran+f2py (standalone) or a C++ compiler") + logger.info(" (standalone_cpp / standalone_mg7).") + logger.info(" For standalone_mg7, --simd picks the vectorisation width:") + logger.info(" auto (default), none, sse4, avx2, 512y, 512z.") logger.info(" Example: check crossing g u > g u",'$MG:color:GREEN') + logger.info(" Example: check crossing g u > g u --exporter=standalone_cpp",'$MG:color:GREEN') + logger.info(" Example: check crossing g u > g u --exporter=standalone_mg7 --simd=avx2",'$MG:color:GREEN') logger.info("o cms:",'$MG:color:GREEN') logger.info(" Check the complex mass scheme consistency by comparing") logger.info(" it to the narrow width approximation in the off-shell") @@ -1095,7 +1102,13 @@ def check_check(self, args): '--collier_internal_stability_test':'False', '--collier_mode':'1', '--events': None, - '--skip_evt':0} + '--skip_evt':0, + # 'check crossing' backend: standalone (default), standalone_cpp + # or standalone_mg7. + '--exporter':'standalone', + # 'check crossing --exporter=standalone_mg7' vectorisation + # (SIMD) width: auto (default), none, sse4, avx2, 512y, 512z. + '--simd':'auto'} if args[0] in ['cms'] or args[0].lower()=='cmsoptions': # increase the default energy to 5000 @@ -2398,6 +2411,7 @@ def complete_check(self, text, line, begidx, endidx, formatting=True): cms_check_mode = len(args) >= 2 and args[1]=='cms' + crossing_check_mode = len(args) >= 2 and args[1]=='crossing' cms_options = ['--name=','--tweak=','--seed=','--offshellness=', '--lambdaCMS=','--show_plot=','--report=','--lambda_plot_range=','--recompute_width=', @@ -2407,6 +2421,20 @@ def complete_check(self, text, line, begidx, endidx, formatting=True): options = ['--energy='] if cms_options: options.extend(cms_options) + if crossing_check_mode: + # 'check crossing' only understands --energy, --exporter and (for + # standalone_mg7) --simd; the cms options above do not apply. + crossing_options = ['--energy=', '--exporter=', '--simd='] + # Value completion for the two 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)) + # 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): @@ -4304,6 +4332,26 @@ def create_lambda_values_list(lower_bound, N): options['report'] = option[1].lower() elif option[0]=='--seed': options['seed'] = int(option[1]) + elif option[0]=='--exporter': + # Backend for 'check crossing': which standalone output to build + # and run the crossing self-check against. + if option[1] not in process_checks.CROSSING_EXPORTERS: + raise self.InvalidCmd( + "The '--exporter' option for 'check crossing' must be " + "one of %s, not '%s'." % ( + ', '.join(process_checks.CROSSING_EXPORTERS), + option[1])) + options['exporter'] = option[1] + elif option[0]=='--simd': + # Vectorisation width for 'check crossing --exporter= + # standalone_mg7' (ignored by the other backends). + if option[1] not in process_checks.MG7_SIMD_CHOICES: + raise self.InvalidCmd( + "The '--simd' option for 'check crossing' must be one " + "of %s, not '%s'." % ( + ', '.join(process_checks.MG7_SIMD_CHOICES), + option[1])) + options['simd'] = option[1] elif option[0]=='--name': if '.' in option[1]: raise self.InvalidCmd("Do not specify the extension in the"+ @@ -4563,9 +4611,11 @@ def create_lambda_values_list(lower_bound, N): # matrix elements. Route it here and return early. if args[0] == 'crossing': options['proc_line'] = proc_line + options.setdefault('exporter', 'standalone') crossing_result = process_checks.check_crossing( myprocdef, param_card=param_card, options=options, cmd=self) - text = 'Crossing symmetry check (crossing on vs off):\n' + 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) diff --git a/madgraph/iolibs/export_cpp.py b/madgraph/iolibs/export_cpp.py index 5214c9dca..591239c8a 100755 --- a/madgraph/iolibs/export_cpp.py +++ b/madgraph/iolibs/export_cpp.py @@ -706,6 +706,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 @@ -937,6 +941,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,188 @@ 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': '', + # 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) + + spincol_init = self._cpp_int_array(tables['spincol']) + perm_init = self._cpp_int_array2d(tables['perm'], nexternal) + ic_init = self._cpp_int_array2d(tables['ic'], nexternal) + basepid_init = self._cpp_int_array(tables['basepid']) + src_init = self._cpp_int_array(tables['source']) + # GHREMAP: the C++ NHEL table is emitted with allow_reverse False (see + # get_helicity_matrix), so the remap must be built in that order. A + # non-filterable crossing (initial-initial swap or inapplicable) gets + # -1, a "no filter" sentinel the loop treats as "compute this row". + ghremap_init = self._cpp_int_array( + [-1 if row is None else row + for row in ProcessExporterFortran.compute_ghremap( + 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 denominator splits into the\n" + "// crossing-dependent initial-state spin*color (spincol_cross) and\n" + "// the flavor-dependent identical-final-state factor (ident_cross).\n" + "const int ncross = %(ncross)d;\n" + "static const int spincol_cross[ncross] = %(spincol)s;\n" + "static const int cross_perm[ncross][nexternal] = %(perm)s;\n" + "static const int cross_ic[ncross][nexternal] = %(ic)s;\n" + "// GHREMAP[cross*ncomb+ihel] = the identity helicity row whose\n" + "// goodhel bit gates crossed row ihel (sigma^-1); -1 = the crossing\n" + "// is not filterable, so that row is always computed and never\n" + "// trains. For cross 0 it is the identity: the uncrossed path is\n" + "// unchanged. See ProcessExporterFortran.compute_ghremap.\n" + "static const int ghremap[ncross * ncomb] = %(ghremap)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, 'spincol': spincol_init, + 'perm': perm_init, 'ic': ic_init, 'ghremap': ghremap_init} + + cross_perm_block = ( + "int perm[nexternal];\n" + "int ic[nexternal];\n" + "for(int i = 0; i < nexternal; i++){\n" + " perm[i] = cross_perm[cross][i];\n" + " ic[i] = cross_ic[cross][i];\n" + "}") + + 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" + "// 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, conjugated already when the leg swapped\n" + "// side) and the same position inside it. FLAVOR is not permuted by\n" + "// the crossing, so slot k reads the position of the original leg\n" + "// that moved into it, via src_cross.\n" + "int CPPProcess::ident_cross(int cross, const int* flavor)\n" + "{\n" + " const int ncross = %(ncross)d;\n" + " static const int basepid_cross[ncross * nexternal] = %(basepid)s;\n" + " static const int src_cross[ncross * nexternal] = %(src)s;\n" + " const int off = cross * nexternal;\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 (basepid_cross[off + k] == basepid_cross[off + l] &&\n" + " flavor[src_cross[off + k]] == flavor[src_cross[off + l]])\n" + " {\n" + " used[l] = true;\n" + " n = n + 1;\n" + " fact = fact * n;\n" + " }\n" + " }\n" + " }\n" + " return fact;\n" + "}" + ) % {'ncross': ncross, 'basepid': basepid_init, 'src': src_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': ' int ident_cross(int cross, const int* flavor);', + 'ident_cross_function': ident_cross_function, + # The good-helicity filter is shared per flavor but consulted and + # trained through GHREMAP (sigma^-1): a crossed row is good iff its + # identity counterpart is. ghidx = -1 disables the filter for a + # non-filterable crossing (compute the row, never train). For cross + # 0 ghidx == ihel, so this is exactly the historical filter. + 'cross_ghidx_setup': 'int ghidx = ghremap[cross*ncomb + ihel];\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 +1600,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() @@ -1570,9 +1774,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)) @@ -2623,6 +2829,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, @@ -2738,7 +2948,108 @@ 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) + pdg_rows = len(pdg_flat) // nx + + # demo_pdg[flavor_id*nexternal + slot], flavor_id = cross*nflav+flav0. + demo_pdg = [] + for cross in range(ncross): + for flav0 in range(n_flav): + # Guard in the unlikely case the pdg table has fewer rows than + # nflavors: fall back to the first flavor rather than overrun. + row = flav0 if flav0 < pdg_rows else 0 + 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``: @@ -2817,6 +3128,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) @@ -2829,7 +3142,18 @@ def generate_subprocess_directory(self, matrix_element, cpp_helas_call_writer, #matrix_element = copy.deepcopy(matrix_element) process_exporter_cpp = self.oneprocessclass(matrix_element,cpp_helas_call_writer) - + # Enable the crossing machinery for standalone_cpp when the process was + # generated with --use_crossing (default on) and the process does not + # pin a specific s-channel (which a crossing would not preserve). Only a + # single-ME directory carries the flavor tables the crossing needs. + process_exporter_cpp.use_crossing = bool( + getattr(self, 'supports_crossing', False) + and self.opt.get('use_crossing', False) + and len(process_exporter_cpp.matrix_elements) == 1 + and not ProcessExporterFortran.breaks_crossing_symmetry( + process_exporter_cpp.matrix_elements[0].get('processes')[0])) + + # Create the directory PN_xx_xxxxx in the specified path proc_dir_name = "P%d_%s" % (process_exporter_cpp.process_number, process_exporter_cpp.process_name) @@ -2847,7 +3171,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 @@ -2865,10 +3190,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 @@ -3176,6 +3503,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 @@ -3207,6 +3535,18 @@ def generate_subprocess_directory( """ Override of super().generate_subprocess_directory """ process_exporter_mg7 = self.oneprocessclass(matrix_element,cpp_helas_call_writer) + # Enable the crossing machinery (extended flavor id) when the process was + # generated with --use_crossing (default on) and the process does not pin + # a specific s-channel (which a crossing would not preserve). Only a + # single-ME directory carries the flavor tables the crossing needs. When + # off, use_crossing stays False and the output is byte-identical. + process_exporter_mg7.use_crossing = bool( + getattr(self, 'supports_crossing', False) + and self.opt.get('use_crossing', False) + and len(process_exporter_mg7.matrix_elements) == 1 + and not ProcessExporterFortran.breaks_crossing_symmetry( + process_exporter_mg7.matrix_elements[0].get('processes')[0])) + # Create the directory PN_xx_xxxxx in the specified path proc_dir_name = process_exporter_mg7.name dirpath = pjoin(self.dir_path, 'SubProcesses', proc_dir_name) @@ -3324,6 +3664,9 @@ 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 (default on). Only the + # standalone_cpp exporter honors it; the others ignore this key. + opt['use_crossing'] = getattr(cmd, '_use_crossing', True) cformat = cmd._export_format if cformat == 'pythia8': diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index a8208dda7..bfa016c5c 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -2312,6 +2312,14 @@ def fill_crossing_replace_dict(self, matrix_element, replace_dict, '\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, @@ -2352,6 +2360,15 @@ def fill_crossing_replace_dict(self, matrix_element, replace_dict, (key, value % {'proc_prefix': prefix, 'den_factor_line': replace_dict['den_factor_line']}) for key, value in self.CROSSING_SNIPPETS.items())) + # The GHREMAP DATA is process dependent (it depends on the helicity + # table and the crossing permutations), so it is appended here rather + # than living in the fixed CROSSING_SNIPPETS. The fortran NHEL table is + # emitted with get_helicity_matrix()'s default order (allow_reverse + # True), so the remap must be built in that same order. + ghremap = self.compute_ghremap(matrix_element, allow_reverse=True) + replace_dict['smatrix_cross_decl'] += '\n' + \ + self.format_integer_data_lines( + 'GHREMAP', [0 if row is None else row + 1 for row in ghremap]) replace_dict['pdg_cross_snippets'] = tuple( snippet % {'proc_prefix': prefix} for snippet in self.PDG_CROSS_SNIPPETS_ON) @@ -2435,7 +2452,13 @@ def fill_crossing_replace_dict(self, matrix_element, replace_dict, REAL*8 PUSE(0:3,NEXTERNAL) INTEGER NHELUSE(NEXTERNAL,NCOMB) INTEGER ICUSE(NEXTERNAL) - INTEGER DUMFLAV""", + INTEGER DUMFLAV +C GHREMAP maps a crossed helicity row to the identity row whose GOODHEL +C bit gates it (see smatrix_goodhel_gate); the DATA statements follow. + INTEGER NCROSS + PARAMETER (NCROSS=(NEXTERNAL+1)*(NEXTERNAL+1)) + INTEGER GHIDX + INTEGER GHREMAP(0:NCROSS*NCOMB-1)""", '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. @@ -2457,6 +2480,24 @@ def fill_crossing_replace_dict(self, matrix_element, replace_dict, & 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. GHREMAP sends crossed +C row IHEL to the identity row that gates it (sigma^-1); 0 means the +C crossing is not filterable (an initial-initial swap, or a crossing that +C cannot be applied) so its every helicity is computed. For CROSSUSE=0 +C GHREMAP is the identity, so this is exactly the historical gate. + GHIDX = GHREMAP(CROSSUSE*NCOMB + IHEL - 1) + 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 @@ -2559,6 +2600,40 @@ def get_iden_cross_lines(self, matrix_element): 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) + spincol = tables['spincol'] + basepid = tables['basepid'] + # SRC_CROSS_TABLE is 1-based in the fortran (FLAVOR is indexed 1..N). + source = [s + 1 for s in tables['source']] + + return '\n'.join([ + self.format_integer_data_lines('SPINCOL_CROSS_TABLE', spincol), + self.format_integer_data_lines('BASEPID_CROSS_TABLE', basepid), + self.format_integer_data_lines('SRC_CROSS_TABLE', source)]) + + 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') legs = process.get('legs') @@ -2576,9 +2651,12 @@ def particle(pdg): 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 = self.get_crossing_permutation(cross, nexternal) + 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 @@ -2611,7 +2689,9 @@ def particle(pdg): slot_ids = list(leg_ids) basepid.extend(slot_ids) - source.extend(perm[slot] + 1 for slot in range(nexternal)) + source.extend(perm[slot] for slot in range(nexternal)) + perm_flat.extend(perm) + ic_flat.extend(ic) # Sanity: for the identity crossing, spin*color times the identical # factor of the representative flavor must rebuild the static IDEN, @@ -2626,10 +2706,137 @@ def particle(pdg): '%s*%s vs %s' % (spincol[0], rep_identical, matrix_element.get_denominator_factor()) - return '\n'.join([ - self.format_integer_data_lines('SPINCOL_CROSS_TABLE', spincol), - self.format_integer_data_lines('BASEPID_CROSS_TABLE', basepid), - self.format_integer_data_lines('SRC_CROSS_TABLE', source)]) + return {'spincol': spincol, 'basepid': basepid, 'source': source, + 'perm': perm_flat, 'ic': ic_flat, + 'nexternal': nexternal, 'ninitial': ninitial} + + 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). + The C++/mg7 backends decode it 0-based as ``cross*NFLAV + flav0``; the + fortran one is 1-based (``index+1``). ``zero_based`` picks which. + * ``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: 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). All three backends therefore agree on the mapping + pdg <-> extended index by construction. 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) + pdg_rows = len(pdg_flat) // nx + + entries = [] + for cross in range(ncross): + if spincol[cross] == 0: + continue + for flav0 in range(n_flav): + # Guard against a pdg table with fewer rows than nflavors. + row = flav0 if flav0 < pdg_rows else 0 + 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 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 @staticmethod def format_integer_data_lines(name, values, per_line=10): @@ -5013,6 +5220,68 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, #=========================================================================== # write_check_sa #=========================================================================== + 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), so the driver is + unchanged. Otherwise it loops over every way of crossing particle 1 and + particle 2 with a final-state particle, and for each flavor evaluates + the crossed matrix element and prints its signed PDGs and value. + + The crossing code is CROSS = FLIP1*(NEXTERNAL+1) + FLIP2 with FLIP1 the + partner of particle 1 and FLIP2 the partner of particle 2 -- matching + GET_CROSS_PERM's decode (i_part = CROSS/(NEXTERNAL+1), + j_part = CROSS mod (NEXTERNAL+1)). FLAV_IDX = CROSS*NFLAV + flav, and + NFLAV is emitted as the literal matrix.f value so the encoding matches + exactly. Overlapping/degenerate crossings (e.g. FLIP1==FLIP2) are left + in: GET_PDG_FOR_FLAVOR reports all-zero and SMATRIX returns 0 for them, + which is itself an informative part of the demonstration. + """ + use_crossing = self.opt.get('use_crossing', True) and \ + not any(self.breaks_crossing_symmetry(proc) + for proc in matrix_element.get('processes')) + if not use_crossing: + return '' + + # 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. The whole section is + # gated behind IF(.FALSE.) so it is present only as a ready-to-enable + # example -- flip it to .TRUE. to actually print the crossed processes. + n_table, _ = self._build_flav_table_flat(matrix_element) + + sep = (' write (*,*) "-----------------------------------------' + '------------------------------------"') + lines = [ + ' if(.false.) then', + ' write (*,*)', + ' write (*,*) " Crossing-symmetry examples (crossed processes):"', + ' write (*,*)', + ' NFLAV = %d' % n_table, + ' DO FLIP1=NINCOMING+1,NEXTERNAL', + ' DO FLIP2=NINCOMING+1,NEXTERNAL', + ' DO J=1,NFLAV', + 'C CROSS = (partner of particle 1)*(NEXTERNAL+1)', + 'C + (partner of particle 2)', + ' I = FLIP1*(NEXTERNAL+1) + FLIP2', + ' FLAV_IDX = I*NFLAV+J', + ' CALL %sGET_PDG_FOR_FLAVOR(FLAV_IDX, XPDG)' % proc_prefix, + ' CALL %sSMATRIX(P, FLAV_IDX, MATELEM)' % proc_prefix, + ' write(*,*) "PARTICLE #1 crossed with particle #", FLIP1', + ' write(*,*) "PARTICLE #2 crossed with particle #", FLIP2', + ' write (*,*) "PDG", (XPDG(K),K=1,NEXTERNAL),' + " 'FLAV_IDX', FLAV_IDX", + ' write (*,*) "Matrix element = ", MATELEM,' + ' " GeV^",-(2*nexternal-8)', + sep, + ' ENDDO', + ' ENDDO', + ' ENDDO', + ' endif', + ] + return '\n'.join(lines) + def write_check_sa(self, writer, matrix_element, proc_prefix=''): if self.format != 'standalone': @@ -5085,6 +5354,14 @@ def write_check_sa(self, writer, matrix_element, proc_prefix=''): replace_dict['maxflavor'] = maxflavor replace_dict['flavor_def'] = '\n '.join(flavor_text) + # Crossing-symmetry demonstration: when crossing is active for this + # matrix element, evaluate one genuinely crossed process (the first + # valid non-identity crossing) at the same phase-space point and print + # its per-leg PDG (via GET_PDG_FOR_FLAVOR) and matrix element, so that + # `make check` visibly exercises the crossing machinery. + replace_dict['crossing_example'] = \ + self._get_check_sa_crossing_example(matrix_element, proc_prefix) + fsock = open(pjoin(self.mgme_dir, 'madgraph', 'iolibs', 'template_files', 'check_sa.f'), 'r') text = fsock.read() fsock.close() diff --git a/madgraph/iolibs/helas_call_writers.py b/madgraph/iolibs/helas_call_writers.py index 864b71e14..b8587b894 100755 --- a/madgraph/iolibs/helas_call_writers.py +++ b/madgraph/iolibs/helas_call_writers.py @@ -1697,6 +1697,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): @@ -1724,6 +1728,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. @@ -1764,50 +1796,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/check_sa.cpp b/madgraph/iolibs/template_files/check_sa.cpp index 9da836ec1..ccc5c5773 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 a5eb27674..6e95acfb5 100644 --- a/madgraph/iolibs/template_files/check_sa.f +++ b/madgraph/iolibs/template_files/check_sa.f @@ -42,6 +42,11 @@ 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 C EXTERNAL C @@ -131,6 +136,8 @@ PROGRAM DRIVER write (*,*) "-----------------------------------------------------------------------------" enddo +%(crossing_example)s + if (%(use_density)s)then do I=1, MAXFLAVOR write (*,*) "==== density matrix for flavor", I, diff --git a/madgraph/iolibs/template_files/cpp_process_class.inc b/madgraph/iolibs/template_files/cpp_process_class.inc index b64dfd4f2..0285c00af 100644 --- a/madgraph/iolibs/template_files/cpp_process_class.inc +++ b/madgraph/iolibs/template_files/cpp_process_class.inc @@ -68,5 +68,6 @@ private: // 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 db3a44fee..0e0816b18 100644 --- a/madgraph/iolibs/template_files/cpp_process_function_definitions.inc +++ b/madgraph/iolibs/template_files/cpp_process_function_definitions.inc @@ -91,3 +91,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 dccadc17f..6c90819b3 100644 --- a/madgraph/iolibs/template_files/cpp_process_sigmaKin_function.inc +++ b/madgraph/iolibs/template_files/cpp_process_sigmaKin_function.inc @@ -6,47 +6,40 @@ 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){ +if (sum_hel[%(fidx)s] == 0 || ntry[%(fidx)s] < 10){ // Calculate the matrix element for all helicities 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; // 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 } } - jhel[flavor_id] = 0; - sum_hel[flavor_id]=min(sum_hel[flavor_id], ngood[flavor_id]); + jhel[%(fidx)s] = 0; + 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); + for(int j=0; j < sum_hel[%(fidx)s]; j++){ + jhel[%(fidx)s]++; + if (jhel[%(fidx)s] >= ngood[%(fidx)s]) jhel[%(fidx)s]=0; + double hwgt = double(ngood[%(fidx)s])/double(sum_hel[%(fidx)s]); + int ihel = igood[%(fidx)s][jhel[%(fidx)s]]; + calculate_wavefunctions(perm, helicities[ihel], flavor%(cross_cw_args)s); %(get_matrix_t_lines)s matrix_element += t*hwgt; } } -return matrix_element * broken_sym(flavor) / denominator; +%(cross_return)s 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 000000000..61841c7f5 --- /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/madmatrix/process_function_definitions.inc b/madgraph/iolibs/template_files/madmatrix/process_function_definitions.inc index 082c373aa..07298fc62 100644 --- a/madgraph/iolibs/template_files/madmatrix/process_function_definitions.inc +++ b/madgraph/iolibs/template_files/madmatrix/process_function_definitions.inc @@ -180,7 +180,7 @@ namespace mg5amcCpu // Host-side flavor table: single source of truth for PDG ids (used by both the // constructor copy into cFlavors and the public CPPProcess::flavorPDG accessor). %(all_flavors)s - +%(crossing_decl)s //-------------------------------------------------------------------------- #ifdef MGONGPUCPP_GPUIMPL @@ -265,7 +265,7 @@ namespace mg5amcCpu int CPPProcess::flavorPDG( int iflavor, int ipar ) { - return flavorPDGs[iflavor][ipar]; +%(flavorpdg_body)s } //-------------------------------------------------------------------------- @@ -549,9 +549,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 ) + 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 */ diff --git a/madgraph/iolibs/template_files/madmatrix/process_sigmaKin_function.inc b/madgraph/iolibs/template_files/madmatrix/process_sigmaKin_function.inc index 227301a6e..171acda75 100644 --- a/madgraph/iolibs/template_files/madmatrix/process_sigmaKin_function.inc +++ b/madgraph/iolibs/template_files/madmatrix/process_sigmaKin_function.inc @@ -291,7 +291,7 @@ const int ievt0 = ipagV * neppV; fptype* MEs = E_ACCESS::ieventAccessRecord( allMEs, ievt0 ); fptype_sv& MEs_sv = E_ACCESS::kernelAccess( MEs ); - MEs_sv = MEs_sv * broken_symmetry_factor(iflavorVec[ievt0]) / helcolDenominators[0]; +%(sigmakin_denominator)s if( mulChannelWeight && allChannelIds != nullptr ) // fix segfault #892 (not 'channelIds[0] != 0') { const unsigned int channelId = getChannelId( allChannelIds, ievt0, false ); diff --git a/madgraph/iolibs/template_files/madmatrix/umami.cc b/madgraph/iolibs/template_files/madmatrix/umami.cc index 36922d7a2..d970f70e9 100644 --- a/madgraph/iolibs/template_files/madmatrix/umami.cc +++ b/madgraph/iolibs/template_files/madmatrix/umami.cc @@ -455,31 +455,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/matrix_standalone_crossing_v4.inc b/madgraph/iolibs/template_files/matrix_standalone_crossing_v4.inc new file mode 100644 index 000000000..4147ee62d --- /dev/null +++ b/madgraph/iolibs/template_files/matrix_standalone_crossing_v4.inc @@ -0,0 +1,231 @@ + 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. + IMPLICIT NONE + INCLUDE 'nexternal.inc' + INTEGER NFLAV + PARAMETER (NFLAV=%(nflav)d) +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 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 + + RETURN + END + + + SUBROUTINE %(proc_prefix)sSWAP_LEGS(SLOT_A, SLOT_B, PERM, SGN) +C Exchange two legs in the permutation being built and flip their NSF/NSV +C sign (see GET_CROSS_PERM). SGN is swapped along with PERM before being +C negated, so that two overlapping swaps compose exactly as they would if +C the momentum/helicity/IC arrays themselves were swapped in turn. + IMPLICIT NONE + INCLUDE 'nexternal.inc' + INTEGER SLOT_A, SLOT_B + INTEGER PERM(NEXTERNAL), SGN(NEXTERNAL) + INTEGER ITMP + + ITMP = PERM(SLOT_A) + PERM(SLOT_A) = PERM(SLOT_B) + PERM(SLOT_B) = ITMP + ITMP = SGN(SLOT_A) + SGN(SLOT_A) = SGN(SLOT_B) + SGN(SLOT_B) = ITMP + SGN(SLOT_A) = -SGN(SLOT_A) + SGN(SLOT_B) = -SGN(SLOT_B) + + RETURN + END + + + SUBROUTINE %(proc_prefix)sAPPLY_CROSSING_TABLE(FLAV_IDX_IN, NROW, + & P_IN, NHEL_IN, IC_IN, P, NHEL, IC, FLAV_IDX) +C Apply the crossing carried by FLAV_IDX_IN to one set of momenta / NSF +C flags and to NROW helicity rows at once (see GET_CROSS_PERM). +C +C SMATRIX uses this to permute its whole NHEL table in a single sweep +C before the helicity loop: the permutation does not depend on the row, so +C decoding it once per SMATRIX call rather than once per helicity is both +C cheaper and the reason MATRIX/GET_AMP can stay pure. +C P/NHEL/IC must not alias P_IN/NHEL_IN/IC_IN. + IMPLICIT NONE + INCLUDE 'nexternal.inc' +C ARGUMENTS + INTEGER FLAV_IDX_IN, NROW + REAL*8 P_IN(0:3,NEXTERNAL) + INTEGER NHEL_IN(NEXTERNAL,NROW), IC_IN(NEXTERNAL) + REAL*8 P(0:3,NEXTERNAL) + INTEGER NHEL(NEXTERNAL,NROW), IC(NEXTERNAL) + INTEGER FLAV_IDX +C LOCAL + INTEGER PERM(NEXTERNAL), SGN(NEXTERNAL) + INTEGER XK, XR + + CALL %(proc_prefix)sGET_CROSS_PERM(FLAV_IDX_IN, PERM, SGN, FLAV_IDX) + + DO XK = 1, NEXTERNAL + P(0,XK) = P_IN(0,PERM(XK)) + P(1,XK) = P_IN(1,PERM(XK)) + P(2,XK) = P_IN(2,PERM(XK)) + P(3,XK) = P_IN(3,PERM(XK)) + IC(XK) = SGN(XK)*IC_IN(PERM(XK)) + ENDDO + DO XR = 1, NROW + DO XK = 1, NEXTERNAL + NHEL(XK,XR) = NHEL_IN(PERM(XK),XR) + ENDDO + ENDDO + + RETURN + END + + + SUBROUTINE %(proc_prefix)sAPPLY_CROSSING(FLAV_IDX_IN, P_IN, NHEL_IN, + & IC_IN, P, NHEL, IC, FLAV_IDX) +C Single helicity row flavour of APPLY_CROSSING_TABLE; kept public so that +C an external (f2py) caller holding an extended FLAV_IDX can pre-apply the +C crossing before calling GET_AMP, which is pure and rejects an extended +C index (see its contract). + IMPLICIT NONE + INCLUDE 'nexternal.inc' + INTEGER FLAV_IDX_IN + REAL*8 P_IN(0:3,NEXTERNAL) + INTEGER NHEL_IN(NEXTERNAL), IC_IN(NEXTERNAL) + REAL*8 P(0:3,NEXTERNAL) + INTEGER NHEL(NEXTERNAL), IC(NEXTERNAL) + INTEGER FLAV_IDX + + CALL %(proc_prefix)sAPPLY_CROSSING_TABLE(FLAV_IDX_IN, 1, P_IN, + & NHEL_IN, IC_IN, P, NHEL, IC, FLAV_IDX) + + RETURN + END + + + INTEGER FUNCTION %(proc_prefix)sGET_SPINCOL_CROSS(CROSS) +C Initial state spin*color average of the crossed process. +C +C Crossing changes which particles sit in the initial state (pulling a +C gluon in takes the color average from 3 to 8), but every particle of a +C flavor group shares its spin and color, so this half of the denominator +C depends on CROSS only and is tabulated at generation time. The other +C half, the identical final state factor, is flavor dependent: see +C GET_IDENT_CROSS. A 0 entry marks a crossing that cannot be applied. + IMPLICIT NONE + INCLUDE 'nexternal.inc' + INTEGER NCROSS + PARAMETER (NCROSS=(NEXTERNAL+1)*(NEXTERNAL+1)) + INTEGER CROSS, I +C The three tables are emitted together; each routine keeps its own copy +C rather than sharing a COMMON, which would need a BLOCK DATA unit to be +C initialised by DATA. + INTEGER SPINCOL_CROSS_TABLE(0:NCROSS-1) + INTEGER BASEPID_CROSS_TABLE(0:NCROSS*NEXTERNAL-1) + INTEGER SRC_CROSS_TABLE(0:NCROSS*NEXTERNAL-1) +%(iden_cross_lines)s + + IF (CROSS .LT. 0 .OR. CROSS .GT. NCROSS-1) THEN + %(proc_prefix)sGET_SPINCOL_CROSS = 0 + ELSE + %(proc_prefix)sGET_SPINCOL_CROSS = SPINCOL_CROSS_TABLE(CROSS) + ENDIF + + 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. FLAVOR is not permuted by the +C crossing, so slot K reads the position of the original leg that moved +C into it, via SRC_CROSS_TABLE. + IMPLICIT NONE + INCLUDE 'nexternal.inc' + INTEGER NCROSS + PARAMETER (NCROSS=(NEXTERNAL+1)*(NEXTERNAL+1)) + INTEGER CROSS + INTEGER FLAVOR(NEXTERNAL) + INTEGER SPINCOL_CROSS_TABLE(0:NCROSS-1) + INTEGER BASEPID_CROSS_TABLE(0:NCROSS*NEXTERNAL-1) + INTEGER SRC_CROSS_TABLE(0:NCROSS*NEXTERNAL-1) +%(iden_cross_lines)s + INTEGER K, L, N, FACT, OFF, I + LOGICAL USED(NEXTERNAL) + + OFF = CROSS*NEXTERNAL + DO K = 1, NEXTERNAL + USED(K) = .FALSE. + ENDDO + FACT = 1 + DO K = NINCOMING+1, NEXTERNAL + IF (USED(K)) CYCLE + N = 1 + DO L = K+1, NEXTERNAL + IF (USED(L)) CYCLE + IF (BASEPID_CROSS_TABLE(OFF+K-1).EQ.BASEPID_CROSS_TABLE(OFF+L + $ -1) .AND. FLAVOR(SRC_CROSS_TABLE(OFF+K-1)) + $ .EQ.FLAVOR(SRC_CROSS_TABLE(OFF+L-1))) THEN + USED(L) = .TRUE. + N = N + 1 + FACT = FACT * N + ENDIF + ENDDO + ENDDO + %(proc_prefix)sGET_IDENT_CROSS = FACT + + RETURN + END 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 000000000..7755e57b1 --- /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_v4.inc b/madgraph/iolibs/template_files/matrix_standalone_v4.inc index 77386dda8..4cf1c6842 100644 --- a/madgraph/iolibs/template_files/matrix_standalone_v4.inc +++ b/madgraph/iolibs/template_files/matrix_standalone_v4.inc @@ -168,7 +168,7 @@ C For this reason, we simply remove the filterin when there is only three ex ANS = 0D0 DO IHEL=1,NCOMB IF (USERHEL.EQ.-1.OR.USERHEL.EQ.IHEL) THEN - IF (GOODHEL(IHEL,FLAV_USE) .OR. NTRY(FLAV_USE) .LT. 20.OR.USERHEL.NE.-1) 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 @@ -178,9 +178,7 @@ C flavor index: the crossing was applied once, above. 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_USE)) THEN - GOODHEL(IHEL,FLAV_USE)=.TRUE. - ENDIF +%(smatrix_goodhel_train)s ENDIF ENDIF ENDDO diff --git a/madgraph/various/process_checks.py b/madgraph/various/process_checks.py index b033cac96..8e4e0cc94 100755 --- a/madgraph/various/process_checks.py +++ b/madgraph/various/process_checks.py @@ -3994,26 +3994,315 @@ def _crossing_run_driver(pdir, request, env): return None +# The three standalone backends that decode an extended (crossing-carrying) +# flavor index. 'standalone' is the fortran default (f2py); the other two are +# the C++ / cudacpp-CPU-SIMD standalones. +CROSSING_EXPORTERS = ('standalone', 'standalone_cpp', 'standalone_mg7') + +# Vectorisation (SIMD) choices for the standalone_mg7 (cudacpp) backend; each +# maps to the madmatrix.mk 'BACKEND=cpp' build variant. 'auto' lets +# madmatrix pick the widest instruction set the host CPU supports. Only used by +# the standalone_mg7 crossing backend; ignored by the others. +MG7_SIMD_CHOICES = ('auto', 'none', 'sse4', 'avx2', '512y', '512z') + + +def _crossing_pdg_entries(matrix_element, identity_only=False): + """Python enumeration of a matrix element's reachable extended flavor ids. + + Returns ``[(index, cross, flav0, pdg_tuple), ...]`` with a 0-based index + (``cross*NFLAV+flav0``) -- the encoding the C++/mg7 sigmaKin decodes. This + is the crossing twin of the fortran runtime GET_PDG_FOR_FLAVOR, used for the + backends that have no runtime PDG accessor. See + ProcessExporterFortran.compute_crossing_pdg_entries. + """ + if matrix_element is None: + # Correlation to a P* directory failed; the caller skips this module. + return None + import madgraph.iolibs.export_v4 as export_v4 + entries = export_v4.ProcessExporterFortran.compute_crossing_pdg_entries( + None, matrix_element, zero_based=True) + if identity_only: + entries = [e for e in entries if e[1] == 0] + return entries + + +# ── C++ standalone (standalone_cpp) ───────────────────────────────────────── +# A tiny driver that evaluates sigmaKin at the requested flavor_id and momenta. +# Each item gets a FRESH CPPProcess so the good-helicity cache (indexed by the +# reduced flavor) cannot carry a warmed-up crossing's helicity pattern into a +# different crossing of the same flavor -- exactly the recipe the acceptance +# test TestStandaloneCppCrossSymmetry uses. The request file holds, on the first +# line the number of items, then per item a flavor_id followed by 4*nexternal +# momentum components (E, px, py, pz per leg, in the leg order the crossed index +# expects them). +_CROSSING_CPP_DRIVER = r''' +#include +#include +#include +#include +#include "CPPProcess.h" +int main(int argc, char** argv){ + std::ifstream in(argv[1]); + int nitems; in >> nitems; + std::cout << std::setprecision(17); + for(int it = 0; it < nitems; it++){ + int fid; in >> fid; + CPPProcess process("../../Cards/param_card.dat"); + int npar = process.nexternal; + std::vector p; + for(int i = 0; i < npar; i++){ + double* m = new double[4]; + for(int j = 0; j < 4; j++) in >> m[j]; + p.push_back(m); + } + process.setMomenta(p); + double me = process.sigmaKin(fid); + std::cout << "ITEM " << it << " " << me << std::endl; + for(int i = 0; i < npar; i++) delete[] p[i]; + } + return 0; +} +''' + + +class _FortranCrossingBackend(object): + """The fortran standalone (f2py) crossing backend -- the historical path. + + Enumeration and evaluation both go through the compiled matrix2py module in + a fresh subprocess (see _CROSSING_DRIVER); the matrix element python object + is not needed because GET_PDG_FOR_FLAVOR resolves the crossed PDG at + runtime. + """ + output_format = 'standalone' + needs_matrix_element = False + + def __init__(self, options=None): + # options accepted for a uniform backend signature; --simd only applies + # to standalone_mg7. + pass + + def build(self, pdir, env): + return _crossing_build_f2py(pdir, env) + + def enumerate(self, pdir, matrix_element, card, env, identity_only): + answer = _crossing_run_driver( + pdir, {'mode': 'enumerate', 'card': card}, env) + if not answer: + return None + entries = [] + for idx, cross, flav, pdg in answer['entries']: + if identity_only and cross != 0: + continue + entries.append((idx, cross, flav, tuple(pdg))) + return entries + + def evaluate(self, pdir, items, card, env): + answer = _crossing_run_driver( + pdir, {'mode': 'evaluate', 'card': card, 'items': items}, env) + return answer['values'] if answer else [None] * len(items) + + +class _CppCrossingBackend(object): + """The C++ standalone (standalone_cpp) crossing backend. + + (`options` is accepted for a uniform backend constructor signature; the + SIMD/vectorisation choice only applies to standalone_mg7.) + + The crossed PDG of an extended flavor_id is computed in python (there is no + runtime accessor); evaluation compiles a small driver that news a fresh + CPPProcess per item and calls sigmaKin(flavor_id). + """ + output_format = 'standalone_cpp' + needs_matrix_element = True + + def __init__(self, options=None): + self.compiler = os.environ.get('CXX', 'g++') + + def build(self, pdir, env): + if not shutil.which(self.compiler): + return False + with open(os.devnull, 'w') as devnull: + rc = subprocess.call(['make'], cwd=pdir, stdout=devnull, + stderr=subprocess.STDOUT, env=env) + return rc == 0 and os.path.isfile(pjoin(pdir, 'CPPProcess.o')) + + def enumerate(self, pdir, matrix_element, card, env, identity_only): + return _crossing_pdg_entries(matrix_element, identity_only=identity_only) + + def evaluate(self, pdir, items, card, env): + with open(pjoin(pdir, 'driver_cross.cpp'), 'w') as fsock: + fsock.write(_CROSSING_CPP_DRIVER) + cxxflags = ['-O3', '-ffast-math', '-I../../src', '-I.', '-fPIC'] + libflags = ['-L../../lib', '-lmodel_sm'] + with open(os.devnull, 'w') as devnull: + rc = subprocess.call( + [self.compiler] + cxxflags + ['-c', '-o', 'driver_cross.o', + 'driver_cross.cpp'], + cwd=pdir, stdout=devnull, stderr=subprocess.STDOUT, env=env) + if rc != 0: + return [None] * len(items) + rc = subprocess.call( + [self.compiler, '-o', 'driver_cross', 'CPPProcess.o', + 'driver_cross.o'] + libflags, + cwd=pdir, stdout=devnull, stderr=subprocess.STDOUT, env=env) + if rc != 0: + return [None] * len(items) + req = pjoin(pdir, 'driver_cross.in') + with open(req, 'w') as fsock: + fsock.write('%d\n' % len(items)) + for item in items: + comps = ['%d' % int(item['index'])] + for leg in item['momenta']: + comps.extend('%.17e' % float(c) for c in leg) + fsock.write(' '.join(comps) + '\n') + try: + out = subprocess.check_output(['./driver_cross', 'driver_cross.in'], + cwd=pdir, env=env).decode() + except subprocess.CalledProcessError: + return [None] * len(items) + values = [None] * len(items) + for match in re.finditer(r'ITEM\s+(\d+)\s+([-\d.eE+]+)', out): + values[int(match.group(1))] = float(match.group(2)) + return values + + +# ── cudacpp CPU-SIMD standalone (standalone_mg7) ───────────────────────────── +# check_sa.exe generates its own RAMBO momenta, so to evaluate at a prescribed +# phase-space point the shipped check_sa.cc is patched (as the acceptance test +# TestStandaloneMg7CrossSymmetry does): its flavorID cap is lifted so the +# extended crossing ids pass validation, and, when MG_MOMFILE is set, the +# momenta read from that file are written into every event of the SIMD page +# before the matrix element is computed. +_MG7_CAP_FROM = 'if( flavorID >= CPPProcess::nmaxflavor )' +_MG7_CAP_TO = ('if( flavorID >= CPPProcess::nmaxflavor * ' + '(unsigned)((CPPProcess::npar+1)*(CPPProcess::npar+1)) )') +_MG7_MOM_FROM = ' prsk->getMomentaFinal();' +_MG7_MOM_TO = ( + ' prsk->getMomentaFinal();\n' + ' if( const char* mgmf = getenv("MG_MOMFILE") ) {\n' + ' std::ifstream mgin( mgmf );\n' + ' std::vector mgbuf( (std::size_t)CPPProcess::npar*4 );\n' + ' for( std::size_t mgk = 0; mgk < mgbuf.size(); mgk++ ) mgin >> mgbuf[mgk];\n' + ' for( unsigned int mgie = 0; mgie < nevt; mgie++ )\n' + ' for( int mgip = 0; mgip < CPPProcess::npar; mgip++ )\n' + ' for( int mgi4 = 0; mgi4 < 4; mgi4++ )\n' + ' MemoryAccessMomenta::ieventAccessIp4Ipar( hstMomenta.data(), mgie, mgi4, mgip ) = mgbuf[mgip*4+mgi4];\n' + ' }') + + +class _Mg7CrossingBackend(object): + """The cudacpp CPU-SIMD standalone (standalone_mg7) crossing backend. + + The vectorisation width is selectable via options['simd'] (see + MG7_SIMD_CHOICES): it is passed to the madmatrix build as + 'BACKEND=cpp', so the same crossing self-check can run on scalar + (none), SSE4, AVX2 or AVX-512 code, or let madmatrix auto-detect ('auto'). + """ + output_format = 'standalone_mg7' + needs_matrix_element = True + + def __init__(self, options=None): + self.compiler = os.environ.get('CXX', 'g++') + simd = (options or {}).get('simd', 'auto') + if simd not in MG7_SIMD_CHOICES: + raise InvalidCmd( + "Unknown --simd '%s' for standalone_mg7; choose one of %s." + % (simd, ', '.join(MG7_SIMD_CHOICES))) + self.simd = simd + + def build(self, pdir, env): + if not shutil.which(self.compiler): + return False + check = pjoin(pdir, 'check_sa.cc') + try: + with open(check) as fsock: + src = fsock.read() + except IOError: + return False + src = src.replace(_MG7_CAP_FROM, _MG7_CAP_TO) + src = src.replace(_MG7_MOM_FROM, _MG7_MOM_TO, 1) + with open(check, 'w') as fsock: + fsock.write(src) + make_cmd = ['make', '-j2', 'BACKEND=cpp%s' % self.simd, 'check_sa.exe'] + with open(os.devnull, 'w') as devnull: + rc = subprocess.call(make_cmd, cwd=pdir, + stdout=devnull, stderr=subprocess.STDOUT, + env=env) + return rc == 0 and os.path.isfile(pjoin(pdir, 'check_sa.exe')) + + def enumerate(self, pdir, matrix_element, card, env, identity_only): + return _crossing_pdg_entries(matrix_element, identity_only=identity_only) + + def evaluate(self, pdir, items, card, env): + values = [] + for item in items: + momfile = pjoin(pdir, 'mom_cross.dat') + with open(momfile, 'w') as fsock: + for leg in item['momenta']: + fsock.write(' '.join('%.17e' % float(c) for c in leg) + '\n') + run_env = dict(env) + run_env['MG_MOMFILE'] = momfile + try: + out = subprocess.check_output( + ['./check_sa.exe', 'perf', '-v', '-f', + str(int(item['index'])), '1', '8', '1'], + cwd=pdir, env=run_env, stderr=subprocess.STDOUT).decode() + except subprocess.CalledProcessError: + values.append(None) + continue + mes = re.findall(r'Matrix element =\s*([-\d.eE+]+)', out) + values.append(float(mes[0]) if mes else None) + return values + + +_CROSSING_BACKENDS = { + 'standalone': _FortranCrossingBackend, + 'standalone_cpp': _CppCrossingBackend, + 'standalone_mg7': _Mg7CrossingBackend, +} + + +def _crossing_dir_name(matrix_element): + """The SubProcesses/P* directory name generated for this matrix element. + + Both the C++ and the mg7 exporters name the directory ``P`` + the process + shell string (export_cpp uses P_ and export_mg7 uses + P, and shell_string already is ``_``), so this + single reconstruction correlates a matrix element to its output directory + for either backend without instantiating a throwaway exporter. + """ + return 'P' + matrix_element.get('processes')[0].shell_string() + + def check_crossing(process_definition, param_card=None, options=None, cmd=FakeInterface()): - """Compare the crossing-enabled and crossing-disabled fortran standalone. + """Compare the crossing-enabled and crossing-disabled standalone output. - The process is generated twice and output to fortran standalone (with the - f2py wrapper): + 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 ``FLAV_IDX`` + 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 ``FLAV_IDX`` reproducing that PDG signature, when one exists) - at the very same phase-space point, giving ``value_crossed``. The two must - agree: this exercises ``APPLY_CROSSING`` / the dynamic NSF / the crossed - averaging denominator against a value computed with none of them. + (a non-identity extended index reproducing that PDG signature, when one + exists) at the very same phase-space point, giving ``value_crossed``. The + two must agree: this exercises the crossing (leg permutation / dynamic NSF / + crossed averaging denominator) against a value computed with none of them. + + The backend abstraction (:data:`_CROSSING_BACKENDS`) parametrises the three + steps that differ per exporter -- the ``output`` format, the build, and how + an extended index is evaluated -- while the generate/match/momenta logic is + shared. ``'standalone'`` enumerates the crossed PDG at runtime via f2py + (GET_PDG_FOR_FLAVOR); ``'standalone_cpp'`` / ``'standalone_mg7'`` have no + runtime accessor and compute it in python from the same crossing tables + (:func:`_crossing_pdg_entries`), then evaluate through a compiled driver. Processes whose crossing is auto-disabled by an s-channel constraint (e.g. ``u u~ > z > e+ e-``: what is s-channel in one arrangement is not in its @@ -4022,7 +4311,6 @@ def check_crossing(process_definition, param_card=None, options=None, Returns a list of result dicts consumed by :func:`output_crossing`. """ - import json import tempfile import madgraph.interface.master_interface as master_interface @@ -4030,6 +4318,13 @@ def check_crossing(process_definition, param_card=None, options=None, options = {} energy = float(options.get('energy', 1000.0)) + exporter = options.get('exporter', 'standalone') + if exporter not in _CROSSING_BACKENDS: + raise InvalidCmd( + "Unknown crossing exporter '%s'; choose one of %s." + % (exporter, ', '.join(CROSSING_EXPORTERS))) + backend = _CROSSING_BACKENDS[exporter](options) + model = process_definition.get('model') proc_line = options.get('proc_line') if proc_line is None: @@ -4044,7 +4339,13 @@ def check_crossing(process_definition, param_card=None, options=None, tmproot = tempfile.mkdtemp(prefix='mg5_crosscheck_') def _generate(use_crossing, name): - """Generate + output standalone; return the list of P* directories.""" + """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) @@ -4060,15 +4361,27 @@ def _generate(use_crossing, name): mgcmd.exec_cmd('generate %s --use_crossing=%s' % (proc_line, use_crossing), printcmd=False) outdir = pjoin(tmproot, name) - mgcmd.exec_cmd('output standalone %s -f' % outdir, printcmd=False) + 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 the module (initialisemodel) and momenta generation. + # default for both evaluation and momenta generation. if param_card: shutil.copy(param_card, pjoin(outdir, 'Cards', 'param_card.dat')) - return outdir, pdirs + return outdir, pdirs, me_by_pdir def _pdg_label(pdg): try: @@ -4084,19 +4397,19 @@ def _pdg_label(pdg): results = [] env = _crossing_build_env() try: - ref_out, ref_pdirs = _generate('False', 'reference') - cross_out, cross_pdirs = _generate('True', 'crossing') + 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] = _crossing_build_f2py(pdir, env) + 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): - # No usable module on either side: signal a skip rather than a fail. - return [{'status': 'build_failed'}] + # 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*: @@ -4107,19 +4420,19 @@ def _pdg_label(pdg): # 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 - # APPLY_CROSSING rather than a plain identity when the process line - # spans crossable subprocesses. + # 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 - answer = _crossing_run_driver( - pdir, {'mode': 'enumerate', 'card': cross_card}, env) - if not answer: + 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 answer['entries']: + for idx, cross, _flav, pdg in entries: key = tuple(pdg) if key not in module_map: module_map[key] = (idx, cross) @@ -4136,11 +4449,11 @@ def _pdg_label(pdg): for pdir in ref_pdirs: if not built.get(pdir): continue - answer = _crossing_run_driver( - pdir, {'mode': 'enumerate', 'card': ref_card}, env) - if not answer: + entries = backend.enumerate(pdir, ref_me.get(pdir), ref_card, env, + identity_only=True) + if not entries: continue - for idx, cross, _flav, pdg in answer['entries']: + for idx, cross, _flav, pdg in entries: if cross != 0: continue # reference has no genuine crossing anyway key = tuple(pdg) @@ -4158,10 +4471,7 @@ def _pdg_label(pdg): 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] - answer = _crossing_run_driver( - pdir, {'mode': 'evaluate', 'card': ref_card, 'items': items}, - env) - values = answer['values'] if answer else [None] * len(items) + values = backend.evaluate(pdir, items, ref_card, env) vi = 0 for idx, key in jobs: if momenta_by_pdg[key] is None: @@ -4181,10 +4491,7 @@ def _pdg_label(pdg): for cpdir, jobs in crossed_jobs.items(): items = [{'index': cidx, 'momenta': momenta_by_pdg[key]} for cidx, key in jobs] - answer = _crossing_run_driver( - cpdir, {'mode': 'evaluate', 'card': cross_card, - 'items': items}, env) - values = answer['values'] if answer else [None] * len(items) + values = backend.evaluate(cpdir, items, cross_card, env) for (cidx, key), value in zip(jobs, values): crossed_val[(cpdir, cidx, key)] = value @@ -4194,6 +4501,11 @@ def _pdg_label(pdg): 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)) @@ -4204,6 +4516,8 @@ def _pdg_label(pdg): 'value_direct': value_direct, 'value_crossed': value_crossed, 'cross_code': cross_code, + 'crossing_matched': crossing_matched, + 'exporter': exporter, 'status': 'ok', }) finally: @@ -4242,13 +4556,25 @@ def output_crossing(comparison_results, output='text'): 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 ``FLAV_IDX``). ``output='fail'`` returns the number of failures + 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': - msg = ("Could not build the f2py matrix2py module (f2py / numpy build " - "backend unavailable); the crossing check cannot run here.") + 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 @@ -4267,7 +4593,10 @@ def output_crossing(comparison_results, output='text'): no_check_proc_list = [] any_crossed = False - res_str = fixed_string_length(process_header, proc_col_size) + \ + 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) + \ @@ -4281,8 +4610,23 @@ def output_crossing(comparison_results, output='text'): 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) + \ - " * No matrix element found for a crossing, not checked *" + " * Not checked: %s *" % reason continue cross_code = one_comp.get('cross_code') diff --git a/madmatrix/model_handling.py b/madmatrix/model_handling.py index 7e6ed0338..85359fb98 100644 --- a/madmatrix/model_handling.py +++ b/madmatrix/model_handling.py @@ -1733,6 +1733,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') @@ -1763,6 +1767,9 @@ def get_sigmaKin_lines(self, color_amplitudes, write=True): replace_dict['nb_channel'] = len(self.multi_channel_map) replace_dict['nb_color'] = max(1, len(self.matrix_elements[0].get('color_basis'))) + # Crossing-symmetry hole (per-event denominator); identity fill when off. + replace_dict.update(self.get_madmatrix_crossing_dict(self.matrix_elements[0])) + if write: file = self.read_template_file(self.process_sigmaKin_function_template) % replace_dict file = strip_banner(file, banner_mark = "!") # skip first 8 lines in process_sigmaKin_function.inc (copyright) @@ -1777,11 +1784,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 @@ -2207,6 +2222,144 @@ def get_reset_jamp_lines(self, color_amplitudes): ret_lines = "" return ret_lines + # ------------------------------------------------------------------ + # Crossing symmetry (extended flavor id) for the madmatrix / cudacpp + # CPU-SIMD backend. Mirrors export_cpp.get_crossing_replace_dict and the + # fortran path but adapted to the SIMD structure of this backend: the + # per-event momentum permutation lives in calculate_jamps (emitted by the + # helas writer, gated by use_crossing_ic), while the crossing-aware + # good-helicity union, the per-event denominator and the crossed flavorPDG + # accessor are filled here. When self.use_crossing is False every hole gets + # the historical code so the output is byte-for-byte the old one. + # ------------------------------------------------------------------ + def get_madmatrix_crossing_dict(self, matrix_element): + plain = { + 'crossing_decl': '', + 'goodhel_scan_count': 'nmaxflavor', + 'goodhel_scan_skip': '', + 'sigmakin_denominator': + ' MEs_sv = MEs_sv * broken_symmetry_factor(iflavorVec[ievt0]) / helcolDenominators[0];', + 'flavorpdg_body': ' return flavorPDGs[iflavor][ipar];', + } + 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()) + spincol = tables['spincol'] + basepid = tables['basepid'] + source = tables['source'] + perm = tables['perm'] + ic = tables['ic'] + + # Crossed per-leg signed PDG for every extended flavor id (physical PDG, + # conjugated where the leg swapped side; 0 for an invalid crossing). + n_flavors, pdg_flat, antipdg_flat = Fort._build_flav_pdg_tables(self, me) + fpdg = [] + for cross in range(ncross): + for flav0 in range(nflav): + for k in range(nexternal): + if spincol[cross] == 0: + fpdg.append(0) + continue + src = perm[cross * nexternal + k] + if ic[cross * nexternal + k] == 1: + fpdg.append(pdg_flat[flav0 * nexternal + src]) + else: + fpdg.append(antipdg_flat[flav0 * nexternal + src]) + + def arr(vals): + return '{ ' + ', '.join(str(v) for v in vals) + ' }' + + crossing_decl = ( + " // ---- Crossing symmetry tables (extended id = cross*nmaxflavor + flav) ----\n" + " // Initial-state spin*color average per crossing (0 = crossing that\n" + " // must not be applied: out of range, impossible, or overlapping swap).\n" + " static const int spincol_cross[%(ncross)d] = %(spincol)s;\n" + " // Crossed physical signed PDG per (extended id, leg); 0 if invalid.\n" + " static const int flavorPDGs_cross[%(nfpdg)d] = %(fpdg)s;\n" + " // Identical-final-state factor of the crossed process (flavor\n" + " // dependent -> runtime). FLAVOR is not permuted, so slot k reads the\n" + " // original leg that moved into it via src_cross.\n" + " __device__ int ident_cross( int cross, int iflavor )\n" + " {\n" + " static const int basepid_cross[%(ncrossN)d] = %(basepid)s;\n" + " static const int src_cross[%(ncrossN)d] = %(source)s;\n" + " const int off = cross * npar;\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 ( basepid_cross[off + k] == basepid_cross[off + l] &&\n" + " cFlavors[iflavor][src_cross[off + k]] == cFlavors[iflavor][src_cross[off + l]] )\n" + " {\n" + " used[l] = true;\n" + " n = n + 1;\n" + " fact = fact * n;\n" + " }\n" + " }\n" + " }\n" + " return fact;\n" + " }\n" + ) % {'ncross': ncross, 'spincol': arr(spincol), + 'nfpdg': ncross * nflav * nexternal, 'fpdg': arr(fpdg), + 'ncrossN': ncross * nexternal, 'basepid': arr(basepid), + 'source': arr(source), 'ninitial': ninitial} + + 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" + " fptype_sv denom_sv;\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 f;\n" + " if ( dcr == 0 )\n" + " f = (fptype)broken_symmetry_factor( dfl ) / helcolDenominators[0];\n" + " else if ( spincol_cross[dcr] == 0 )\n" + " f = (fptype)0.; // invalid crossing (out of range / overlapping swap) -> ME 0\n" + " else\n" + " f = (fptype)1. / ( (fptype)spincol_cross[dcr] * (fptype)ident_cross( dcr, dfl ) );\n" + " reinterpret_cast( &denom_sv )[ieppV] = f;\n" + " }\n" + " MEs_sv = MEs_sv * denom_sv;" + ) + + flavorpdg_body = ( + " const int ncross = ( npar + 1 ) * ( npar + 1 );\n" + " if ( iflavor < 0 || iflavor >= ncross * nmaxflavor ) return 0;\n" + " return flavorPDGs_cross[iflavor * npar + ipar];" + ) + + return { + 'crossing_decl': crossing_decl, + # Good-helicity UNION now also spans crossings: sample every valid + # extended flavor id (skip spincol==0) so cGoodHel covers the crossed + # helicity rows too. A helicity that vanishes for a given event's + # crossing simply contributes 0 at run time. + 'goodhel_scan_count': str(ncross * nflav), + 'goodhel_scan_skip': + ' if ( spincol_cross[iflav / nmaxflavor] == 0 ) continue;\n ', + 'sigmakin_denominator': sigmakin_denominator, + 'flavorpdg_body': flavorpdg_body, + } + #------------------------------------------------------------------------------------ import madgraph.core.helas_objects as helas_objects @@ -2476,13 +2629,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()): @@ -2623,13 +2776,135 @@ 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 '' + + @staticmethod + def _crossing_int_2d(flat, ncols): + """Format a flat int list as a C++ 2-D initializer { {...}, {...} }.""" + rows = [] + for start in range(0, len(flat), ncols): + rows.append('{ ' + ', '.join(str(v) for v in flat[start:start+ncols]) + ' }') + return '{\n ' + ',\n '.join(rows) + ' }' + + 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).""" + tables = self._crossing_tables(matrix_element) + nexternal = tables['nexternal'] + ncross = (nexternal + 1) * (nexternal + 1) + perm = self._crossing_int_2d(tables['perm'], nexternal) + ic = self._crossing_int_2d(tables['ic'], nexternal) + return """#ifndef MGONGPUCPP_GPUIMPL + // === CROSSING SYMMETRY: per-event momentum permutation (NOT vectorized) === + constexpr int ncross = ( npar + 1 ) * ( npar + 1 ); + static const int cross_perm[ncross][npar] = %(perm)s; + static const int cross_ic[ncross][npar] = %(ic)s; + 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 ); + for( int s = 0; s < npar; ++s ) + { + const int src = cross_perm[xcr][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)cross_ic[xcr][s]; + } + } +#endif +""" % {'perm': perm, 'ic': ic} + + 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. The + helicity is taken from the destination slot (cHel[ihel][s]); summing + over the good-helicity UNION then reproduces the crossed |M|^2 (the + helicity permutation is absorbed by the sum). GPU is 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') + + def one_call(sign, obj): + if spin == 1: + call = '%s( xmom, %+d, cFlavors[iflavor][%d], %s, %d );' % \ + (routine, sign, s, obj, s) + else: + call = '%s( xmom, m_pars->%s, cHel[ihel][%d], %+d, cFlavors[iflavor][%d], %s, %d );' % \ + (routine, mass, s, sign, s, obj, s) + return self.format_coupling(call) + + lines = ['#ifndef MGONGPUCPP_GPUIMPL'] + lines.append(' ' + one_call(nsf, 'aloha_x[0]')) + lines.append(' ' + one_call(-nsf, 'aloha_x[1]')) + # Lane-wise blend: icsign[s]==+1 -> nsf=+base (aloha_x[0]); -1 -> 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) + # The flavor index is the same in both scratch calls; copy it onto the + # real object (both scratch calls set it, but the blended target keeps + # its default -1 otherwise, which the flavor-masked vertices treat as + # "vanishing" and zero the amplitude). + 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 3183f9e45..0b14b7744 100644 --- a/madmatrix/output.py +++ b/madmatrix/output.py @@ -70,6 +70,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/tests/acceptance_tests/test_standalone_cross_symmetry.py b/tests/acceptance_tests/test_standalone_cross_symmetry.py new file mode 100644 index 000000000..d9f8b23f0 --- /dev/null +++ b/tests/acceptance_tests/test_standalone_cross_symmetry.py @@ -0,0 +1,1788 @@ +################################################################################ +# +# Copyright (c) 2009 The MadGraph5_aMC@NLO Development team and Contributors +# +# This file is a part of the MadGraph5_aMC@NLO project, an application which +# automatically generates Feynman diagrams and matrix elements for arbitrary +# high-energy processes in the Standard Model and beyond. +# +# It is subject to the MadGraph5_aMC@NLO license which should accompany this +# distribution. +# +# For more information, visit madgraph.phys.ucl.ac.be and amcatnlo.web.cern.ch +# +################################################################################ +"""Check the crossing-symmetry support of the fortran standalone output. + +The standalone SMATRIX takes a flavor index (IFLAV / FLAV_IDX). Its range is +extended so that a single value carries both the flavor and a crossing to +apply, decoded as:: + + cross = (IFLAV-1) / NFLAV + flav = mod(IFLAV-1, NFLAV) + 1 ! the index used for masking/... + I = cross / (NEXTERNAL+1) + J = mod(cross, NEXTERNAL+1) + +I and J are the crossing partners of particle 1 and particle 2 respectively: +particle 1 is swapped with particle I and particle 2 with particle J, with 0 +meaning "leave that particle alone". IFLAV in [1,NFLAV] gives cross=0, i.e. the +identity, so existing callers are unaffected. The base is NEXTERNAL+1 rather +than NEXTERNAL so that I and J run over 0..NEXTERNAL and can designate the last +particle as well. + +Swapping a particle across the initial/final state flips its NSF/NSV helas flag +(which is what negates the momentum stored in the wavefunction) and flips its +helicity, so the crossed call evaluates the same analytic amplitude in a +different kinematic region. + +The processes u u~ > g g and u g > u g are exactly each other's crossing under +(I=0, J=3): swapping particle 2 with particle 3 turns the incoming u~ into an +outgoing u and the outgoing g into an incoming g. Because the swap also +reorders the legs, the crossed call takes the *other* process's natural +momentum layout, so this test feeds both codes the very same momenta. + +Crossing preserves the raw sum over helicities and colors of |M|^2, not the +averaged matrix element: the two processes have different averaging/symmetry +denominators (IDEN=72 for u u~ > g g, IDEN=96 for u g > u g, since crossing a +gluon into the initial state changes the color average and un-identifies the +two final state gluons). SMATRIX divides by the IDEN of the *crossed* process, +so a crossed call returns the properly averaged matrix element of the process +it crosses into and can be compared directly against the other code. +""" + +from __future__ import absolute_import + +import 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' + +# q q~ > g q q~ is likewise mapped onto q g > q q q~ by the same (I=0, J=3) +# crossing: the incoming q~ becomes the outgoing q of slot 3 and the outgoing g +# becomes an incoming one, leaving the legs ordered as (q, g, q, q, q~). +# Repeated over the quark flavors to exercise the flavor tables / masks and the +# BROKEN_SYM factor, which sees two identical final u's on the crossed side. +PROC_QQX_GQQX = '%(q)s %(q)s~ > g %(q)s %(q)s~' +PROC_QG_QQQX = '%(q)s g > %(q)s %(q)s %(q)s~' +QUARK_FLAVORS = ['u', 'd', 's', 'c'] + +# The merged (multi-flavor) form of the same pair. Generated with the group +# labels so that flavor grouping keeps every quark combination in a single +# matrix element, which is the only way to get NFLAV>1 and a non-trivial mask. +PROC_MERGED_QQX_GQQX = '_quark _anti_quark > g _quark _anti_quark' +PROC_MERGED_QG_QQQX = '_quark g > _quark _quark _anti_quark' + +# The same merged process constrained to a single squared coupling order. A +# squared-order constraint is what sets the process' 'split_orders', which is +# what makes write_matrix_element_v4 pick matrix_standalone_splitOrders_v4.inc +# instead of the default template. Same final state, so BROKEN_SYM is still 2 +# on the rows where the two final quarks differ. +PROC_MERGED_QG_QQQX_SO = '_quark g > _quark _quark _anti_quark QED^2==0' + +# Processes constraining an s-channel propagator. A crossing moves legs between +# the initial and the final state, so what is s-channel in the generated process +# is not s-channel in its crossings: `> z >` (required) and `$$ z` (forbidden, +# diagram removed) must therefore disable the crossing machinery on their own. +# A single `$ z` only forbids the on-shell *region* of a kept diagram, which +# survives the crossing, so it must NOT disable anything. +PROC_REQUIRED_S = 'u u~ > z > e+ e-' +PROC_FORBIDDEN_S = 'u u~ > e+ e- $$ z' +PROC_FORBIDDEN_ONSH_S = 'u u~ > e+ e- $ z' +PROC_UNCONSTRAINED = 'u u~ > e+ e-' + +# Every routine/table that only exists to decode an extended FLAV_IDX. +CROSSING_MACHINERY_NAMES = [ + 'APPLY_CROSSING', 'APPLY_CROSSING_TABLE', 'GET_CROSS_PERM', + 'GET_SPINCOL_CROSS', 'GET_IDENT_CROSS', 'SWAP_LEGS', + 'SPINCOL_CROSS_TABLE', 'BASEPID_CROSS_TABLE', 'SRC_CROSS_TABLE'] + +# cross = I*(NEXTERNAL+1) + J = 0*5 + 3 = 3. Both processes have NFLAV=1, so +# IFLAV = cross*NFLAV + flav = 3*1 + 1 = 4. +NEXTERNAL = 4 +CROSS_2_3 = 0 * (NEXTERNAL + 1) + 3 +# Same crossing for the 2->3 pair, where the base is NEXTERNAL+1 = 6. +NEXTERNAL_5 = 5 +CROSS_2_3_5 = 0 * (NEXTERNAL_5 + 1) + 3 +# Crossing particle 2 with the *last* particle. Only expressible because the +# base is NEXTERNAL+1: with base NEXTERNAL, mod(cross, NEXTERNAL) could never +# yield NEXTERNAL. +CROSS_2_LAST = 0 * (NEXTERNAL + 1) + NEXTERNAL +IFLAV_IDENTITY = 1 + + +def _iflav(cross, flav, nflav): + """Encode a crossing code and a flavor index into the extended IFLAV.""" + return cross * nflav + flav + + +# Subprocess probe for the good-helicity remap (GHREMAP) relation. Run against +# a compiled matrix2py module: for every DERIVABLE crossing (active partners all +# final), the crossed good-helicity set -- the rows where py_smatrixhel_idx is +# non-zero, unioned over many phase-space points -- must equal the identity +# good-helicity set mapped through the crossing's own row permutation sigma +# (config h -> (ic[k]*nhel[perm[k],h])_k). This is the invariant the generated +# GHREMAP encodes, so a wrong table (or a wrong derivability condition) breaks +# the fix. Run in a subprocess: importing an f2py .so into the test interpreter +# would leak a compiled module and clash across tests. +# +# GOTCHA locked in by this probe: 3 phase-space points are NOT enough -- for +# u u~ > g g, cross=23 then showed 6 non-zero rows instead of 8 (an accidental +# zero at the probed points). NPTS is deliberately >= 12. +_GOODHEL_PROBE = r''' +import sys, math +import numpy as np +sys.path.insert(0, %(pdir)r) +import matrix2py as m + +NINITIAL = %(ninitial)d +NPTS = %(npts)d + +def get_crossing_permutation(cross, nexternal): + base = nexternal + 1 + i_part, j_part = cross // base, cross %% base + perm = list(range(nexternal)); ic = [1] * nexternal + def swap(a, b): + perm[a], perm[b] = perm[b], perm[a]; ic[a] = -ic[a]; ic[b] = -ic[b] + valid = not (i_part not in (0, 1) and j_part not in (0, 2) + and (i_part == 2 or j_part == 1 or i_part == j_part)) + if i_part not in (0, 1): swap(0, i_part - 1) + if j_part not in (0, 2): swap(1, j_part - 1) + return perm, ic, valid + +def rambo(nf, ecm, rng): + q = np.zeros((4, nf)) + for i in range(nf): + c = 2 * rng.random() - 1 + s = math.sqrt(1 - c * c) + phi = 2 * math.pi * rng.random() + r1, r2 = rng.random(), rng.random() + q[0, i] = -math.log(r1 * r2) + q[3, i] = q[0, i] * c + q[2, i] = q[0, i] * s * math.cos(phi) + q[1, i] = q[0, i] * s * math.sin(phi) + Q = q.sum(axis=1) + M = math.sqrt(Q[0]**2 - Q[1]**2 - Q[2]**2 - Q[3]**2) + b = -Q[1:] / M; g = Q[0] / M; a = 1.0 / (1.0 + g); x = ecm / M + p = np.zeros((4, nf)) + for i in range(nf): + bq = b @ q[1:, i] + p[1:, i] = x * (q[1:, i] + b * (q[0, i] + a * bq)) + p[0, i] = x * (g * q[0, i] + bq) + return p + +def momenta(nexternal, ninitial, npts, seed): + rng = np.random.default_rng(seed) + ecm = 1000.0; nf = nexternal - ninitial; ps = [] + for _ in range(npts): + P = np.zeros((4, nexternal)) + P[0, 0] = ecm / 2; P[3, 0] = ecm / 2 + if ninitial >= 2: + P[0, 1] = ecm / 2; P[3, 1] = -ecm / 2 + P[:, ninitial:] = rambo(nf, ecm, rng) + ps.append(np.asfortranarray(P)) + return ps + +m.py_initialisemodel(%(card)r) +nflav, nexternal_l, ncross = m.py_get_flavor_layout() +_iden, nhel = m.py_get_nhel_idx(1) +nhel = np.array(nhel) # (nexternal, ncomb) +nexternal, ncomb = nhel.shape +ps = momenta(nexternal, NINITIAL, NPTS, seed=20260721) +row_of = {tuple(nhel[:, h]): h + 1 for h in range(ncomb)} + +def good_set(flav_idx): + good = set() + for P in ps: + for h in range(1, ncomb + 1): + if abs(m.py_smatrixhel_idx(P, h, flav_idx)) > 1e-30: + good.add(h) + return good + +g_id = good_set(1) +assert g_id, 'identity has no good helicity -- probe is broken' +base = nexternal + 1 +checked = genuine = 0 +for cross in range(1, base * base): + perm, ic, valid = get_crossing_permutation(cross, nexternal) + if not valid: + continue + I, J = cross // base, cross %% base + # DERIVABLE = the crossing's active partners are all final particles. + final_only = ((I in (0, 1) or I > NINITIAL) and (J in (0, 2) or J > NINITIAL)) + if not final_only: + continue + flav_idx = cross * nflav + 1 + # Skip a crossing that is not evaluable (spincol==0 -> SMATRIX returns 0). + tot = sum(abs(m.py_smatrixhel_idx(ps[0], h, flav_idx)) + for h in range(1, ncomb + 1)) + if tot == 0: + continue + sigma = {} + for h in range(ncomb): + cfg = tuple(ic[k] * nhel[perm[k], h] for k in range(nexternal)) + hp = row_of.get(cfg) + assert hp is not None, 'cross %%d: sigma is not a row bijection' %% cross + sigma[h + 1] = hp + expected = {sigma[h] for h in g_id} + g_cr = good_set(flav_idx) + assert g_cr == expected, ( + 'cross %%d (I=%%d,J=%%d): crossed good-hel %%s != sigma(identity) %%s' + %% (cross, I, J, sorted(g_cr), sorted(expected))) + checked += 1 + if perm != list(range(nexternal)): + genuine += 1 +assert genuine >= 1, 'no genuine (non-identity) derivable crossing was checked' +print('GHREMAP_RELATION_OK checked=%%d genuine=%%d points=%%d' %% + (checked, genuine, NPTS)) +''' + + +class TestStandaloneCrossSymmetry(unittest.TestCase): + """u u~ > g g and u g > u g must reproduce each other under crossing.""" + + # A crossing swaps a leg between the initial and final state, so it probes + # a genuinely different kinematic region of the same analytic amplitude. + # Compare at a few scattering angles rather than a single point. + cos_thetas = [0.3, -0.62, 0.85] + energy = 1000.0 + tolerance = 1e-11 + + debugging = getattr(unittest, 'debug', False) + + def setUp(self): + self.cmd = cmd_interface.MasterCmd() + self.cmd.no_notification() + prefix = 'cross_debug_' if self.debugging else 'cross_' + self.tmpdir = tempfile.mkdtemp(prefix=prefix) + + def tearDown(self): + if not self.debugging and os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + # ------------------------------------------------------------------ + # generation / build helpers + # ------------------------------------------------------------------ + def _generate(self, process, name, options='', split_orders=False): + """Generate the standalone output for `process`, return its P* dir. + + `options` is appended to the generate command (e.g. --use_crossing=False). + `split_orders` selects the driver for the split-orders template, whose + density entry point takes the FLAVOR array rather than a FLAV_IDX. + """ + pdir = self._output_standalone(process, name, options) + self._write_driver(pdir, split_orders=split_orders) + self._build(pdir) + return pdir + + def _output_standalone(self, process, name, options=''): + """Write the standalone output for `process` and return its P* dir. + + Split out of _generate for the tests that only inspect the emitted + fortran and so have no reason to pay for a compile. + """ + outdir = pjoin(self.tmpdir, name) + self.cmd.exec_cmd('set automatic_html_opening False') + self.cmd.exec_cmd('set group_subprocesses False') + self.cmd.exec_cmd('set apply_flavor_grouping True') + self.cmd.exec_cmd('import model sm') + self.cmd.exec_cmd(('generate %s %s' % (process, options)).strip()) + self.cmd.exec_cmd('output standalone %s -f' % outdir) + + subproc_root = pjoin(outdir, 'SubProcesses') + pdirs = [pjoin(subproc_root, name) for name in sorted(os.listdir(subproc_root)) + if name.startswith('P') and os.path.isdir(pjoin(subproc_root, name))] + self.assertEqual(len(pdirs), 1, + 'Expected a single subprocess directory for %s, got %s' + % (process, pdirs)) + return pdirs[0] + + def _matrix_code(self, pdir): + """The emitted matrix.f with comment lines stripped. + + Only definitions/uses must be matched, not the prose: a comment may + legitimately still mention the machinery to explain its absence. + """ + with open(pjoin(pdir, 'matrix.f')) as fsock: + source = fsock.read() + return '\n'.join(line for line in source.split('\n') + if not line.lstrip().upper().startswith('C')) + + def _write_driver(self, pdir, split_orders=False): + """Replace check_sa.f by a driver reading momenta+IFLAV from a file. + + Reading the input rather than hardcoding it lets each process be + compiled once and then probed at many points / flavor indices. + + The split-orders template has no crossing machinery and hence no + GET_DENSITY_IDX: its density entry point takes the FLAVOR array, so the + driver resolves the index through GET_FLAVOR first. Everything else + (SMATRIX, GET_FLAVOR, GET_FLAVOR_INDEX) has the same interface, so only + that one call differs. + """ + if split_orders: + density_call = ''' CALL GET_FLAVOR(FLAV_IDX, FLAVOR) + CALL GET_DENSITY(P, DPOS, 1, ALLOW_HEL, 2, FLAVOR, + & 0D0, 0D0, INTER)''' + else: + density_call = ''' CALL GET_DENSITY_IDX(P, DPOS, 1, ALLOW_HEL, 2, FLAV_IDX, + & 0D0, 0D0, INTER)''' + # GET_NHEL_IDX / GET_PDG_FOR_FLAVOR only exist in matrix_standalone_v4; + # the split-orders template lacks them, so its driver must not reference + # them or it will not link. + if split_orders: + nhel_idx_call = ''' WRITE(*,*) 'IDEN= ', -1 + WRITE(*,*) 'PDG= ', 0''' + else: + nhel_idx_call = ''' CALL GET_NHEL_IDX(FLAV_IDX, IDEN_STAR, NHEL_STAR) + CALL GET_PDG_FOR_FLAVOR(FLAV_IDX, PDGS) + WRITE(*,*) 'IDEN= ', IDEN_STAR + WRITE(*,*) 'PDG= ', (PDGS(I),I=1,NEXTERNAL)''' + # GET_NHEL writes NEXTERNAL*NCOMB entries into NHEL_STAR using its own + # NCOMB; an oversized array in the caller is safe and avoids parsing + # NCOMB out of matrix.f. + driver = ''' PROGRAM DRIVER + use model_object + IMPLICIT NONE + INCLUDE "coupl.inc" + INCLUDE "nexternal.inc" + INTEGER NCOMB_MAX + PARAMETER (NCOMB_MAX=4096) + REAL*8 P(0:3,NEXTERNAL), MATELEM + INTEGER FLAV_IDX, I, J, MODE + INTEGER FLAVOR(NEXTERNAL) + INTEGER GET_FLAVOR_INDEX + INTEGER NHEL_STAR(NEXTERNAL,NCOMB_MAX), IDEN_STAR + INTEGER DPOS(1), ALLOW_HEL(2) + INTEGER PDGS(NEXTERNAL) + DOUBLE COMPLEX INTER(3) + call setpara('param_card.dat') + OPEN(UNIT=42,FILE='cross_input.dat',STATUS='OLD') + READ(42,*) MODE + IF (MODE.EQ.1) THEN + READ(42,*) FLAV_IDX + CALL GET_FLAVOR(FLAV_IDX, FLAVOR) + WRITE(*,*) 'POS= ', (FLAVOR(I),I=1,NEXTERNAL) + ELSEIF (MODE.EQ.2) THEN + READ(42,*) (FLAVOR(I),I=1,NEXTERNAL) + WRITE(*,*) 'IDX= ', GET_FLAVOR_INDEX(FLAVOR) + ELSEIF (MODE.EQ.4) THEN +C Density matrix: interference between the helicity states of one leg. +C GET_DENSITY_IDX takes the index directly, so it can carry a crossing; +C the FLAVOR-array entry point cannot express one. + READ(42,*) FLAV_IDX + READ(42,*) DPOS(1) + DO I=1,NEXTERNAL + READ(42,*) (P(J,I),J=0,3) + ENDDO + ALLOW_HEL(1) = +1 + ALLOW_HEL(2) = -1 +%(density_call)s + DO I=1,3 + WRITE(*,*) 'INTER= ', DREAL(INTER(I)), DIMAG(INTER(I)) + ENDDO + ELSEIF (MODE.EQ.5) THEN +C The f2py-facing crossing accessors: GET_NHEL_IDX returns the crossed +C averaging denominator (unlike GET_NHEL, which only knows the static +C uncrossed one), and GET_PDG_FOR_FLAVOR returns the per-leg signed PDG +C of the process the extended FLAV_IDX selects (crossed and conjugated). + READ(42,*) FLAV_IDX +%(nhel_idx_call)s + ELSE + READ(42,*) FLAV_IDX + DO I=1,NEXTERNAL + READ(42,*) (P(J,I),J=0,3) + ENDDO + CALL SMATRIX(P,FLAV_IDX,MATELEM) + CALL GET_NHEL(IDEN_STAR,NHEL_STAR) + WRITE(*,*) 'ANS= ', MATELEM + WRITE(*,*) 'IDEN= ', IDEN_STAR + ENDIF + CLOSE(42) + END +''' + with open(pjoin(pdir, 'check_sa.f'), 'w') as fsock: + fsock.write(driver % {'density_call': density_call, + 'nhel_idx_call': nhel_idx_call}) + + def _build(self, pdir): + retcode = self._call(['make', 'check'], pdir) + self.assertEqual(retcode, 0, 'Failed to compile standalone check in %s' % pdir) + + def _build_f2py(self, pdir): + """Build the f2py matrix2py module in `pdir`, or skip the test. + + f2py needs a working numpy build backend (meson on numpy>=1.26 / + python>=3.12), which is not guaranteed in every environment. When it is + missing this raises SkipTest rather than a failure: the wrapper logic is + also covered by a mock-backed test that has no toolchain dependency. + """ + env = dict(os.environ) + with open(os.devnull, 'w') as devnull: + retcode = subprocess.call(['make', 'matrix2py.so'], cwd=pdir, + stdout=devnull, stderr=devnull, env=env) + modules = [name for name in os.listdir(pdir) + if name.startswith('matrix2py') and name.endswith('.so')] + if retcode != 0 or not modules: + raise unittest.SkipTest( + 'Could not build the f2py module in %s (f2py/numpy build ' + 'backend unavailable); skipping the compiled-module test.' + % pdir) + + def _call(self, command, cwd): + if logger.isEnabledFor(logging.INFO): + return subprocess.call(command, cwd=cwd) + with open(os.devnull, 'w') as devnull: + return subprocess.call(command, stdout=devnull, stderr=devnull, cwd=cwd) + + # ------------------------------------------------------------------ + # running + # ------------------------------------------------------------------ + def _probe(self, pdir, lines): + """Feed the driver an input block and return its stdout.""" + with open(pjoin(pdir, 'cross_input.dat'), 'w') as fsock: + fsock.write('\n'.join(lines) + '\n') + return subprocess.Popen(['./check'], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=pdir).communicate()[0].decode() + + def _flavor_positions(self, pdir, flav): + """GET_FLAVOR: the per-leg flavor-group positions of a flavor index.""" + output = self._probe(pdir, ['1', '%d' % flav]) + match = re.search(r'POS=\s*(.*)', output) + self.assertTrue(match, 'No POS from %s, got:\n%s' % (pdir, output)) + return tuple(int(token) for token in match.group(1).split()) + + def _flavor_index(self, pdir, positions): + """GET_FLAVOR_INDEX: flavor index of a position vector, 0 if absent.""" + output = self._probe(pdir, ['2', ' '.join(str(p) for p in positions)]) + match = re.search(r'IDX=\s*(-?\d+)', output) + self.assertTrue(match, 'No IDX from %s, got:\n%s' % (pdir, output)) + return int(match.group(1)) + + def _nhel_idx(self, pdir, iflav): + """(crossed IDEN, per-leg signed PDG) an extended FLAV_IDX selects. + + Exercises the two f2py-facing accessors GET_NHEL_IDX / + GET_PDG_FOR_FLAVOR that a python caller working in PDG codes relies on. + """ + output = self._probe(pdir, ['5', '%d' % iflav]) + iden = re.search(r'IDEN=\s*(-?\d+)', output) + pdg = re.search(r'PDG=\s*(.*)', output) + self.assertTrue(iden and pdg, + 'No IDEN/PDG from %s, got:\n%s' % (pdir, output)) + return int(iden.group(1)), tuple(int(t) for t in pdg.group(1).split()) + + def _density(self, pdir, momenta, iflav, leg): + """Return the 3 interference terms of the density matrix of `leg`. + + (++), (+-) and (--) for the two helicity states of that single leg, + each as a complex number. + """ + lines = ['4', '%d' % iflav, '%d' % leg] + for mom in momenta: + lines.append(' '.join('%.17e' % component for component in mom)) + output = self._probe(pdir, lines) + values = re.findall(r'INTER=\s*(\S+)\s+(\S+)', output) + self.assertEqual(len(values), 3, + 'Expected 3 interference terms from %s, got:\n%s' + % (pdir, output)) + return [complex(float(re.sub('[dD]', 'e', real)), + float(re.sub('[dD]', 'e', imag))) + for real, imag in values] + + def _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. + """ + halfe = 0.5 * self.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)] + + 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 test_split_orders_density_diagonal_matches_smatrix(self): + """The same invariant on the split-orders template. + + matrix_standalone_splitOrders_v4.inc is a separate template with its own + copy of the density code, and it had the very same missing-BROKEN_SYM + bug as the default one: SMATRIX applies BROKEN_SYM(FLAVOR) while + GET_INTER normalises with the bare static IDEN and cannot, so the + diagonal came out a factor BROKEN_SYM low. Fixing one template does not + fix the other, hence this test next to + test_density_matrix_diagonal_matches_smatrix. + + Uses the merged q g > q q q~ for the same reason: its two final quarks + share a flavor group, so BROKEN_SYM=2 on the rows where they differ. A + single-flavor process has BROKEN_SYM=1 everywhere and would pass even + with the rescaling removed entirely. + """ + merged = self._generate(PROC_MERGED_QG_QQQX_SO, 'Proc_merged_so', + split_orders=True) + # Guard the premise: if the squared-order syntax ever stopped setting + # split_orders, this would silently retest the default template. + self.assertIn('SMATRIX_SPLITORDERS', self._matrix_code(merged), + 'Expected %s to be written with the split-orders ' + 'template; this test would otherwise just retest the ' + 'default one' % PROC_MERGED_QG_QQQX_SO) + nflav = self._read_nflav(merged) + self.assertGreater(nflav, 1, + 'Expected a merged multi-flavor matrix element, got ' + 'NFLAV=%s: BROKEN_SYM would be 1 throughout and this ' + 'test could not fail' % nflav) + momenta = self._phase_space_2to3() + for flav in range(1, nflav + 1): + with self.subTest(flav=flav): + density = self._density(merged, momenta, flav, leg=1) + diagonal = density[0] + density[2] + reference = self._run(merged, momenta, flav) + self.assertNotEqual(reference, 0.0, + 'Sanity check failed: null matrix element ' + 'for flavor %s' % flav) + scale = max(abs(diagonal), abs(reference), 1e-99) + self.assertLessEqual( + abs(diagonal.real - reference) / scale, self.tolerance, + 'Split-orders density diagonal does not sum to SMATRIX for ' + 'flavor %s: diagonal=%r smatrix=%r (ratio %r)' + % (flav, diagonal.real, reference, + reference / diagonal.real if diagonal.real else None)) + + def test_use_crossing_false_drops_the_machinery(self): + """--use_crossing=False must emit no crossing code, same ME otherwise. + + The extended FLAV_IDX only makes sense when the crossed subprocesses + are *not* generated separately, which is exactly what --use_crossing + drives. With it off, none of the decoding routines nor the tables they + read may reach matrix.f (they would be dead code, and GET_AMP's IC + would carry a crossing that can never be requested), while the plain + uncrossed matrix element must be untouched: the crossing-off path goes + through ANS/IDEN*BROKEN_SYM instead of the per-crossing denominator, + and those two must agree for CROSS=0. + """ + default = self._generate(PROC_QQ_GG, 'Proc_qq_gg_default') + no_cross = self._generate(PROC_QQ_GG, 'Proc_qq_gg_nocross', + options='--use_crossing=False') + + code = self._matrix_code(no_cross) + for name in CROSSING_MACHINERY_NAMES: + self.assertNotIn(name, code, + '%s is still emitted with --use_crossing=False' + % name) + # Sanity: the very same assertion must fail on the default output, + # otherwise this test would pass on a matrix.f that never had any. + self.assertIn('GET_SPINCOL_CROSS', self._matrix_code(default), + 'Default output has no crossing machinery either: ' + 'this test proves nothing') + + for cos_theta in self.cos_thetas: + momenta = self._phase_space(cos_theta) + with self.subTest(cos_theta=cos_theta): + plain = self._run(no_cross, momenta, IFLAV_IDENTITY) + reference = self._run(default, momenta, IFLAV_IDENTITY) + self.assertNotEqual(reference, 0.0, + 'Sanity check failed: %s gives a null ' + 'matrix element' % PROC_QQ_GG) + self.assertEqual(plain, reference, + '--use_crossing=False changes the uncrossed ' + 'matrix element at cos(theta)=%s: %r vs %r' + % (cos_theta, plain, reference)) + + def _assert_machinery(self, process, name, expected): + """Assert the crossing machinery is (not) emitted for `process`.""" + code = self._matrix_code(self._output_standalone(process, name)) + if expected: + # One representative name is enough to prove the machinery is there; + # the full list matters only for the "must be absent" direction, + # where any single leftover would be dead code reading a crossing + # that can never be requested. + self.assertIn('GET_SPINCOL_CROSS', code, + 'Crossing machinery is missing for %s, which does ' + 'not constrain any s-channel' % process) + else: + for routine in CROSSING_MACHINERY_NAMES: + self.assertNotIn(routine, code, + '%s is emitted for %s, whose s-channel ' + 'constraint no crossing preserves' + % (routine, process)) + return code + + def test_required_s_channel_disables_crossing(self): + """`> z >` must drop the machinery; the same process without it keeps it. + + A required s-channel names a propagator that is only s-channel in this + arrangement of the legs, so it cannot survive a crossing and the + machinery must not be emitted. The unconstrained twin is generated too: + without it, the test would pass on any matrix.f that never had the + machinery at all (e.g. if e+e- output stopped emitting it for an + unrelated reason). + """ + self._assert_machinery(PROC_REQUIRED_S, 'Proc_required_s', + expected=False) + self._assert_machinery(PROC_UNCONSTRAINED, 'Proc_unconstrained_req', + expected=True) + + def test_forbidden_s_channel_disables_crossing(self): + """`$$ z` removes a diagram by s-channel, so it must drop the machinery. + + Paired with the unconstrained twin for the same anti-vacuity reason as + test_required_s_channel_disables_crossing. + """ + self._assert_machinery(PROC_FORBIDDEN_S, 'Proc_forbidden_s', + expected=False) + self._assert_machinery(PROC_UNCONSTRAINED, 'Proc_unconstrained_forb', + expected=True) + + def test_forbidden_onshell_s_channel_keeps_crossing(self): + """A single `$ z` must NOT disable crossing: the diagram is kept. + + `$` only forbids the on-shell region of a propagator, it does not pin + the topology, so the crossing machinery stays. This is the test that + stops the fix from being over-broad and disabling crossing for every + process carrying any `$`-like constraint. + """ + self._assert_machinery(PROC_FORBIDDEN_ONSH_S, 'Proc_forbidden_onsh_s', + expected=True) + + def test_f2py_flavor_index_accessors(self): + """GET_NHEL_IDX / GET_PDG_FOR_FLAVOR must describe the crossed process. + + These are the f2py-facing accessors that let a python caller work in + PDG codes: they turn an extended FLAV_IDX into (crossed denominator, + crossed+conjugated PDG list). Two failure modes they must not have, + both invisible to the |M|^2 tests: + * GET_NHEL_IDX returning the static uncrossed IDEN (the historical + GET_NHEL bug) rather than the crossed one, and + * GET_PDG_FOR_FLAVOR forgetting to conjugate a leg that swapped + between the initial and the final state. + For u u~ > g g the identity (IFLAV=1) is itself, and the (I=0,J=3) + crossing (IFLAV=4) is u g > u g: leg 2's u~ (pdg -2) becomes an + outgoing u (pdg +2) in slot 3, and IDEN goes 72 -> 96. + """ + qq_gg = self._generate(PROC_QQ_GG, 'Proc_qq_gg') + + iden_id, pdg_id = self._nhel_idx(qq_gg, IFLAV_IDENTITY) + self.assertEqual(iden_id, 72, + 'Identity IDEN wrong: %s' % iden_id) + self.assertEqual(pdg_id, (2, -2, 21, 21), + 'Identity PDG wrong: %s' % (pdg_id,)) + + iden_cr, pdg_cr = self._nhel_idx(qq_gg, _iflav(CROSS_2_3, 1, nflav=1)) + self.assertEqual(iden_cr, 96, + 'Crossed IDEN should be 96 (u g > u g), got %s. A 72 ' + 'here is the GET_NHEL static-IDEN bug.' % iden_cr) + self.assertEqual(pdg_cr, (2, 21, 2, 21), + 'Crossed PDG should be u g > u g with leg 2 conjugated,' + ' got %s' % (pdg_cr,)) + + def test_f2py_pdg_wrapper(self): + """The python PDG wrapper must find the crossing and call the right ME. + + End-to-end through the compiled f2py module: build it, then drive + flavor_dispatch.FlavorDispatch. A caller who knows only the physical + process as a signed-PDG list must get back the extended FLAV_IDX (via + find_pdg) and the correct crossed matrix element (via + matrix_element_pdg). For a u u~ > g g module the identity is itself and + the (I=0,J=3) crossing is u g > u g. Skips if f2py cannot build here. + """ + pdir = self._output_standalone(PROC_QQ_GG, 'Proc_qq_gg_f2py') + self._build_f2py(pdir) + + # Run in a subprocess: importing an f2py .so into the test interpreter + # would leak a compiled module and clash across tests. + script = ''' +import sys, math, numpy as np +sys.path.insert(0, %(pdir)r) +import matrix2py +from flavor_dispatch import FlavorDispatch +me = FlavorDispatch(matrix2py) +me.initialisemodel(%(card)r) +assert me.flavor_layout() == (1, 4, 25), me.flavor_layout() +assert me.pdg_for_index(1) == (2, -2, 21, 21), me.pdg_for_index(1) +assert me.pdg_for_index(4) == (2, 21, 2, 21), me.pdg_for_index(4) +assert me.find_pdg([2, -2, 21, 21]) == 1 +assert me.find_pdg([2, 21, 2, 21]) == 4 +assert me.find_pdg([6, -6, 21, 21]) is None # unreachable process +E = 500.0; c = 0.3; s = math.sqrt(1.0 - c * c) +P = np.asfortranarray(np.array([[E, 0, 0, E], [E, 0, 0, -E], + [E, E * s, 0, E * c], [E, -E * s, 0, -E * c]]).T) +direct = me.smatrix(P, 4) +via = me.matrix_element_pdg(P, [2, 21, 2, 21]) +assert abs(direct - via) <= 1e-11 * abs(direct), (direct, via) +assert direct > 0.0 +print("F2PY_PDG_OK") +''' % {'pdir': pdir, + 'card': pjoin(pdir, os.pardir, os.pardir, 'Cards', 'param_card.dat')} + script_path = pjoin(pdir, 'pdg_wrapper_probe.py') + with open(script_path, 'w') as fsock: + fsock.write(script) + proc = subprocess.Popen([sys.executable, script_path], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + cwd=pdir) + output = proc.communicate()[0].decode() + self.assertIn('F2PY_PDG_OK', output, + 'PDG wrapper probe failed:\n%s' % output) + + def _assert_goodhel_relation(self, process, name, ninitial, npts=16): + """Compiled-module check of the GHREMAP good-helicity relation. + + Builds the f2py module for `process` and, for every DERIVABLE crossing, + asserts the crossed good-helicity set equals the identity's mapped + through the crossing permutation sigma (the invariant GHREMAP encodes). + Skips if the f2py toolchain is unavailable, exactly like the other + compiled-module tests. + """ + pdir = self._output_standalone(process, name) + self._build_f2py(pdir) + card = pjoin(pdir, os.pardir, os.pardir, 'Cards', 'param_card.dat') + script = _GOODHEL_PROBE % {'pdir': pdir, 'card': card, + 'ninitial': ninitial, 'npts': npts} + script_path = pjoin(pdir, 'goodhel_relation_probe.py') + with open(script_path, 'w') as fsock: + fsock.write(script) + proc = subprocess.Popen([sys.executable, script_path], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + cwd=pdir) + output = proc.communicate()[0].decode() + self.assertIn('GHREMAP_RELATION_OK', output, + 'good-helicity relation probe failed for %s:\n%s' + % (process, output)) + + def test_goodhel_relation_qq_gg(self): + """The crossed good-helicity set of u u~ > g g must be the identity's + mapped through sigma, for every derivable crossing (>=12 points, so the + cross=23 accidental-zero undercount cannot mask a bug).""" + self._assert_goodhel_relation(PROC_QQ_GG, 'Proc_qq_gg_goodhel', + ninitial=2) + + def test_goodhel_relation_qq_ggg(self): + """Same relation on a 2->3 (u u~ > g g g): more crossings, and the + initial-initial swaps that break the relation are correctly excluded + from the derivable set the probe checks.""" + self._assert_goodhel_relation('u u~ > g g g', 'Proc_qq_ggg_goodhel', + ninitial=2) + + def test_qg_qg_crossed_gives_qq_gg(self): + """u g > u g with particle 2 <-> 3 crossed must give u u~ > g g.""" + qq_gg = self._generate(PROC_QQ_GG, 'Proc_qq_gg') + qg_qg = self._generate(PROC_QG_QG, 'Proc_qg_qg') + self._assert_crossing( + crossed_dir=qg_qg, crossed_iflav=_iflav(CROSS_2_3, 1, nflav=1), + reference_dir=qq_gg, label='%s crossed (I=0,J=3) vs %s' + % (PROC_QG_QG, PROC_QQ_GG)) + + +class TestCheckCrossingCommand(unittest.TestCase): + """The `check crossing` MG5 subcommand end-to-end. + + Drives the same code path as ``check crossing ``: + ``process_checks.check_crossing`` regenerates the process to fortran + standalone twice (crossing on and off), builds the f2py ``matrix2py`` + module in every P* directory, and compares each subprocess evaluated + through the crossing-enabled build against its crossing-disabled value. + Skips (rather than fails) when the f2py/numpy build backend is missing. + """ + + # x = u u~, x x > x x is the smallest line that puts a subprocess of the + # crossing-disabled reference (u u > u u) behind a *genuine* crossing in the + # crossing-enabled build: the two modes pick different representatives, so + # u u > u u is reached there only by a non-identity FLAV_IDX. That makes the + # comparison exercise APPLY_CROSSING rather than a plain identity, and it is + # small enough (no external gluon) to build quickly. + def setUp(self): + import madgraph.interface.master_interface as cmd_interface + self.cmd = cmd_interface.MasterCmd() + self.cmd.no_notification() + self.cmd.exec_cmd('set automatic_html_opening False', printcmd=False) + self.cmd.exec_cmd('import model sm', printcmd=False) + self.cmd.exec_cmd('define xq = u u~', printcmd=False) + + def _run_check(self, proc_line, exporter='standalone'): + import madgraph.various.process_checks as process_checks + # The C++/mg7 backends need a working C++ compiler + build toolchain; + # the fortran one needs f2py. Skip (do not fail) when unavailable. + if exporter != 'standalone': + compiler = os.environ.get('CXX', 'g++') + if not shutil.which(compiler): + raise unittest.SkipTest('no C++ compiler (%s) available for ' + 'exporter %s' % (compiler, exporter)) + procdef = self.cmd.extract_process(proc_line) + results = process_checks.check_crossing( + procdef, param_card=None, + options={'energy': 1000.0, 'proc_line': proc_line, + 'exporter': exporter}, + cmd=self.cmd) + if any(r.get('status') == 'build_failed' for r in results): + raise unittest.SkipTest( + 'Could not build the %s crossing output (build backend ' + 'unavailable); skipping the check crossing test.' % exporter) + return results, process_checks + + def _assert_all_pass_with_crossing(self, results, process_checks, + require_crossing=True): + """Shared assertions: every subprocess agrees (Passed), at least one is + reached through a genuine (non-identity) crossing, and the rendered + report is failure-free.""" + self.assertTrue(results, 'check crossing returned no comparison') + checked = 0 + crossed = 0 + for res in results: + self.assertEqual(res['status'], 'ok', res) + vd = res['value_direct'] + vc = res['value_crossed'] + self.assertIsNotNone(vd, 'no direct value for %s' % res['process']) + self.assertIsNotNone(vc, 'no crossed value for %s' % res['process']) + self.assertGreater(abs(vd), 0.0, + 'null matrix element for %s' % res['process']) + scale = max(abs(vd), abs(vc), 1e-99) + self.assertLessEqual( + abs(vd - vc) / scale, 1e-6, + '%s disagrees between crossing on/off: direct=%r crossed=%r' + % (res['process'], vd, vc)) + checked += 1 + if res.get('cross_code'): + crossed += 1 + self.assertGreater(checked, 0, 'no subprocess was checked') + if require_crossing: + # Non-vacuity: the comparison must genuinely go through the crossing + # machinery for at least one subprocess, not only identity matches. + self.assertGreater( + crossed, 0, + 'No subprocess was reached through a non-identity crossing; the ' + 'test would then only compare the two builds at cross=0') + + # The rendered report must show the Passed verdict, as the other check + # subcommands do. + text = process_checks.output_crossing(results) + self.assertIn('Passed', text) + self.assertIn('Summary:', text) + self.assertEqual(process_checks.output_crossing(results, 'fail'), 0, + 'output_crossing reported a failure:\n%s' % text) + return crossed + + def test_check_crossing_command(self): + """standalone (fortran): every subprocess must agree between the two + modes, with a Passed verdict, and at least one must be reached through a + real crossing.""" + results, process_checks = self._run_check('xq xq > xq xq') + self._assert_all_pass_with_crossing(results, process_checks) + + def test_check_crossing_command_cpp(self): + """standalone_cpp backend: u u > u u reached through a genuine crossing + of a different subprocess must agree with its independent value.""" + results, process_checks = self._run_check( + 'xq xq > xq xq', exporter='standalone_cpp') + self._assert_all_pass_with_crossing(results, process_checks) + + def test_check_crossing_command_mg7(self): + """standalone_mg7 (cudacpp CPU-SIMD) backend: same genuine-crossing + agreement, evaluated at a prescribed phase-space point injected into the + SIMD momenta buffer.""" + results, process_checks = self._run_check( + 'xq xq > xq xq', exporter='standalone_mg7') + self._assert_all_pass_with_crossing(results, process_checks) + + def test_check_crossing_invalid_exporter(self): + """An unknown --exporter must raise a clear InvalidCmd, not run.""" + import madgraph + import madgraph.various.process_checks as process_checks + procdef = self.cmd.extract_process('g u > g u') + with self.assertRaises(madgraph.InvalidCmd) as ctx: + process_checks.check_crossing( + procdef, param_card=None, + options={'energy': 1000.0, 'proc_line': 'g u > g u', + 'exporter': 'not_a_backend'}, + cmd=self.cmd) + self.assertIn('not_a_backend', str(ctx.exception)) + + def test_check_crossing_invalid_simd(self): + """An unknown standalone_mg7 --simd must raise a clear InvalidCmd. + + No build: constructing the mg7 backend validates the choice up front. + """ + import madgraph + import madgraph.various.process_checks as process_checks + procdef = self.cmd.extract_process('g u > g u') + with self.assertRaises(madgraph.InvalidCmd) as ctx: + process_checks.check_crossing( + procdef, param_card=None, + options={'energy': 1000.0, 'proc_line': 'g u > g u', + 'exporter': 'standalone_mg7', 'simd': 'not_a_simd'}, + cmd=self.cmd) + self.assertIn('not_a_simd', str(ctx.exception)) + + def test_check_crossing_s_channel_graceful(self): + """A required s-channel disables crossing; the check must still pass. + + `u u~ > z > e+ e-` is only s-channel in this arrangement of the legs, so + no crossing preserves it and the crossing machinery is not emitted. The + command must handle this gracefully: every subprocess is matched at the + identity and passes (the crossing-enabled and crossing-disabled builds + agree), rather than erroring. + """ + results, process_checks = self._run_check('u u~ > z > e+ e-') + self.assertTrue(results, 'check crossing returned no comparison') + for res in results: + self.assertEqual(res['status'], 'ok', res) + self.assertIsNotNone(res['value_direct']) + self.assertIsNotNone(res['value_crossed']) + self.assertFalse(res.get('cross_code'), + 'a constrained-s-channel process should not be ' + 'reached by any non-identity crossing: %s' % res) + self.assertEqual(process_checks.output_crossing(results, 'fail'), 0) + + +class TestCrossingUnsupportedOutput(unittest.TestCase): + """Outputs that cannot cross must refuse a process generated with crossing. + + --use_crossing is on by default and tells the generation *not* to write the + crossed subprocesses out separately, because the matrix element is supposed + to reach them through an extended FLAV_IDX. Only the fortran standalone + decodes one. Any other output would therefore quietly produce a matrix + element that is missing those subprocesses, so it has to raise instead. + """ + + # Outputs reached through ExportV4Factory that have no crossing machinery. + UNSUPPORTED_FORMATS = ['madevent', '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=''): + """Run generate+output for `fmt`, returning nothing or raising.""" + cmd = cmd_interface.MasterCmd() + cmd.no_notification() + cmd.exec_cmd('set automatic_html_opening False') + cmd.exec_cmd('import model sm') + cmd.exec_cmd(('generate %s %s' % (PROC_QG_QG, options)).strip()) + cmd.exec_cmd('output %s %s -f' % (fmt, pjoin(self.tmpdir, name))) + + def test_unsupported_output_raises_with_crossing(self): + """The default (crossing on) must be refused, and say how to fix it.""" + for fmt in self.UNSUPPORTED_FORMATS: + with self.subTest(format=fmt): + with self.assertRaises(madgraph.InvalidCmd) as ctx: + self._output(fmt, 'raise_%s' % fmt) + message = str(ctx.exception) + # An error that does not name the way out would just leave the + # user stuck, so the remedy is part of the requirement. + self.assertIn('--use_crossing=False', message, + 'The %s error does not name the fix: %s' + % (fmt, message)) + + 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_standalone_still_accepts_crossing(self): + """The one output that does implement crossing must not be caught. + + Anchors the gate against being over-broad: a check that refused every + output would pass both tests above. + """ + self._output('standalone', 'ok_standalone') + + +# The C++ standalone driver: take a fixed RAMBO phase space point once +# (all-massless, so the momenta are identical between the two P directories) and +# print sigmaKin at each flavor_id passed on the command line. Each flavor_id is +# evaluated in a FRESH CPPProcess so the good-helicity cache starts empty: that +# cache is indexed by the reduced flavor (flav_use), so different crossings of +# one flavor would otherwise share it and, once it kicks in, a later crossing +# would be filtered by an earlier one's non-zero-helicity pattern (the deferred +# open question of keying the cache on the full flavor_id). The momenta are +# generated once and reused so every process sees the very same point. +# The shipped check_sa.cpp only ever loops over its own maxflavor identities, so +# a purpose-built driver is needed to request a crossed flavor_id. +_CPP_DRIVER = r""" +#include +#include +#include +#include "CPPProcess.h" +#include "rambo.h" + +int main(int argc, char** argv){ + double energy = 1000.0; + double weight; + CPPProcess seed("../../Cards/param_card.dat"); + vector p = get_momenta(seed.ninitial, energy, + seed.getMasses(), weight); + std::cout << std::setprecision(17); + for(int a = 1; a < argc; a++){ + int fid = atoi(argv[a]); + CPPProcess process("../../Cards/param_card.dat"); + process.setMomenta(p); + double me = process.sigmaKin(fid); + std::cout << "sigmaKin(" << fid << ") = " << me << std::endl; + } + return 0; +} +""" + + +class TestStandaloneCppCrossSymmetry(unittest.TestCase): + """standalone_cpp must reproduce the crossing the fortran standalone does. + + Mirror of TestStandaloneCrossSymmetry for the C++ backend: u u~ > g g and + u g > u g are each other's crossing under (I=0, J=3). In the 0-based C++ + flavor_id encoding cross = flavor_id / nflav and flav = flavor_id % nflav, + so with NFLAV=1 the crossing (I=0, J=3) -> cross = 0*(NEXTERNAL+1)+3 = 3 is + reached by flavor_id = 3. sigmaKin already divides by the crossed + denominator, so a crossed call returns the properly averaged matrix element + of the process it crosses into and can be compared directly. + + Skipped when no C++ compiler is available (the whole check needs to build + and run real C++). + """ + + energy = 1000.0 + tolerance = 1e-9 + # cross = I*(NEXTERNAL+1)+J = 0*5+3 = 3, flavor_id = cross*NFLAV+flav (NFLAV=1) + CROSS_2_3 = 3 + IDENTITY = 0 + + debugging = getattr(unittest, 'debug', False) + + def setUp(self): + self.compiler = os.environ.get('CXX', 'g++') + if not shutil.which(self.compiler): + self.skipTest('no C++ compiler (%s) available' % self.compiler) + self.tmpdir = tempfile.mkdtemp(prefix='cross_cpp_') + + def tearDown(self): + if not self.debugging and os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + # ------------------------------------------------------------------ + def _output_standalone_cpp(self, process, name, options=''): + """Write the standalone_cpp output for `process`, return its P* dir.""" + outdir = pjoin(self.tmpdir, name) + cmd = cmd_interface.MasterCmd() + cmd.no_notification() + cmd.exec_cmd('set automatic_html_opening False') + cmd.exec_cmd('set group_subprocesses False') + cmd.exec_cmd('set apply_flavor_grouping True') + cmd.exec_cmd('import model sm') + cmd.exec_cmd(('generate %s %s' % (process, options)).strip()) + cmd.exec_cmd('output standalone_cpp %s -f' % outdir) + + subproc_root = pjoin(outdir, 'SubProcesses') + pdirs = [pjoin(subproc_root, d) for d in sorted(os.listdir(subproc_root)) + if d.startswith('P') and os.path.isdir(pjoin(subproc_root, d))] + self.assertEqual(len(pdirs), 1, + 'Expected a single subprocess directory for %s, got %s' + % (process, pdirs)) + return pdirs[0] + + def _cpp_source(self, pdir): + with open(pjoin(pdir, 'CPPProcess.cc')) as fsock: + return fsock.read() + + def _build_and_run(self, pdir, flavor_ids): + """Build the driver in `pdir` and return {flavor_id: sigmaKin}.""" + # 'make' compiles CPPProcess.o and links the shipped check; it also + # proves the generated code compiles. + with open(os.devnull, 'w') as devnull: + rc = subprocess.call(['make'], cwd=pdir, stdout=devnull, + stderr=subprocess.STDOUT) + self.assertEqual(rc, 0, 'make failed in %s' % pdir) + + with open(pjoin(pdir, 'driver_cross.cpp'), 'w') as fsock: + fsock.write(_CPP_DRIVER) + cxxflags = ['-O3', '-ffast-math', '-I../../src', '-I.', '-fPIC'] + libflags = ['-L../../lib', '-lmodel_sm'] + with open(os.devnull, 'w') as devnull: + rc = subprocess.call( + [self.compiler] + cxxflags + ['-c', '-o', 'driver_cross.o', + 'driver_cross.cpp'], + cwd=pdir, stdout=devnull, stderr=subprocess.STDOUT) + self.assertEqual(rc, 0, 'driver compile failed in %s' % pdir) + rc = subprocess.call( + [self.compiler, '-o', 'driver_cross', 'CPPProcess.o', + 'driver_cross.o'] + libflags, + cwd=pdir, stdout=devnull, stderr=subprocess.STDOUT) + self.assertEqual(rc, 0, 'driver link failed in %s' % pdir) + + out = subprocess.check_output( + ['./driver_cross'] + [str(f) for f in flavor_ids], + cwd=pdir).decode() + values = {} + for match in re.finditer(r'sigmaKin\((\d+)\)\s*=\s*([-\d.eE+]+)', out): + values[int(match.group(1))] = float(match.group(2)) + self.assertEqual(set(values), set(flavor_ids), + 'driver output did not cover every flavor_id: %s' % out) + return values + + # ------------------------------------------------------------------ + def test_qq_gg_crossed_gives_qg_qg(self): + """u u~ > g g crossed by (I=0,J=3) must equal u g > u g at the same + momenta, and the crossed value must differ from the identity one so the + check is non-vacuous.""" + crossed_dir = self._output_standalone_cpp(PROC_QQ_GG, 'qqgg') + reference_dir = self._output_standalone_cpp(PROC_QG_QG, 'qgqg') + + crossed = self._build_and_run(crossed_dir, + [self.IDENTITY, self.CROSS_2_3]) + reference = self._build_and_run(reference_dir, [self.IDENTITY]) + + self.assertAlmostEqual( + crossed[self.CROSS_2_3], reference[self.IDENTITY], + delta=self.tolerance * abs(reference[self.IDENTITY]), + msg='u u~ > g g crossed (%r) != u g > u g identity (%r)' + % (crossed[self.CROSS_2_3], reference[self.IDENTITY])) + # Non-vacuous: the crossing must move the answer, not return the + # identity value. + self.assertNotAlmostEqual( + crossed[self.CROSS_2_3], crossed[self.IDENTITY], places=6, + msg='crossed value equals the identity value; crossing had no ' + 'effect, so the test would pass trivially') + + def test_qg_qg_crossed_gives_qq_gg(self): + """The reverse: u g > u g crossed by (I=0,J=3) must equal u u~ > g g.""" + crossed_dir = self._output_standalone_cpp(PROC_QG_QG, 'qgqg_rev') + reference_dir = self._output_standalone_cpp(PROC_QQ_GG, 'qqgg_rev') + + crossed = self._build_and_run(crossed_dir, + [self.IDENTITY, self.CROSS_2_3]) + reference = self._build_and_run(reference_dir, [self.IDENTITY]) + + self.assertAlmostEqual( + crossed[self.CROSS_2_3], reference[self.IDENTITY], + delta=self.tolerance * abs(reference[self.IDENTITY]), + msg='u g > u g crossed (%r) != u u~ > g g identity (%r)' + % (crossed[self.CROSS_2_3], reference[self.IDENTITY])) + + def test_invalid_overlapping_swap_returns_zero(self): + """An overlapping-swap crossing code (I=2, J=1 here) is marked invalid; + sigmaKin must short-circuit to 0 for it.""" + pdir = self._output_standalone_cpp(PROC_QQ_GG, 'qqgg_inv') + # cross = I*(NEXTERNAL+1)+J = 2*5+1 = 11, flavor_id = 11 (NFLAV=1). + overlapping = 2 * (NEXTERNAL + 1) + 1 + values = self._build_and_run(pdir, [overlapping]) + self.assertEqual(values[overlapping], 0.0, + 'an overlapping-swap code must give a zero matrix ' + 'element, got %r' % values[overlapping]) + + def test_use_crossing_false_drops_the_machinery(self): + """--use_crossing=False must compile and emit no crossing machinery, + while still giving the same uncrossed matrix element.""" + with_dir = self._output_standalone_cpp(PROC_QQ_GG, 'qqgg_on') + without_dir = self._output_standalone_cpp(PROC_QQ_GG, 'qqgg_off', + options='--use_crossing=False') + + on_src = self._cpp_source(with_dir) + off_src = self._cpp_source(without_dir) + for token in ('spincol_cross', 'cross_perm', 'cross_ic', + 'ident_cross', 'flav_use', 'const int ic[]'): + self.assertIn(token, on_src, + '%s should be emitted with crossing on' % token) + self.assertNotIn(token, off_src, + '%s must NOT be emitted with --use_crossing=False' + % token) + + on = self._build_and_run(with_dir, [self.IDENTITY]) + off = self._build_and_run(without_dir, [self.IDENTITY]) + self.assertAlmostEqual( + on[self.IDENTITY], off[self.IDENTITY], + delta=self.tolerance * abs(on[self.IDENTITY]), + msg='the uncrossed matrix element changed when the crossing ' + 'machinery was emitted: %r vs %r' + % (on[self.IDENTITY], off[self.IDENTITY])) + + +class TestStandaloneMg7CrossSymmetry(unittest.TestCase): + """standalone_mg7 (madmatrix / cudacpp CPU-SIMD) must reproduce the crossing. + + Mirror of TestStandaloneCppCrossSymmetry for the data-parallel madmatrix + backend. The extended flavor id encodes cross = id / nflav and flav = id % + nflav (0-based, NFLAV=1 here), so (I=0, J=3) -> cross = 3 -> id = 3. The key + extra check versus the scalar C++ backend is that DIFFERENT events in the + SAME SIMD page may carry DIFFERENT crossings while sharing the reduced + flavor: the per-event momentum permutation must not be vectorized. + + The whole check needs to build and run real C++/SIMD code; skipped (not + failed) if the compiler or the madmatrix build toolchain is unavailable. + """ + + CROSS_2_3 = 3 # cross = I*(NEXTERNAL+1)+J = 0*5+3 = 3, id = cross*NFLAV+flav + IDENTITY = 0 + OVERLAP = 2 * (NEXTERNAL + 1) + 1 # cross=11 (I=2,J=1): overlapping swap -> invalid + tolerance = 1e-9 + + debugging = getattr(unittest, 'debug', False) + + def setUp(self): + self.compiler = os.environ.get('CXX', 'g++') + if not shutil.which(self.compiler): + self.skipTest('no C++ compiler (%s) available' % self.compiler) + self.tmpdir = tempfile.mkdtemp(prefix='cross_mg7_') + + def tearDown(self): + if not self.debugging and os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + # ------------------------------------------------------------------ + def _output_standalone_mg7(self, process, name, options=''): + """Write the standalone_mg7 output for `process`, return its P* dir.""" + outdir = pjoin(self.tmpdir, name) + cmd = cmd_interface.MasterCmd() + cmd.no_notification() + cmd.exec_cmd('set automatic_html_opening False') + cmd.exec_cmd('set group_subprocesses False') + cmd.exec_cmd('set apply_flavor_grouping True') + cmd.exec_cmd('import model sm') + cmd.exec_cmd(('generate %s %s' % (process, options)).strip()) + cmd.exec_cmd('output standalone_mg7 %s -f' % outdir) + + subproc_root = pjoin(outdir, 'SubProcesses') + pdirs = [pjoin(subproc_root, d) for d in sorted(os.listdir(subproc_root)) + if d.startswith('P') and 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 g g crossed by (I=0,J=3) equals u g > u g at the same momenta + (both 2->2 massless -> identical RAMBO momenta for the same seed).""" + crossed = self._output_standalone_mg7(PROC_QQ_GG, 'qqgg') + reference = self._output_standalone_mg7(PROC_QG_QG, 'qgqg') + 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='u u~ > g g crossed (%r) != u g > u g 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_qg_qg_crossed_gives_qq_gg(self): + """The reverse: u g > u g crossed by (I=0,J=3) equals u u~ > g g.""" + crossed = self._output_standalone_mg7(PROC_QG_QG, 'qgqg_rev') + reference = self._output_standalone_mg7(PROC_QQ_GG, 'qqgg_rev') + self._patch_and_build(crossed) + self._patch_and_build(reference) + self.assertAlmostEqual( + self._me(crossed, self.CROSS_2_3), self._me(reference, self.IDENTITY), + delta=self.tolerance * abs(self._me(reference, self.IDENTITY)), + msg='u g > u g 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.""" + pdir = self._output_standalone_mg7(PROC_QQ_GG, 'qqgg_perevent') + self._patch_and_build(pdir) + identity_val = self._me(pdir, self.IDENTITY) + crossed_val = self._me(pdir, self.CROSS_2_3) + self.assertNotAlmostEqual(identity_val, crossed_val, places=6, + msg='degenerate: identity == crossed') + mixed = self._event_mes( + pdir, self.IDENTITY, + env={'MG_SAMEMOM': '1', + 'MG_FLVMIX': '%d,%d' % (self.IDENTITY, self.CROSS_2_3)}) + self.assertGreaterEqual(len(mixed), 4, + 'need several events to prove per-event crossing') + for i, me in enumerate(mixed): + expected = identity_val if i % 2 == 0 else crossed_val + self.assertAlmostEqual( + me, expected, delta=self.tolerance * abs(expected) + 1e-12, + msg='event %d (cross %s) got %r, expected %r' + % (i, 'id' if i % 2 == 0 else '2<->3', me, expected)) + + def test_invalid_overlapping_swap_returns_zero(self): + """An overlapping-swap crossing code (I=2, J=1 -> cross 11) is invalid; + the per-event denominator must short-circuit its matrix element to 0.""" + pdir = self._output_standalone_mg7(PROC_QQ_GG, 'qqgg_inv') + self._patch_and_build(pdir) + self.assertEqual(self._me(pdir, self.OVERLAP), 0.0, + 'an overlapping-swap code must give a zero ME') + + def test_use_crossing_false_byte_identical(self): + """--use_crossing=False must emit NO crossing machinery (every crossing + token absent from the generated source) and still give the same + uncrossed matrix element as the crossing-on build. (A full byte-identical + `diff -r` against the pre-feature output was checked by hand; here we + assert the token absence and the numerical invariance.)""" + on_dir = self._output_standalone_mg7(PROC_QQ_GG, 'qqgg_on') + off_dir = self._output_standalone_mg7(PROC_QQ_GG, 'qqgg_off', + options='--use_crossing=False') + on_src = self._cpp_source(on_dir) + off_src = self._cpp_source(off_dir) + for token in ('spincol_cross', 'cross_perm', 'cross_ic', 'ident_cross', + 'xmom', 'flavorPDGs_cross'): + 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') diff --git a/tests/acceptance_tests/test_standalone_madevent_consistency.py b/tests/acceptance_tests/test_standalone_madevent_consistency.py index 0c9dd0198..66efca10c 100644 --- a/tests/acceptance_tests/test_standalone_madevent_consistency.py +++ b/tests/acceptance_tests/test_standalone_madevent_consistency.py @@ -181,6 +181,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() diff --git a/tests/unit_tests/iolibs/test_export_cpp.py b/tests/unit_tests/iolibs/test_export_cpp.py index d621b4dd5..6658239fe 100755 --- a/tests/unit_tests/iolibs/test_export_cpp.py +++ b/tests/unit_tests/iolibs/test_export_cpp.py @@ -920,7 +920,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') From af7f5d05c863c558d4b9b600fca972f2ef681f5f Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 22 Jul 2026 09:46:32 +0200 Subject: [PATCH 03/43] Keep crossed subprocesses under --use_crossing=False (restore 3.x completeness) --use_crossing=False mapped to merge_crossing=True, which takes the "do not generate diagrams" branch in generate_diagrams_multiprocess and drops the crossed subprocesses from the amplitude list entirely -- an incomplete partonic sum (verified: p p > j j gave 3 of 8 subprocesses, missing g q > g q and q q~ > g g). In 3.x merge_crossing defaulted to False (crossed processes kept as cross_amplitudes); only the old --no_crossing debug flag dropped them. Decouple the two: keep crossed subprocesses unconditionally (merge_crossing=False). --use_crossing now only decides, at the exporter stage (via self._use_crossing), 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). --use_crossing=True generation is byte-identical (already mapped to False); only --use_crossing=False changes, and it is now a complete output again -- the fallback the madevent gate already points users to. Verified: p p > j j --use_crossing=False -> output madevent now yields all 8 subprocesses (5 P dirs); test_standalone_cross_symmetry 36/36 OK. Co-Authored-By: Claude Opus 4.8 --- madgraph/interface/madgraph_interface.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/madgraph/interface/madgraph_interface.py b/madgraph/interface/madgraph_interface.py index 9ec60101a..81e4ed7d8 100755 --- a/madgraph/interface/madgraph_interface.py +++ b/madgraph/interface/madgraph_interface.py @@ -3394,9 +3394,16 @@ def do_add(self, line): raise self.InvalidCmd('--use_crossing expects True or ' 'False, got \'%s\'' % value) args.remove(arg) - # Internally the switch is inverted: merge_crossing=True means "do not - # reuse/generate the crossed subprocesses". - merge_crossing = not 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 = False # Check the validity of the arguments self.check_add(args) From 0af8317221373c54c0df988b2d3dde6f088b5e68 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 22 Jul 2026 10:17:57 +0200 Subject: [PATCH 04/43] madevent crossing M0 (slice 1): SMATRIX/MATRIX crossing holes, OFF byte-equivalent Scaffold the crossing-aware evaluation path in the madevent group matrix template without changing behaviour yet. Add holes to the SMATRIX/MATRIX of matrix_madevent_group_v4.inc: - smatrix_me_cross_decl / smatrix_me_cross_decode (decode IFLAV -> CROSS, FLAV_USE and APPLY_CROSSING to build crossed P/NHEL/IC), - me_flav_key token (IFLAV off / FLAV_USE on) at every GOODHEL/NTRY/GET_FLAVOR, - me_matrix_args for both MATRIX call sites, - smatrix_me_iden_line (crossed denominator branch), - me_matrix_ic_param / me_matrix_ic_decl (runtime IC into MATRIX), - crossing_routines_me + smatrix_me_goodhel_or. fill_crossing_replace_dict_me() fills them; its OFF branch reproduces the historical madevent code and the ON branch is added in slice 2. The madevent exporter calls it with use_crossing=False for now, so the output is functionally identical (a p p > j j regeneration differs only by two comment lines and three blank lines). Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 42 ++++++++++++++++++- .../matrix_madevent_group_v4.inc | 30 +++++++------ 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index bfa016c5c..a6e90b8b2 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -2391,6 +2391,38 @@ def fill_crossing_replace_dict(self, matrix_element, replace_dict, replace_dict['crossing_routines'] = \ open(crossing_template).read() % replace_dict + def fill_crossing_replace_dict_me(self, matrix_element, replace_dict, + use_crossing, proc_id): + """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). + """ + 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', + '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': '', + }) + return + raise NotImplementedError( + 'madevent crossing ON path is added in the next slice') + # (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 = ( @@ -6992,7 +7024,15 @@ 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 holes of matrix_madevent_group_v4.inc. Only the fortran + # standalone can currently decode the extended FLAV_IDX, so the madevent + # exporter always takes the OFF path for now (byte-identical output); + # the ON path is wired in once the group ME evaluates crossings. + me_use_crossing = False + self.fill_crossing_replace_dict_me(matrix_element, replace_dict, + me_use_crossing, proc_id) + mask_decl, mask_setup, n_flavors, active_flavor_mask = \ self._get_flavor_mask_blocks(matrix_element) diff --git a/madgraph/iolibs/template_files/matrix_madevent_group_v4.inc b/madgraph/iolibs/template_files/matrix_madevent_group_v4.inc index b1523fc0e..abe9510e5 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 @@ -128,8 +129,9 @@ 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 (multi_channel) THEN DO I=1,NDIAGS @@ -149,8 +151,8 @@ 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(I,%(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 + 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) @@ -159,7 +161,7 @@ C ---------- TS(I)=T ENDIF ENDDO - IF(NTRY(IFLAV,%(proc_id)s).EQ.(MAXTRIES+1).and.DS_get_dim_status('Helicity').ne.-1) THEN + 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 +178,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 (.NOT.GOODHEL(I,%(me_flav_key)s,%(proc_id)s) .AND. (DABS(TS(I)).GT.ANS*LIMHEL/NCOMB)) THEN + GOODHEL(I,%(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 +199,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 @@ -254,11 +256,12 @@ 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 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 +277,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 @@ -314,6 +317,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 From ce366daaed0032aed49326936e67c44b5c6cb824 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 22 Jul 2026 10:30:52 +0200 Subject: [PATCH 05/43] madevent crossing M0 (slice 2): ON path for the group SMATRIX (compiles+links) Implement the crossing-on fills of fill_crossing_replace_dict_me: decode the extended FLAV_IDX into (CROSS, FLAV_USE), short-circuit an unusable crossing (spin*color = 0) to a zero ME, and build the crossed P/NHEL/IC once via APPLY_CROSSING_TABLE before the helicity loop; MATRIX now takes a runtime IC and its helas calls read *IC(i) (use_crossing_ic). The denominator uses GET_SPINCOL_CROSS*GET_IDENT_CROSS for a genuine crossing and the historical IDEN*BROKEN_SYM for CROSS=0. The crossing routines are emitted with a per-proc qualifier (CR_GET_CROSS_PERM, ...) so the matrix.f of one group do not clash at link time. The good-helicity filter is bypassed for a crossing-enabled ME (a crossing permutes/flips helicities; a shared-flavor remap can optimise this later). write_matrix_element_v4 computes me_use_crossing (opt use_crossing, group template only, and the process definition must not pin an s-channel) and drives use_crossing_ic from it. The madevent crossing gate is unchanged, so this stays dormant until the per-crossing auto_dsig/metadata land: a normal --use_crossing=False output is byte-identical, and the ON matrix element (matrix1_orig) compiles and links (madevent_forhel) for p p > j j. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 86 +++++++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 7 deletions(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index a6e90b8b2..b65e58a47 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -2420,8 +2420,69 @@ def fill_crossing_replace_dict_me(self, matrix_element, replace_dict, 'me_matrix_ic_decl': '', }) return - raise NotImplementedError( - 'madevent crossing ON path is added in the next slice') + + # 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 = self._build_flav_table_flat(matrix_element)[0] + cp = 'CR%s_' % pid + crossing_template = pjoin(_file_path, 'iolibs', 'template_files', + 'matrix_standalone_crossing_v4.inc') + crossing_routines = open(crossing_template).read() % { + 'proc_prefix': cp, + 'nflav': nflav, + 'iden_cross_lines': self.get_iden_cross_lines(matrix_element)} + replace_dict.update({ + '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' + ) % {'nflav': nflav, 'cp': cp}, + # 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)' + ) % {'cp': cp}, + 'me_flav_key': 'FLAV_USE', + # A crossing permutes/flips helicities, so the shared GOODHEL filter + # (keyed by the reduced flavor) no longer gates its rows; compute + # every helicity for a crossing-enabled ME (optimise with a remap + # later). For CROSS=0 the crossed arrays equal the originals. + 'smatrix_me_goodhel_or': ' .OR. .TRUE.', + '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)', + }) # (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. @@ -7025,11 +7086,17 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, 'flavor_mask_decl':'', 'flavor_mask_setup':''} - # Crossing holes of matrix_madevent_group_v4.inc. Only the fortran - # standalone can currently decode the extended FLAV_IDX, so the madevent - # exporter always takes the OFF path for now (byte-identical output); - # the ON path is wired in once the group ME evaluates crossings. - me_use_crossing = False + # 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) @@ -7042,12 +7109,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 From 85ded67d260308705680a0ce244eb451e3303847 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 22 Jul 2026 10:40:26 +0200 Subject: [PATCH 06/43] madevent crossing M0: disable helicity recycling for a crossing output Helicity recycling (hel_recycle.py) rewrites matrix_orig.f into a single sweep with fixed, baked-in helicity values. That cannot coexist with the runtime helicity permutation a crossing applies: one baked helicity set cannot serve every crossing of a merged matrix element, and the _hel template MATRIX has no runtime IC to thread. So the group exporter now turns helicity recycling off when the output is written with crossing on, and compiles the crossing-aware matrix.f directly. Verified: with crossing on the group emits matrix.f (no _orig/template pair) and the default `make madevent` target links for p p > j j; a normal --use_crossing=False output is unchanged (recycling still on, matrix_orig.f + template_matrix.f). Gated on opt use_crossing, so it is inert until the madevent crossing gate is opened. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index b65e58a47..e1b2e04b9 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -8631,6 +8631,18 @@ def generate_subprocess_directory(self, subproc_group, except KeyError: self.proc_characteristic['hel_recycling'] = False self.opt['hel_recycling'] = False + # Helicity recycling bakes fixed helicity values into the matrix element + # (hel_recycle.py rewrites matrix_orig.f into a single good-helicity + # sweep). That is incompatible with the runtime helicity permutation a + # crossing applies -- one baked helicity set cannot serve every crossing + # -- so a crossing output keeps the direct, crossing-aware matrix.f + # instead. (No effect until the madevent crossing gate is opened.) + if self.opt.get('use_crossing', False) and self.opt['hel_recycling']: + logger.info('Crossing symmetry on: disabling helicity recycling for ' + 'this output (incompatible with the runtime helicity ' + 'crossing).') + self.opt['hel_recycling'] = False + self.proc_characteristic['hel_recycling'] = False for ime, matrix_element in \ enumerate(matrix_elements): if self.opt['hel_recycling']: From 2d8d1ff3542f558e28f6b680a551570430686907 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 22 Jul 2026 10:56:00 +0200 Subject: [PATCH 07/43] madevent crossing: support helicity recycling (drop the disable) Helicity recycling IS compatible with crossing, via the same permutation that lets goodhel be shared. hel_recycle.py rewrites only the helicity argument of an external call (NHEL(k) -> a baked +-1) and copies the momentum slot and the NSF argument unchanged, so an IC-aware matrix_orig.f yields IC-aware baked calls (e.g. IXXXXX(P(0,1),ZERO,+1,+1*IC(1),...)). The recycled MATRIX loops over the base good-helicity set; feeding it the crossed momenta PUSE and IC evaluates that set at the crossed kinematics, which by H=sigma(K) is exactly the crossed matrix element -- no NHEL table nor an explicit helicity remap is needed in the recycled code. So: revert the "disable helicity recycling for a crossing output" stopgap, and give matrix_madevent_group_v4_hel.inc the same crossing holes as the non-hel template (decode + PUSE/IC build, crossed denominator, IC into MATRIX, per-proc CR_ routines), minus NHELUSE. fill_crossing_replace_dict_me fills the _hel variants (smatrix_hel_cross_decl/decode, hel_matrix_call_args, hel_matrix_ic_param). Verified (crossing gate bypassed in a throwaway harness): with recycling ON the group emits matrix1_orig.f + template_matrix1.f (both crossing-aware, no leftover holes); madevent_forhel links; and running HelicityRecycler on them produces a matrix1_optim.f whose baked calls keep *IC(k) and which compiles+links into the full madevent. OFF (--use_crossing=False) output unchanged. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 53 ++++++++++++++----- .../matrix_madevent_group_v4_hel.inc | 14 +++-- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index e1b2e04b9..47d1b8d0f 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -2418,6 +2418,13 @@ def fill_crossing_replace_dict_me(self, matrix_element, replace_dict, 'crossing_routines_me': '', 'me_matrix_ic_param': '', 'me_matrix_ic_decl': '', + # 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': '', }) return @@ -2482,6 +2489,40 @@ def fill_crossing_replace_dict_me(self, matrix_element, replace_dict, '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 the base good-helicity set; feeding it the + # crossed momenta PUSE and IC evaluates that set at the crossed + # kinematics, which by H=sigma(K) is exactly the crossed ME -- no + # NHEL table (nor a helicity remap) is needed here. + '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' + ) % {'nflav': nflav, 'cp': cp}, + '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' + ) % {'cp': cp}, + 'hel_matrix_call_args': 'PUSE ,IC, FLAV_USE, TS, AMP2, JAMP2, IVEC', + 'hel_matrix_ic_param': 'IC,', }) # (decl, decode, apply) for GET_PDG_FOR_FLAVOR without crossing: FLAV_IDX_IN @@ -8631,18 +8672,6 @@ def generate_subprocess_directory(self, subproc_group, except KeyError: self.proc_characteristic['hel_recycling'] = False self.opt['hel_recycling'] = False - # Helicity recycling bakes fixed helicity values into the matrix element - # (hel_recycle.py rewrites matrix_orig.f into a single good-helicity - # sweep). That is incompatible with the runtime helicity permutation a - # crossing applies -- one baked helicity set cannot serve every crossing - # -- so a crossing output keeps the direct, crossing-aware matrix.f - # instead. (No effect until the madevent crossing gate is opened.) - if self.opt.get('use_crossing', False) and self.opt['hel_recycling']: - logger.info('Crossing symmetry on: disabling helicity recycling for ' - 'this output (incompatible with the runtime helicity ' - 'crossing).') - self.opt['hel_recycling'] = False - self.proc_characteristic['hel_recycling'] = False for ime, matrix_element in \ enumerate(matrix_elements): if self.opt['hel_recycling']: 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 45bcdb821..5c02863a5 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 @@ -96,7 +97,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,8 +115,8 @@ C ---------- TS(:) = 0d0 - call MATRIX%(proc_id)s(P ,IFLAV, TS, AMP2, JAMP2, IVEC) - DO I=1,NCOMB + call MATRIX%(proc_id)s(%(hel_matrix_call_args)s) + 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 @@ -167,11 +169,12 @@ 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 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 +190,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 @@ -227,6 +230,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 From 1fbac01f0d4c6940270e588c89bfa55ad6a720a9 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 22 Jul 2026 12:48:52 +0200 Subject: [PATCH 08/43] madevent crossing #6/#7 (piece 1): partition_crossing_classes helper Add ProcessExporterFortran.partition_crossing_classes(matrix_elements): group a subprocess group's matrix elements into crossing-equivalence classes. A member is tied to a base when the base's crossing enumeration (compute_crossing_pdg_entries) reproduces the member's crossed physical-PDG signature -- the same key check_crossing matches on -- returning per member a (base_index, cross) pair. This is the basis for sharing one matrix.f across a base and its crossings (the base SMATRIX, driven by an extended FLAV_IDX, evaluates the whole class), which the exporter wiring will use next. Pure function, not wired into the exporter yet. Unit test TestCrossingPartition on p p > j j: within P_gq_gq, g q~ > g q~ is found as a crossing of g q > g q (2 MEs -> 1 base); within P_qq_qq, q~ q~ > q~ q~ crosses q q > q q (3 -> 2), while q q~ > q q~ stays its own base. Cross-*group* crossings (g g > q q~ vs q q~ > g g, separate P dirs) are not merged -- that needs crossing-aware grouping, a later step. test_standalone_cross_symmetry: 37/37 OK. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 52 +++++++++++++++++++ .../test_standalone_cross_symmetry.py | 43 +++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 47d1b8d0f..283dc2b6f 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -2901,6 +2901,58 @@ def compute_crossing_pdg_entries(self, matrix_element, zero_based=True): entries.append((index, cross, flav0, tuple(pdg))) return entries + def partition_crossing_classes(self, matrix_elements): + """Partition a group's matrix elements into crossing-equivalence classes. + + Two subprocesses belong to the same class when one is a crossing of the + other -- same particles, related by the initial<->final leg swap the + crossing machinery encodes. One member of each class is the *base*: its + SMATRIX, driven by an extended FLAV_IDX, evaluates every member of the + class, so only the base needs its own matrix.f and the other members' + auto_dsig can call the base SMATRIX with the crossing's FLAV_IDX. + + Returns a list parallel to ``matrix_elements``: for member ``i`` a pair + ``(base_index, cross)`` where ``base_index`` indexes ``matrix_elements`` + (the class base) and ``cross`` is the crossing code that reaches member + ``i`` from that base (0 for a base itself). Matching is by the crossed + physical-PDG signature, exactly the key check_crossing matches on, so a + member is tied to a base only when the base can actually reproduce it. + """ + n = len(matrix_elements) + + # Representative identity signature (cross == 0) of every member: the + # PDG tuple of its own leg ordering, the thing a base crossing must hit. + id_sig = [None] * n + for i, me in enumerate(matrix_elements): + for _idx, cross, _flav0, pdg in \ + self.compute_crossing_pdg_entries(me, zero_based=True): + if cross == 0: + id_sig[i] = pdg + break + + assigned = [None] * n + for b in range(n): + if assigned[b] is not None: + continue + # b opens a new class as its base. + assigned[b] = (b, 0) + # Every crossed PDG signature b can reproduce, and with which code. + cross_by_sig = {} + for _idx, cross, _flav0, pdg in \ + self.compute_crossing_pdg_entries(matrix_elements[b], + zero_based=True): + if cross == 0: + continue + cross_by_sig.setdefault(pdg, cross) + # Pull every still-free member b can reach into b's class. + for m in range(n): + if assigned[m] is not None or id_sig[m] is None: + continue + cross = cross_by_sig.get(id_sig[m]) + if cross is not None: + assigned[m] = (b, cross) + return assigned + def compute_ghremap(self, matrix_element, allow_reverse=True): """Build the good-helicity remap table for the crossing filter. diff --git a/tests/acceptance_tests/test_standalone_cross_symmetry.py b/tests/acceptance_tests/test_standalone_cross_symmetry.py index d9f8b23f0..e841d86b0 100644 --- a/tests/acceptance_tests/test_standalone_cross_symmetry.py +++ b/tests/acceptance_tests/test_standalone_cross_symmetry.py @@ -1786,3 +1786,46 @@ def test_use_crossing_false_byte_identical(self): 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') + + +class TestCrossingPartition(unittest.TestCase): + """partition_crossing_classes groups a subprocess group's matrix elements + into crossing-equivalence classes (a base plus the crossings it reproduces), + 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~') + cmd.run_cmd('generate %s' % proc) + 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') + total_me = total_base = 0 + found_cross = False + for g in groups: + mes = g.get('matrix_elements') + assigned = exp.partition_crossing_classes(mes) + self.assertEqual(len(assigned), len(mes)) + bases = set() + for i, (b, c) in enumerate(assigned): + # the base a member points at is itself a base (cross 0) + self.assertEqual(assigned[b], (b, 0)) + bases.add(b) + if i != b: + self.assertNotEqual(c, 0) # a genuine crossing, not identity + found_cross = True + total_me += len(mes) + total_base += len(bases) + self.assertTrue(found_cross, + 'no crossing class found in p p > j j') + self.assertLess(total_base, total_me, + 'no matrix element was shared across a crossing') From a5e3b6af4e777d5e1bea905431be850c5f93ad35 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 22 Jul 2026 13:03:03 +0200 Subject: [PATCH 09/43] madevent crossing #6/#7: make partition_crossing_classes per-flavor The previous ME-level partition was wrong: it matched a module by its first flavor's signature only, so it silently missed genuine crossings (e.g. it claimed u u~ > u u~ is not a crossing of u u > u u, when it is exactly cross=4). More fundamentally, the crossing relates flavor combinations, not whole modules: a flavor-merged module bundles flavors that cross to different bases (the Q Q~ > Q Q~ module carries both u u~ > u u~, a crossing of Q Q > Q Q, and d d~ > u u~, which is not), so an ME-level base/cross map cannot be right. Rework the helper to route per flavor: a module may drop its own matrix.f only when EVERY one of its flavors is a genuine crossing of some base module's flavor; otherwise it stays a base. Returns (bases, routing), routing[i] giving one (base_index, iflav) per flavor -- the base SMATRIX and the 1-based extended FLAV_IDX (cross*nflav+flav) to reach that flavor. This is what per-flavor auto_dsig routing (piece 2) needs. Verified on p p > j j: gq_gq g q~ > g q~ routes to g q > g q (cross 4), 2 MEs -> 1 base; qq_qq q~ q~ > q~ q~ routes to q q > q q (cross 19) and is eliminated, while Q Q~ > Q Q~ stays a base because of its d d~ > u u~ flavor. TestCrossingPartition updated; suite green. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 108 ++++++++++-------- .../test_standalone_cross_symmetry.py | 41 +++---- 2 files changed, 81 insertions(+), 68 deletions(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 283dc2b6f..44f7ef792 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -2902,56 +2902,72 @@ def compute_crossing_pdg_entries(self, matrix_element, zero_based=True): return entries def partition_crossing_classes(self, matrix_elements): - """Partition a group's matrix elements into crossing-equivalence classes. - - Two subprocesses belong to the same class when one is a crossing of the - other -- same particles, related by the initial<->final leg swap the - crossing machinery encodes. One member of each class is the *base*: its - SMATRIX, driven by an extended FLAV_IDX, evaluates every member of the - class, so only the base needs its own matrix.f and the other members' - auto_dsig can call the base SMATRIX with the crossing's FLAV_IDX. - - Returns a list parallel to ``matrix_elements``: for member ``i`` a pair - ``(base_index, cross)`` where ``base_index`` indexes ``matrix_elements`` - (the class base) and ``cross`` is the crossing code that reaches member - ``i`` from that base (0 for a base itself). Matching is by the crossed - physical-PDG signature, exactly the key check_crossing matches on, so a - member is tied to a base only when the base can actually reproduce it. + """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) - - # Representative identity signature (cross == 0) of every member: the - # PDG tuple of its own leg ordering, the thing a base crossing must hit. - id_sig = [None] * n - for i, me in enumerate(matrix_elements): - for _idx, cross, _flav0, pdg in \ - self.compute_crossing_pdg_entries(me, zero_based=True): + # 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: - id_sig[i] = pdg + 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 - - assigned = [None] * n - for b in range(n): - if assigned[b] is not None: - continue - # b opens a new class as its base. - assigned[b] = (b, 0) - # Every crossed PDG signature b can reproduce, and with which code. - cross_by_sig = {} - for _idx, cross, _flav0, pdg in \ - self.compute_crossing_pdg_entries(matrix_elements[b], - zero_based=True): - if cross == 0: - continue - cross_by_sig.setdefault(pdg, cross) - # Pull every still-free member b can reach into b's class. - for m in range(n): - if assigned[m] is not None or id_sig[m] is None: - continue - cross = cross_by_sig.get(id_sig[m]) - if cross is not None: - assigned[m] = (b, cross) - return assigned + 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_ghremap(self, matrix_element, allow_reverse=True): """Build the good-helicity remap table for the crossing filter. diff --git a/tests/acceptance_tests/test_standalone_cross_symmetry.py b/tests/acceptance_tests/test_standalone_cross_symmetry.py index e841d86b0..9c00d8743 100644 --- a/tests/acceptance_tests/test_standalone_cross_symmetry.py +++ b/tests/acceptance_tests/test_standalone_cross_symmetry.py @@ -1789,10 +1789,10 @@ def test_use_crossing_false_byte_identical(self): class TestCrossingPartition(unittest.TestCase): - """partition_crossing_classes groups a subprocess group's matrix elements - into crossing-equivalence classes (a base plus the crossings it reproduces), - the basis for sharing one matrix.f across a base and its crossings in the - madevent output.""" + """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 @@ -1809,23 +1809,20 @@ def _groups(self, proc): def test_partition_pp_jj(self): groups, exp = self._groups('p p > j j') - total_me = total_base = 0 - found_cross = False + eliminated_any = False for g in groups: mes = g.get('matrix_elements') - assigned = exp.partition_crossing_classes(mes) - self.assertEqual(len(assigned), len(mes)) - bases = set() - for i, (b, c) in enumerate(assigned): - # the base a member points at is itself a base (cross 0) - self.assertEqual(assigned[b], (b, 0)) - bases.add(b) - if i != b: - self.assertNotEqual(c, 0) # a genuine crossing, not identity - found_cross = True - total_me += len(mes) - total_base += len(bases) - self.assertTrue(found_cross, - 'no crossing class found in p p > j j') - self.assertLess(total_base, total_me, - 'no matrix element was shared across a crossing') + 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') From d7bed92d2b683498f48d95e7642ce50c538d3e81 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 22 Jul 2026 13:15:10 +0200 Subject: [PATCH 10/43] madevent crossing M0 fix: NFLAV must be iden (max_flavor), not masks Verifying the n_flav/NFLAV consistency (needed before the per-flavor auto_dsig routing) turned up a real M0 bug. fill_crossing_replace_dict_me sized the extended-FLAV_IDX NFLAV from _build_flav_table_flat() (compute_flavor_masks, the STANDALONE convention), but the madevent GET_FLAVOR table is sized by get_external_flavors_with_iden() (== replace_dict 'max_flavor', what FLAVOR(NEXTERNAL,max_flavor)/MAXFLAVPERPROC use). For a merged group ME the two differ -- e.g. Q Q~ > g g has iden 1 but masks 4 -- so NFLAV=4 while the FLAVOR table has a single row, and FLAV_USE=mod(IFLAV-1,4)+1 would index it out of bounds at run time. Use get_external_flavors_with_iden() for NFLAV. This also makes the madevent decode agree with compute_crossing_pdg_entries (which already uses iden), so partition_crossing_classes' routed FLAV_IDX decodes correctly in the base SMATRIX -- the property the per-flavor routing (piece 2) relies on. Verified on p p > j j (crossing on): every matrix_orig.f now has PARAMETER(NFLAV) equal to its FLAVOR(NEXTERNAL,rows) count (0 mismatches over 8 matrices, incl. the NFLAV=2 module), and forhel still links. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 44f7ef792..8a82f9440 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -2431,7 +2431,17 @@ def fill_crossing_replace_dict_me(self, matrix_element, replace_dict, # 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 = self._build_flav_table_flat(matrix_element)[0] + # + # 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') From 262042fd57bf9af2f4fb649adc311c1f36e3f67b Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 22 Jul 2026 16:26:53 +0200 Subject: [PATCH 11/43] madevent crossing #6/#7: per-flavor auto_dsig routing to a shared base ME Wire the crossing merge into the madevent group output. generate_subprocess_ directory now partitions the group's matrix elements (partition_crossing_ classes) and, for a subprocess whose every flavor is a crossing of a base subprocess's flavor, writes a light router matrix.f instead of the full one: it keeps GET_FLAVOR (for the PDF) and a router SMATRIX that dispatches per flavor to the base SMATRIX with the crossed FLAV_IDX, dropping the heavy MATRIX/helas. The base SMATRIX (M0) crosses the momenta and rebuilds the crossed denominator, so the routed ANS is the subprocess's matrix element. get_nhel lives in auto_dsig.f so it is unaffected; the auto_dsig itself is unchanged (it still calls SMATRIX/GET_FLAVOR). - New template matrix_madevent_group_router_v4.inc; write_matrix_router_file. - ProcessExporterFortranMEGroup.supports_crossing = True (the _check_crossing_ support gate now lets a grouped madevent output through with --use_crossing). - Helicity recycling is turned off for a merged group for now (the router writes a static matrix.f while recycling builds matrix_optim.f at run time and the makefile globs one or the other; mixing them needs build-system work). - gen_infohtml.get_diagram_nb: tolerate a matrix.f with no diagram comment (a router shares the base's diagrams). Validated: p p > j j, low-stat integration. use_crossing=True (merged/router) gives 5.449e8 +- 2.8e6 pb vs use_crossing=False (complete reference) 5.448e8 +- 2.8e6 pb -- agree within MC error. P1_qq_qq runs 3 subprocesses on 2 heavy MEs, P1_gq_gq 2 on 1; both link and integrate. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 78 ++++++++++++++++++- madgraph/iolibs/gen_infohtml.py | 8 +- .../matrix_madevent_group_router_v4.inc | 45 +++++++++++ 3 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 madgraph/iolibs/template_files/matrix_madevent_group_router_v4.inc diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 8a82f9440..f986e77f6 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -8671,14 +8671,52 @@ 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 write_matrix_router_file(self, writer, matrix_element, fortran_model, + proc_id="", config_map=[], subproc_number="", + routing=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.""" + # 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 = [] + for flav0, (base_index, iflav) in enumerate(routing): + kw = 'IF' if flav0 == 0 else 'ELSE IF' + dispatch.append(' %s (IFLAV.EQ.%d) THEN' % (kw, flav0 + 1)) + dispatch.append( + ' CALL SMATRIX%d(P, %d, RHEL, RCOL, channel, IVEC, ANS,' + ' IHEL, ICOL)' % (base_index + 1, iflav)) + if dispatch: + dispatch.append(' ENDIF') + replace_dict['smatrix_router_dispatch'] = '\n'.join(dispatch) + 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 #=========================================================================== @@ -8750,9 +8788,43 @@ 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) + # The router writes static matrix.f, but helicity recycling builds + # matrix_optim.f at run time and the makefile globs one or the + # other. Mixing recycled bases and static routers needs build-system + # work, so a merged group compiles the direct matrix.f throughout + # for now (recycling stays available for non-merged crossing output). + if self.opt['hel_recycling']: + self.opt['hel_recycling'] = False + self.proc_characteristic['hel_recycling'] = False + else: + crossing_bases, crossing_routing = None, None + for ime, matrix_element in \ enumerate(matrix_elements): - if self.opt['hel_recycling']: + if crossing_routing is not None and ime not in crossing_bases: + filename = 'matrix%d.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]) + elif self.opt['hel_recycling']: filename = 'matrix%d_orig.f' % (ime+1) replace_dict = self.write_matrix_element_v4(None, matrix_element, diff --git a/madgraph/iolibs/gen_infohtml.py b/madgraph/iolibs/gen_infohtml.py index 2f5dee8e8..1a4077c67 100755 --- a/madgraph/iolibs/gen_infohtml.py +++ b/madgraph/iolibs/gen_infohtml.py @@ -244,10 +244,14 @@ def get_diagram_nb(self, proc, id): if not os.path.exists(path): path = os.path.join(self.dir, 'SubProcesses', proc, 'matrix%s_orig.f' % id) text = open(path).read() + match = None for match in re.finditer(pat, text): pass - nb_diag += int(match.groups()[0]) - + # A crossing-router matrix.f shares a base subprocess's diagrams and + # holds none of its own, so it has no diagram-number comment: count 0. + if match is not None: + nb_diag += int(match.groups()[0]) + return nb_diag 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 000000000..32b21d960 --- /dev/null +++ b/madgraph/iolibs/template_files/matrix_madevent_group_router_v4.inc @@ -0,0 +1,45 @@ + SUBROUTINE SMATRIX%(proc_id)s(P, IFLAV, RHEL, RCOL, channel, IVEC, ANS, IHEL, ICOL) +C +%(info_lines)s +C +C MadGraph5_aMC@NLO for Madevent Version +C +C Crossing-symmetry router: this subprocess shares its matrix element with a +C base subprocess of the same group. Each of its flavors is a crossing of a +C base flavor, so instead of its own (heavy) MATRIX it dispatches to the base +C SMATRIX with the extended FLAV_IDX that reproduces the crossed process. The +C base SMATRIX crosses the momenta P (supplied in this subprocess's own leg +C order) and rebuilds the crossed denominator, so ANS is this subprocess's +C matrix element. Only the flavor table (GET_FLAVOR) is kept here for the PDF. +C +%(process_lines)s +C + IMPLICIT NONE + INCLUDE 'nexternal.inc' + REAL*8 P(0:3,NEXTERNAL), ANS + DOUBLE PRECISION RHEL, RCOL + INTEGER channel, IVEC, IFLAV, IHEL, ICOL + 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 From 54114526b000434bb685c58c1175d68d6133d878 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 22 Jul 2026 16:50:50 +0200 Subject: [PATCH 12/43] madevent crossing: helicity recycling + router (drop the merged-group disable) Let a merged crossing group keep helicity recycling on its heavy base matrix elements while the light routers stay static. A router now writes matrix_router.f (not matrix.f): the makefile globs matrix*_router.o into both the forhel and the optimized binaries, gen_ximprove recycles only matrix*orig.f so it leaves routers untouched, and a base is recycled exactly as before (matrix_orig.f + template -> matrix_optim.f). Removes the stopgap that disabled recycling for a whole merged group. gen_infohtml.get_diagram_nb also looks for matrix_router.f and returns 0 when a subprocess has no matrix file of its own (a router shares the base's diagrams). Validated p p > j j, hel_recycling=True: bases produce matrix_optim.f, the router matrix3_router.o compiles and links, the full forhel->recycle->madevent run completes and gives 5.468e8 +- 3.0e6 pb vs the use_crossing=False reference 5.448e8 +- 2.8e6 pb (agree in MC error). Also validated (earlier commits' merge path): p p > t t~ j j merged == reference 344.8 pb exactly (massive tops), and p p > j j j merged 4.912e7 vs reference 4.923e7 pb (agree in MC error). Co-Authored-By: Claude Opus 4.8 --- Template/LO/SubProcesses/makefile | 8 ++++++++ madgraph/iolibs/export_v4.py | 14 +++++--------- madgraph/iolibs/gen_infohtml.py | 20 +++++++++++++------- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/Template/LO/SubProcesses/makefile b/Template/LO/SubProcesses/makefile index fbce3dfad..c387e596f 100644 --- a/Template/LO/SubProcesses/makefile +++ b/Template/LO/SubProcesses/makefile @@ -38,8 +38,16 @@ 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)) ifeq ($(strip $(MATRIX_HEL)),) MATRIX = $(patsubst %.f,%.o,$(wildcard matrix*.f)) +else + MATRIX += $(ROUTER) + MATRIX_HEL += $(ROUTER) endif diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index f986e77f6..8bb47c685 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -8803,21 +8803,17 @@ def generate_subprocess_directory(self, subproc_group, crossing_bases, crossing_routing = \ self.partition_crossing_classes(matrix_elements) crossing_bases = set(crossing_bases) - # The router writes static matrix.f, but helicity recycling builds - # matrix_optim.f at run time and the makefile globs one or the - # other. Mixing recycled bases and static routers needs build-system - # work, so a merged group compiles the direct matrix.f throughout - # for now (recycling stays available for non-merged crossing output). - if self.opt['hel_recycling']: - self.opt['hel_recycling'] = False - self.proc_characteristic['hel_recycling'] = False else: crossing_bases, crossing_routing = None, None for ime, matrix_element in \ enumerate(matrix_elements): if crossing_routing is not None and ime not in crossing_bases: - filename = 'matrix%d.f' % (ime+1) + # 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), diff --git a/madgraph/iolibs/gen_infohtml.py b/madgraph/iolibs/gen_infohtml.py index 1a4077c67..db308ea1d 100755 --- a/madgraph/iolibs/gen_infohtml.py +++ b/madgraph/iolibs/gen_infohtml.py @@ -236,19 +236,25 @@ 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 - # A crossing-router matrix.f shares a base subprocess's diagrams and - # holds none of its own, so it has no diagram-number comment: count 0. if match is not None: nb_diag += int(match.groups()[0]) From 685b2fa9dda79ac49a9e37218fb93fb0cdf09200 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 22 Jul 2026 17:08:44 +0200 Subject: [PATCH 13/43] madevent crossing: madevent now accepts crossing (update the gate test) TestCrossingUnsupportedOutput asserted that a madevent output refuses a process generated with crossing. That was true before Track A; the grouped madevent exporter now supports crossing (supports_crossing=True, the crossing router shares a base matrix element), so move 'madevent' out of UNSUPPORTED_FORMATS (matchbox stays) and assert instead that both standalone and madevent accept it. Verified: TestCrossingUnsupportedOutput 3/3 OK. Co-Authored-By: Claude Opus 4.8 --- .../test_standalone_cross_symmetry.py | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/acceptance_tests/test_standalone_cross_symmetry.py b/tests/acceptance_tests/test_standalone_cross_symmetry.py index 9c00d8743..f1adf5ddb 100644 --- a/tests/acceptance_tests/test_standalone_cross_symmetry.py +++ b/tests/acceptance_tests/test_standalone_cross_symmetry.py @@ -1324,13 +1324,16 @@ class TestCrossingUnsupportedOutput(unittest.TestCase): --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. Only the fortran standalone - decodes one. Any other output would therefore quietly produce a matrix - element that is missing those subprocesses, so it has to raise instead. + to reach them through an extended FLAV_IDX. The fortran standalone and the + (grouped) madevent output decode one; an output that cannot would quietly + produce a matrix element missing those subprocesses, so it has to raise + instead. """ - # Outputs reached through ExportV4Factory that have no crossing machinery. - UNSUPPORTED_FORMATS = ['madevent', 'matchbox'] + # 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_') @@ -1372,13 +1375,17 @@ def test_unsupported_output_accepted_without_crossing(self): self._output(fmt, 'ok_%s' % fmt, options='--use_crossing=False') - def test_standalone_still_accepts_crossing(self): - """The one output that does implement crossing must not be caught. + 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. + 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. """ - self._output('standalone', 'ok_standalone') + for fmt in ('standalone', 'madevent'): + with self.subTest(format=fmt): + self._output(fmt, 'ok_%s' % fmt) # The C++ standalone driver: take a fixed RAMBO phase space point once From f0cf84c573ca489bc738f042e4fcd08e07ff7a5e Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 22 Jul 2026 18:07:04 +0200 Subject: [PATCH 14/43] madevent crossing: fix router LHE colour flow (COLMAP) Checking the LHE colour flow (thanks to the reviewer's prompt) turned up a real bug. A router returns the base's selected colour-flow index ICOL, but events are written through the router's own leshouche ICOLUP. For p p > j j the two flow orders happen to coincide, but in general the crossed colour reps decompose the shared colour basis in a DIFFERENT order (verified: for p p > j j j and p p > t t~ j j the router flow set equals the crossed base set but is permuted), so ICOLUP(ICOL) is a valid but WRONG flow -- colour conservation still holds, so it is invisible in the event, but the parton shower would get the wrong colour connection. Add a per-flavor COLMAP in the router: match each crossed base flow (leg j <- base flow leg perm^-1(j), colour<->anticolour where that leg swapped initial/final) to the local flow of the same topology, and remap ICOL after the base call. Emitted only when non-identity. Helicity needs no such map: the router's own get_nhel already enumerates the crossed helicities in the base's order (rh[hb] = bh[hb] with the crossed legs sign-flipped, verified for every router in p p > j j / j j j). Verified: p p > j j j routers now carry COLMAP /4,3,2,1/ and /3,4,1,2/, compile, and the full run gives 4.929e7 +- 3.3e5 pb vs reference 4.923e7 +- 4.2e5 (xsec unchanged, as ICOL only labels the flow) with colour conserved in all events. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 98 ++++++++++++++++++- .../matrix_madevent_group_router_v4.inc | 8 ++ 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 8bb47c685..72f1b0b35 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -8686,28 +8686,119 @@ class ProcessExporterFortranMEGroup(ProcessExporterFortranME): #=========================================================================== # write_matrix_router_file #=========================================================================== + def _module_color_flows(self, matrix_element): + """Return the colour-flow decomposition (leshouche ICOLUP) of an ME as a + list, one entry per flow, of (colour, anticolour) per leg in leg order. + None if the ME has no colour basis.""" + if not matrix_element.get('color_basis'): + return None + proc = matrix_element.get('processes')[0] + legs = proc.get_legs_with_decays() + ninitial = matrix_element.get_nexternal_ninitial()[1] + repr_dict = {l.get('number'): + proc.get('model').get_particle(l.get('id')).get_color() + * (-1) ** (1 + l.get('state')) for l in legs} + flows = matrix_element.get('color_basis').color_flow_decomposition( + repr_dict, ninitial) + return [[tuple(cf[l.get('number')]) for l in legs] for cf in flows] + + 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): pair each label's colour leg with its + # anticolour leg, incoming legs swapping the two roles. + col, anti = {}, {} + for leg, (c, a) in enumerate(flow): + if rstates[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) + + 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): + 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.""" + not emitted. get_nhel lives in auto_dsig.f, so it is unaffected. + + The base returns the selected colour flow in the base's flow order; a + per-flavor COLMAP maps it to this subprocess's flow (same topology) so + the event's ICOLUP is right (see _router_colmap). 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 = [] 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 = self._router_colmap(matrix_element, base_me, cross) kw = 'IF' if flav0 == 0 else 'ELSE IF' dispatch.append(' %s (IFLAV.EQ.%d) THEN' % (kw, flav0 + 1)) dispatch.append( ' CALL SMATRIX%d(P, %d, RHEL, RCOL, channel, IVEC, ANS,' ' IHEL, ICOL)' % (base_index + 1, iflav)) + # A non-identity colmap has to be applied; skip it when it is the + # identity (single flow, or the flow orders already agree). + if colmap and colmap != list(range(1, len(colmap) + 1)): + 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') + replace_dict['smatrix_router_decl'] = '\n'.join(decl) replace_dict['smatrix_router_dispatch'] = '\n'.join(dispatch) tpl = open(pjoin(_file_path, 'iolibs', 'template_files', 'matrix_madevent_group_router_v4.inc')).read() @@ -8819,7 +8910,8 @@ def generate_subprocess_directory(self, subproc_group, fortran_model, proc_id=str(ime+1), config_map=subproc_group.get('diagram_maps')[ime], subproc_number=group_number, - routing=crossing_routing[ime]) + 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, diff --git a/madgraph/iolibs/template_files/matrix_madevent_group_router_v4.inc b/madgraph/iolibs/template_files/matrix_madevent_group_router_v4.inc index 32b21d960..056af8b9c 100644 --- a/madgraph/iolibs/template_files/matrix_madevent_group_router_v4.inc +++ b/madgraph/iolibs/template_files/matrix_madevent_group_router_v4.inc @@ -19,6 +19,14 @@ C REAL*8 P(0:3,NEXTERNAL), ANS DOUBLE PRECISION RHEL, RCOL INTEGER channel, IVEC, IFLAV, IHEL, ICOL +C Per-flavor colour-flow remap: the base returns the selected colour flow in +C its own basis, but events are written through this subprocess's leshouche +C ICOLUP, whose flows can be ordered differently (the crossed colour reps +C decompose the shared colour basis in another order). COLMAP sends the base +C flow index to the local flow with the same colour topology. (The helicity +C index needs no such map: this subprocess's get_nhel already enumerates the +C crossed helicities in the base's order.) +%(smatrix_router_decl)s ANS = 0D0 IHEL = 1 ICOL = 1 From b7a1cc7ffdbacb3b3df8068ba5295a4ffc93033e Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 22 Jul 2026 20:31:05 +0200 Subject: [PATCH 15/43] madevent crossing: block beam polarisation + EVA under crossing Crossing reuses one matrix element across physically distinct (crossed) initial states, so a per-beam property is ill-defined. Rather than return silently-wrong numbers, tag the output and raise a clear error at run time. Generation: in generate_subprocess_directory, append 'crossing' to proc_characteristic['limitations'] -- but only when crossing is materially applied (a router is emitted, or a base evaluates a cross>0 flavor). A process where crossing does nothing (e.g. two unrelated single-process groups) stays untagged and is never blocked. Run time: in check_card_consistency (LO branch, next to the dressed_ee check), when 'crossing' is tagged, raise InvalidCmd if polbeam1/polbeam2 != 0 (beam polarisation) or pdlabel/pdlabel1/pdlabel2 == 'eva' (EVA luminosity). The message points to 'generate --use_crossing=False'. The guard sits inside the existing RunCardLO gate, so NLO cards (no polbeam) are untouched. Verified: p p > j j tags ['crossing'] (round-trips as a list); the guard blocks polbeam=-100 and pdlabel=eva, and does NOT block when limitations=[] with the same settings; --use_crossing=False gives limitations=[], 0 routers, 8 full matrix.f (falls back to the 3.x per-subprocess matrix elements). Co-Authored-By: Claude Opus 4.8 --- madgraph/interface/common_run_interface.py | 23 +++++++++++++++++++++- madgraph/iolibs/export_v4.py | 16 +++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/madgraph/interface/common_run_interface.py b/madgraph/interface/common_run_interface.py index 75a8de140..6105b9fdc 100755 --- a/madgraph/interface/common_run_interface.py +++ b/madgraph/interface/common_run_interface.py @@ -6854,7 +6854,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/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 72f1b0b35..3aa41d743 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -8894,6 +8894,22 @@ def generate_subprocess_directory(self, subproc_group, crossing_bases, crossing_routing = \ self.partition_crossing_classes(matrix_elements) crossing_bases = set(crossing_bases) + # 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') else: crossing_bases, crossing_routing = None, None From bed7fd680d04c5edc074f49e3212083923cf2fd6 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 22 Jul 2026 22:37:17 +0200 Subject: [PATCH 16/43] madevent crossing Track B: cross-group ME reuse via symlink (lepton/photon) For lepton/photon beams (define EP=e+ a; EM=e- a; generate EP EM > ...) each initial state lands in its own single-process P directory and the crossings relate DIFFERENT directories, so the within-group router (Track A) never fires and every group compiles its own matrix element even when several are crossings of one base. This adds cross-group reuse: a dependent group symlinks the base group's crossing-aware SMATRIX and routes to it, keeping its own phase space and PDFs. - compute_crossgroup_routing(): flatten every group's matrix elements, run the (group-agnostic) crossing partition, and return, per dependent, the base directory/proc_id and the crossed FLAV_IDX per flavor. It bows out entirely if ANY group has within-group routing -- that is the hadronic p p case, which Track A owns and where cross-group merging is a separate deferred optimisation. Wired into export_processes before the group loop. - generate_subprocess_directory: a dependent writes NO matrix element of its own; it symlinks the base group's matrix.f (+ template with hel recycling) and its auto_dsig routes to the base SMATRIX. - auto_dsig_v4.inc: backward-compatible holes (default == original) for the flavor lookup and the SMATRIX call in BOTH the scalar and vectorised (SMATRIX_MULTI) paths, plus per-program-unit declaration holes. A dependent inlines its own beams (DSIG_XGFLAV, since it cannot own a GET_FLAVOR without clashing with the symlinked base's) and calls SMATRIX(P, DSIG_XGROUTE (IFLAV), ...). Verified p p > j j auto_dsig is BYTE-IDENTICAL. Validated on EP EM > EP EM: check crossing 5/5 at machine precision; the dependent compiles and links; and a full generate_events (direct e+e- collider) gives 43.23 +- 0.17 pb (crossing) vs 43.40 +- 0.16 pb (independent) with every subprocess -- all three cross-group dependents included -- agreeing. Deferred: sharing the compiled .o across directories (recompilation is still per-dir), COLMAP/hel-map for the LHE event records, CONFIGMAP for multi-channel at higher multiplicity, and tagging the 'crossing' limitation for the cross- group case. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 227 +++++++++++++++++- .../iolibs/template_files/auto_dsig_v4.inc | 18 +- 2 files changed, 230 insertions(+), 15 deletions(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 3aa41d743..4255e6383 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -669,8 +669,9 @@ def _make_flavor_pdg_fortran_function(self, func_name, n_flavors, pdg_flat, #=========================================================================== 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 if isinstance(matrix_elements, group_subprocs.SubProcessGroupList): # check handling for the polarization for m in matrix_elements: @@ -683,6 +684,17 @@ 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) + 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})) + for (group_number, me_group) in enumerate(matrix_elements): calls = calls + self.generate_subprocess_directory(\ me_group, fortran_model, group_number, @@ -2979,6 +2991,70 @@ def partition_crossing_classes(self, matrix_elements): 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 {} + flat = [] # (group_enum_idx, me_idx, matrix_element) + for gi, group in enumerate(subproc_groups): + for mi, me in enumerate(group.get('matrix_elements')): + flat.append((gi, mi, me)) + # A pinned s-channel does not survive crossing (see breaks_crossing_ + # symmetry): fall back to independent matrix elements for the whole run. + if any(self.breaks_crossing_symmetry(proc) + for (_, _, me) in flat for proc in me.get('processes')): + return {} + # Cross-group routing is the lepton/photon mechanism -- distinct beam + # particles, each initial state landing in its OWN single-process group. + # It must NOT touch the hadronic p p case, where the crossings live + # inside one group and are already shared by within-group routers (Track + # A); merging those groups too is a separate, deferred optimisation. The + # p p case is exactly the one with within-group crossing routing, so if + # any group routes a flavor within itself, leave the whole run to Track A. + for group in subproc_groups: + mes_g = group.get('matrix_elements') + g_bases, _ = self.partition_crossing_classes(mes_g) + if len(g_bases) < len(mes_g): + 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. @@ -6352,6 +6428,23 @@ 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_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)' + # Set dsig_line if ninitial == 1: # No conversion, since result of decay should be given in GeV @@ -7476,12 +7569,93 @@ 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] + + #=========================================================================== + # _dsig_crossgroup_fills + #=========================================================================== + 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. + + The colour flow (COLMAP) and multi-channel config (CONFIGMAP) remaps are + added in a later step; the channel is passed through for now. + """ + base_proc_id = crossgroup['base_proc_id'] + flav_idx = crossgroup['flav_idx'] # per dep flavor -> base FLAV_IDX + 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)] + decl.append(' INTEGER DSIG_XGROUTE(%d)' % len(all_flv)) + decl.append(' DATA DSIG_XGROUTE /%s/' + % ','.join(str(x) for x in flav_idx)) + # DSIG_XGFLAV/DSIG_XGROUTE 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_getflavor': ' FLAVOR(:) = DSIG_XGFLAV(:, IFLAV)', + 'dsig_smatrix_call': ( + ' CALL SMATRIX%d(P1, DSIG_XGROUTE(IFLAV), RHEL, RCOL, channel,' + ' 1, DSIGUU, selected_hel(1), selected_col(1))' % base_proc_id), + # 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))', + } + #=========================================================================== # 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'): @@ -7535,6 +7709,23 @@ 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_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)' + # Set dsig_line if ninitial == 1: # No conversion, since result of decay should be given in GeV @@ -7646,9 +7837,14 @@ def write_auto_dsig_file(self, writer, matrix_element, proc_id = ""): f, pdg_to_group_pos, max_group_size)) for f in flav[0]] replace_dict['get_flavor_matrix'] += ' DATA (FLAVOR(i, %d),i= 1, NEXTERNAL) /%s/\n' % (i+1, ', '.join(flav_positions)) - + # Cross-group dependent (Track B): override the flavor lookup + SMATRIX + # call to route to the symlinked base group's crossing-aware SMATRIX. + if crossgroup is not None: + replace_dict.update( + self._dsig_crossgroup_fills(matrix_element, proc_id, crossgroup)) + if writer: file = open(pjoin(_file_path, \ 'iolibs/template_files/auto_dsig_v4.inc')).read() @@ -8915,7 +9111,25 @@ def generate_subprocess_directory(self, subproc_group, for ime, matrix_element in \ enumerate(matrix_elements): - if crossing_routing is not None and ime not in crossing_bases: + 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) + # 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 @@ -8996,7 +9210,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) diff --git a/madgraph/iolibs/template_files/auto_dsig_v4.inc b/madgraph/iolibs/template_files/auto_dsig_v4.inc index 4730c4fb5..af2add9ca 100644 --- a/madgraph/iolibs/template_files/auto_dsig_v4.inc +++ b/madgraph/iolibs/template_files/auto_dsig_v4.inc @@ -106,7 +106,7 @@ c double precision P1(0:3, nexternal) integer channel double precision rwgt_value -C +%(dsig_xg_decl)sC C DATA C %(pdf_data)s @@ -166,7 +166,7 @@ C Continue only if IMODE is 0, 4 or 5 WGT = WGT * %(maxflavor)d endif endif - CALL GET_FLAVOR%(proc_id)s(IFLAV, FLAVOR) +%(dsig_getflavor)s %(passcuts_begin)s ## if( nogrouping) { ! for no grouping update the scale here (done in main autodsig for grouping @@ -214,7 +214,7 @@ C and IFLAV are still set above so SMATRIX gets valid arguments. rwgt_value=1d0 endif - CALL SMATRIX%(proc_id)s(P1, IFLAV, RHEL, RCOL,channel,1, DSIGUU, selected_hel(1), selected_col(1)) +%(dsig_smatrix_call)s DSIGUU = DSIGUU* rwgt_value @@ -389,9 +389,9 @@ c C Per-event MLM graph: igraphs(1) from REWGT (0 = no MLM) integer igraph(VECSIZE_MEMMAX) common/vec_igraph/igraph -C +%(dsig_xg_decl_vec)sC C DATA -C +C %(pdf_data_vec)s C ---------- C BEGIN CODE @@ -442,7 +442,7 @@ C Select a flavor combination (need to do here for right sign) %(get_channel_vec)s - CALL GET_FLAVOR%(proc_id)s(IFLAV_VEC(IVEC), FLAVOR) +%(dsig_getflavor_vec)s if (IMODE.eq.0) then ALL_RWGT(IVEC) = REWGT(all_PP(0,1,IVEC),FLAVOR,ivec) else @@ -549,14 +549,14 @@ 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), From d390d9aa99d5843b5659cfa98894672dbc68d6ad Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 22 Jul 2026 22:57:41 +0200 Subject: [PATCH 17/43] madevent crossing Track B: share the base .o + parallel build of all P dirs Cross-group dependents symlinked the base group's matrix-element SOURCE but each directory still recompiled it. Reuse the compiled object instead, and build every P directory in one parallel call. - write_crossgroup_mk(): each dependent P dir gets a crossgroup.mk (pulled in by the shared makefile via `-include crossgroup.mk`, a no-op where absent) whose specific rule symlinks matrix_orig.o from the base directory rather than recompiling the symlinked source; the base object is built first (the specific rule overrides the %.o:%.f pattern, and a recursive rule is the standalone ordering fallback). Only matrix_orig.o -- the full matrix element, identical across the crossing class -- is shared; matrix_optim.o is NOT, because gen_ximprove bakes each subprocess's own good-helicity set into it (it is subprocess-specific). Without recycling the single matrix.o is shared. - write_crossgroup_parallel_makefile(): SubProcesses/makefile_madevent builds every P directory with `make -f makefile_madevent -jN` (or `... forhel`), delegating each to its own makefile and adding `/madevent: /madevent` ordering so make compiles the base before its dependents while running the rest in parallel. Verified: parallel forhel build (-j4) links all binaries with the dependents' matrix1_orig.o symlinked to the base (compiled once); a full generate_events still gives 43.23 pb; p p is unaffected (no crossgroup files, auto_dsig byte-identical, the -include is a no-op); acceptance suite 37/37 OK. Co-Authored-By: Claude Opus 4.8 --- Template/LO/SubProcesses/makefile | 5 +++ madgraph/iolibs/export_v4.py | 72 +++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/Template/LO/SubProcesses/makefile b/Template/LO/SubProcesses/makefile index c387e596f..1c91a8a13 100644 --- a/Template/LO/SubProcesses/makefile +++ b/Template/LO/SubProcesses/makefile @@ -1,6 +1,11 @@ include ../../Source/make_opts FFLAGS+= -w -I ../../Source/DHELAS/ -I ../../Source/MODEL +# 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). Absent elsewhere. +-include crossgroup.mk + # Load additional dependencies of the bias module, if present ifeq (,$(wildcard ../bias_dependencies)) BIASDEPENDENCIES = diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 4255e6383..cf9eb4acb 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -672,6 +672,7 @@ def export_processes(self, matrix_elements, fortran_model, second_exporter=None, calls = 0 self._crossgroup = {} # (group_idx, me_idx) -> base info; Track B below + self._crossgroup_dirs = [] # (dependent_dir, base_dir) for the parallel makefile if isinstance(matrix_elements, group_subprocs.SubProcessGroupList): # check handling for the polarization for m in matrix_elements: @@ -700,6 +701,9 @@ def export_processes(self, matrix_elements, fortran_model, second_exporter=None, 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')) else: # check handling for the polarization self.beam_polarization = [True,True] @@ -7585,6 +7589,66 @@ def _crossgroup_base_files(self, 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 only matrix_orig.o (the full matrix element, + identical across the crossing class) is shared; matrix_optim.o is NOT, + because gen_ximprove bakes THIS subprocess's own good-helicity set into it, + so it is subprocess-specific. 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] + 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)) + open('crossgroup.mk', 'w').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 #=========================================================================== @@ -9121,6 +9185,14 @@ def generate_subprocess_directory(self, subproc_group, # 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'])) # 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. From a2c0a0c891c49f094f288c1aeef9f85ed9fa9c57 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 22 Jul 2026 23:23:51 +0200 Subject: [PATCH 18/43] madevent crossing Track B: event helicity (+colour) remap for cross-group A cross-group dependent evaluates its matrix element through the base group's SMATRIX, which selects a helicity/colour in the BASE's enumeration; but the event is written through this subprocess's OWN get_helicities / ICOLUP, so the index must be translated. Without it the LHE events carried the base's helicity labels against the dependent's table -- e.g. e+ e- > a a events showed unphysical same-helicity e+e- pairs (the cross section, summed over helicities, was already right, so this was invisible until the events were inspected). - _crossgroup_helmap(dep_me, base_me, cross): 1-based map from a base helicity index to the dependent helicity index carrying the physically-crossed configuration. The base APPLY_CROSSING permutes NHEL by PERM and flips it by the IC sign, so dep config[k] = base_row[PERM[k]]*SGN[k]; the result is a clean permutation of the dependent's helicity table. - _dsig_crossgroup_fills emits per-flavor DSIG_XGHEL (and DSIG_XGCOL for colour, which is the identity and thus skipped for colourless lepton/photon) and applies selected_hel = DSIG_XGHEL(selected_hel, IFLAV) after the base SMATRIX call in both the scalar and vectorised (SMATRIX_MULTI) paths. Emitted only when non-identity; the new dsig_smatrix_vec_post hole attaches to the call's closing paren so the default is byte-identical. Verified: e+ e- > a a events now have physical opposite-helicity e+e- (matching the independent generation, zero same-helicity events); xsec 43.32 pb; p p auto_dsig byte-identical; acceptance suite 37/37 OK. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 84 ++++++++++++++++--- .../iolibs/template_files/auto_dsig_v4.inc | 2 +- 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index cf9eb4acb..598ec8505 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -6448,6 +6448,7 @@ def write_auto_dsig_file(self, writer, matrix_element, proc_id = ""): ' 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_post'] = '' # Set dsig_line if ninitial == 1: @@ -7652,6 +7653,29 @@ def write_crossgroup_parallel_makefile(self, subproc_path): #=========================================================================== # _dsig_crossgroup_fills #=========================================================================== + def _crossgroup_helmap(self, dep_me, base_me, cross): + """1-based map from a base helicity index to the DEPENDENT helicity index + carrying the physically-crossed configuration. The base SMATRIX selects a + helicity in ITS own NHEL enumeration, but the event is written through the + dependent's own get_helicities, so the index must be translated. The base + APPLY_CROSSING permutes NHEL by PERM and flips it by the IC sign, so a base + row corresponds to dep config[k] = base_row[PERM[k]]*SGN[k]. Returns the + identity if that is not a clean permutation of the dependent's table.""" + bh = [tuple(x) for x in base_me.get_helicity_matrix()] + dh = [tuple(x) for x in dep_me.get_helicity_matrix()] + tables = ProcessExporterFortran.compute_crossing_tables(self, base_me) + nx = tables['nexternal'] + perm = tables['perm'] + ic = tables['ic'] + P = [perm[cross * nx + k] for k in range(nx)] # 0-based source leg + S = [ic[cross * nx + k] for k in range(nx)] + dhpos = {cfg: i for i, cfg in enumerate(dh)} + hmap = [dhpos.get(tuple(row[P[k]] * S[k] for k in range(nx)), -1) + for row in bh] + if -1 in hmap or sorted(hmap) != list(range(len(bh))): + return list(range(1, len(bh) + 1)) + return [h + 1 for h in hmap] + 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 @@ -7665,12 +7689,17 @@ def _dsig_crossgroup_fills(self, matrix_element, proc_id, crossgroup): (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. - - The colour flow (COLMAP) and multi-channel config (CONFIGMAP) remaps are - added in a later step; the channel is passed through for now. + * 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). Emitted only when non-identity (colour is the + identity for colourless processes; the channel is still passed through -- + CONFIGMAP is a later step). """ 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) @@ -7681,12 +7710,25 @@ def _dsig_crossgroup_fills(self, matrix_element, proc_id, crossgroup): 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)] - decl.append(' INTEGER DSIG_XGROUTE(%d)' % len(all_flv)) - decl.append(' DATA DSIG_XGROUTE /%s/' - % ','.join(str(x) for x in flav_idx)) - # DSIG_XGFLAV/DSIG_XGROUTE are used from three separate program units - # (DSIG, DSIG_VEC, SMATRIX_MULTI); declare them in each. + ' 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 event helicity + colour maps (base index -> dependent index). + ncomb = base_me.get_helicity_combinations() + helmap = [self._crossgroup_helmap(matrix_element, base_me, + (iflav - 1) // nflav_base) + for iflav in flav_idx] + 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 + hel_post = self._crossgroup_remap_decl( + decl, 'DSIG_XGHEL', helmap, ncomb, 'selected_hel') + col_post = self._crossgroup_remap_decl( + decl, 'DSIG_XGCOL', colmap, ncol, 'selected_col') + # 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 { @@ -7696,7 +7738,9 @@ def _dsig_crossgroup_fills(self, matrix_element, proc_id, crossgroup): 'dsig_getflavor': ' FLAVOR(:) = DSIG_XGFLAV(:, IFLAV)', 'dsig_smatrix_call': ( ' CALL SMATRIX%d(P1, DSIG_XGROUTE(IFLAV), RHEL, RCOL, channel,' - ' 1, DSIGUU, selected_hel(1), selected_col(1))' % base_proc_id), + ' 1, DSIGUU, selected_hel(1), selected_col(1))' % base_proc_id + + hel_post.format(idx='(1)', flav='IFLAV') + + col_post.format(idx='(1)', flav='IFLAV')), # 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. @@ -7704,8 +7748,27 @@ def _dsig_crossgroup_fills(self, matrix_element, proc_id, crossgroup): ' 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_post': ( + hel_post.format(idx='(IVEC)', flav='IFLAV_VEC(IVEC)') + + col_post.format(idx='(IVEC)', flav='IFLAV_VEC(IVEC)')), } + def _crossgroup_remap_decl(self, decl, name, maps, size, var): + """Helper for _dsig_crossgroup_fills: if any flavor's map is non-identity, + append the DATA declaration of a (size, nflav) remap table to ``decl`` and + return a str.format template with fields {idx} (the (1)/(IVEC) slot) and + {flav} (the flavor expression). Otherwise return an empty string. The DATA + is column-major (index fastest, then flavor).""" + identity = list(range(1, size + 1)) + if all(m == identity for m in maps): + return '' + flat = ','.join(str(x) for col in maps for x in col) + decl.append(' INTEGER %s(%d,%d)' % (name, size, len(maps))) + decl.append(' DATA %s /%s/' % (name, flat)) + return ('\n IF ({v}{{idx}}.GE.1.AND.{v}{{idx}}.LE.{n}) ' + '{v}{{idx}} = {name}({v}{{idx}}, {{flav}})').format( + v=var, n=size, name=name) + #=========================================================================== # write_auto_dsig_file #=========================================================================== @@ -7789,6 +7852,7 @@ def write_auto_dsig_file(self, writer, matrix_element, proc_id = "", ' 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_post'] = '' # Set dsig_line if ninitial == 1: diff --git a/madgraph/iolibs/template_files/auto_dsig_v4.inc b/madgraph/iolibs/template_files/auto_dsig_v4.inc index af2add9ca..0d1a394e4 100644 --- a/madgraph/iolibs/template_files/auto_dsig_v4.inc +++ b/madgraph/iolibs/template_files/auto_dsig_v4.inc @@ -564,7 +564,7 @@ C Per-event MLM graph: igraphs(1) from REWGT (0 = no MLM) & out(IVEC), & selected_hel(IVEC), & selected_col(IVEC) - & ) + & )%(dsig_smatrix_vec_post)s ENDDO %(OMP_POSTFIX)s From e1a456a2376b0640e3e95b9267eedc02729029cc Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 23 Jul 2026 00:07:27 +0200 Subject: [PATCH 19/43] madevent crossing Track B: share the optim too via a union good-hel The base group's compiled orig object was already shared, but each dependent still compiled its own _optim: helicity recycling bakes each subprocess's good- helicity INDEX set, and under crossing those sets are permutations of each other, so the base's baked set does not cover a dependent's. The fix mirrors what the within-group case gets for free (there the base's forhel survey already sees all the crossed flavors): bake the base optim over the UNION good-hel of the crossing class. Because G_dep = pi^-1(G_base), the union is derivable from the base's set plus the crossing permutations -- no dependent surveys needed. - _crossgroup_base_helperm(base_me, cross): the base->base helicity permutation pi[hb] = the base index whose NHEL row equals the crossed row of hb (factored _crossed_helicity_configs, shared with _crossgroup_helmap). The dependent for that crossing is good at h iff pi[h] is good for the base. - export persists these per base directory in crossgroup_helunion.dat. - gen_ximprove reads it and, before hel_recycle, expands the base's good-hel to the union. - crossgroup.mk re-adds the matrix_optim.o symlink. The `-include crossgroup.mk` moves to the END of the SubProcesses makefile so its specific rules override the $(MATRIX) static-pattern rule for the optim objects (orig already worked from the top since orig is not in $(MATRIX)). Verified on EP EM > EP EM: base optim now bakes the union {3,5,8,9,12,14} (6, was 4); all three dependents' matrix1_orig.o AND matrix1_optim.o are symlinks to the base (compiled once); xsec 43.32 pb; e+ e- > a a event helicities still physical; p p matrix+auto_dsig byte-identical with no crossgroup files; acceptance suite 37/37 OK. Co-Authored-By: Claude Opus 4.8 --- Template/LO/SubProcesses/makefile | 12 ++-- madgraph/iolibs/export_v4.py | 95 ++++++++++++++++++++++++------- madgraph/madevent/gen_ximprove.py | 24 +++++++- 3 files changed, 103 insertions(+), 28 deletions(-) diff --git a/Template/LO/SubProcesses/makefile b/Template/LO/SubProcesses/makefile index 1c91a8a13..6122052f4 100644 --- a/Template/LO/SubProcesses/makefile +++ b/Template/LO/SubProcesses/makefile @@ -1,11 +1,6 @@ include ../../Source/make_opts FFLAGS+= -w -I ../../Source/DHELAS/ -I ../../Source/MODEL -# 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). Absent elsewhere. --include crossgroup.mk - # Load additional dependencies of the bias module, if present ifeq (,$(wildcard ../bias_dependencies)) BIASDEPENDENCIES = @@ -115,3 +110,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/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 598ec8505..2ae138b51 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -673,6 +673,7 @@ def export_processes(self, matrix_elements, fortran_model, second_exporter=None, calls = 0 self._crossgroup = {} # (group_idx, me_idx) -> base info; Track B below 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: @@ -704,6 +705,9 @@ def export_processes(self, matrix_elements, fortran_model, second_exporter=None, 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] @@ -7600,14 +7604,15 @@ def write_crossgroup_mk(self, base_dir, base_proc_id): fallback -- the top-level parallel makefile also orders base before dependents). - With helicity recycling only matrix_orig.o (the full matrix element, - identical across the crossing class) is shared; matrix_optim.o is NOT, - because gen_ximprove bakes THIS subprocess's own good-helicity set into it, - so it is subprocess-specific. Without recycling the single matrix.o is - the full, shareable object.""" + With helicity recycling BOTH matrix_orig.o (the full matrix element) and + matrix_optim.o are shared: gen_ximprove bakes the base optim over the + UNION good-hel 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] + 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] @@ -7619,6 +7624,24 @@ def write_crossgroup_mk(self, base_dir, base_proc_id): lines.append('\t+$(MAKE) -C %s %s' % (pjoin('..', base_dir), o)) open('crossgroup.mk', 'w').write('\n'.join(lines) + '\n') + def write_crossgroup_helunion(self, subproc_path): + """Write crossgroup_helunion.dat in each cross-group BASE directory. Each + line is ` p1 p2 ... pNCOMB`, a base->base helicity + permutation of one dependent crossing: the dependent is good at helicity h + iff p[h] is good for the base. gen_ximprove reads it and bakes the base + optim over the UNION good-hel of the class (G_base plus the images under + these permutations), so a single compiled optim serves every member.""" + 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`. @@ -7653,29 +7676,46 @@ def write_crossgroup_parallel_makefile(self, subproc_path): #=========================================================================== # _dsig_crossgroup_fills #=========================================================================== + def _crossed_helicity_configs(self, base_me, cross): + """The base helicity rows after applying the crossing: crossed[hb][k] = + base_row[PERM[k]]*SGN[k] (PERM 0-based source leg, SGN the IC sign), i.e. + what APPLY_CROSSING makes of each base NHEL row. Returns (base_rows, + crossed_rows) as lists of 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)] + S = [tables['ic'][cross * nx + k] for k in range(nx)] + crossed = [tuple(row[P[k]] * S[k] for k in range(nx)) for row in bh] + return bh, crossed + def _crossgroup_helmap(self, dep_me, base_me, cross): """1-based map from a base helicity index to the DEPENDENT helicity index carrying the physically-crossed configuration. The base SMATRIX selects a helicity in ITS own NHEL enumeration, but the event is written through the - dependent's own get_helicities, so the index must be translated. The base - APPLY_CROSSING permutes NHEL by PERM and flips it by the IC sign, so a base - row corresponds to dep config[k] = base_row[PERM[k]]*SGN[k]. Returns the - identity if that is not a clean permutation of the dependent's table.""" - bh = [tuple(x) for x in base_me.get_helicity_matrix()] + dependent's own get_helicities, so the index must be translated. Returns + the identity if that is not a clean permutation of the dependent's table.""" + _, crossed = self._crossed_helicity_configs(base_me, cross) dh = [tuple(x) for x in dep_me.get_helicity_matrix()] - tables = ProcessExporterFortran.compute_crossing_tables(self, base_me) - nx = tables['nexternal'] - perm = tables['perm'] - ic = tables['ic'] - P = [perm[cross * nx + k] for k in range(nx)] # 0-based source leg - S = [ic[cross * nx + k] for k in range(nx)] dhpos = {cfg: i for i, cfg in enumerate(dh)} - hmap = [dhpos.get(tuple(row[P[k]] * S[k] for k in range(nx)), -1) - for row in bh] - if -1 in hmap or sorted(hmap) != list(range(len(bh))): - return list(range(1, len(bh) + 1)) + hmap = [dhpos.get(c, -1) for c in crossed] + if -1 in hmap or sorted(hmap) != list(range(len(crossed))): + return list(range(1, len(crossed) + 1)) return [h + 1 for h in hmap] + def _crossgroup_base_helperm(self, base_me, cross): + """1-based base->base helicity permutation of a crossing: pi[hb] = the base + index whose NHEL row equals the crossed row of hb. So the dependent for + this crossing has good helicity hb iff pi[hb] is good for the base -- which + is how gen_ximprove expands the base optim over the UNION good-hel of the + class so it can be shared. Returns None if not a clean permutation.""" + bh, crossed = self._crossed_helicity_configs(base_me, cross) + 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 _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 @@ -9257,6 +9297,19 @@ def generate_subprocess_directory(self, subproc_group, 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 permutation(s) so + # the base optim can be baked over the UNION good-hel and shared. + 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_helperm( + base_me, (iflav - 1) // nflav_base) + if pi is not None and pi != list(range(1, len(pi) + 1)) \ + and 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. diff --git a/madgraph/madevent/gen_ximprove.py b/madgraph/madevent/gen_ximprove.py index 455cec077..dd332e1e4 100755 --- a/madgraph/madevent/gen_ximprove.py +++ b/madgraph/madevent/gen_ximprove.py @@ -271,8 +271,22 @@ def get_helicity(self, to_submit=True, clean=True): fsock.write(data) + # Cross-group crossing (Track B): in a base directory, bake the optim + # over the UNION good-hel of the crossing class so a single compiled + # optim can be shared by every crossing. 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): - + split_file = matrix_file.split('/') me_index = split_file[-1][len('matrix'):-len('_orig.f')] @@ -286,7 +300,13 @@ 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])] + good_set = set(all_good_hels[me_index]) + # Cross-group: expand to the UNION good-hel of the class. + gbase = frozenset(good_set) + for pi in helunion.get(me_index, []): + good_set |= {h for h in range(1, len(pi) + 1) + if pi[h - 1] in gbase} + good_hels = [str(x) for x in sorted(good_set)] if self.run_card['hel_zeroamp']: bad_amps = [str(x) for x in sorted(all_bad_amps[me_index])] From ab2ed1cc15c08489a6da266032eae69472158efe Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 23 Jul 2026 00:15:39 +0200 Subject: [PATCH 20/43] madevent crossing Track B: tag 'crossing' limitation for cross-group too The within-group case already tags the 'crossing' limitation so check_card_consistency blocks beam polarisation / EVA (a per-beam property is ill-defined once one matrix element is shared across crossed initial states). Cross-group (lepton/photon) reuse shares a matrix element across even more distinct initial states (e+ vs e- vs gamma), so tag it the same way when compute_crossgroup_routing returns any routing. Verified: EP EM > EP EM now reports limitations = ['crossing'] and the guard blocks polbeam/EVA; the non-crossing EP EM > mu+ mu- (two unrelated groups) stays []; p p > j j still tags via the within-group path. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 2ae138b51..09d704a15 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -696,6 +696,12 @@ def export_processes(self, matrix_elements, fortran_model, second_exporter=None, 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(\ From 523f8889e583e583c5b265d88a4c88713685fb36 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 23 Jul 2026 00:33:32 +0200 Subject: [PATCH 21/43] madevent crossing Track B: CONFIGMAP for the multi-channel channel A cross-group dependent samples its own config's poles in genps, but the base SMATRIX enhances AMP2(channel) in the BASE's diagram numbering, so the channel must name the base diagram of the same topology -- otherwise the importance sampling is mis-paired. This never changes the result (summing the channels gives the full integral for any bijective pairing), only the convergence, which matters at higher multiplicity. - _diagram_leg_subsets(me): per diagram, the set of its internal propagators' canonical external-leg subsets (from get_s_and_t_channels; propagators are the negative leg numbers, the trailing single-external-leg t-channel one dropped; a subset and its complement are one propagator) -- a crossing-covariant topology signature. - _crossgroup_configmap(dep, base, cross): map each dependent diagram's subsets through the crossing leg permutation and match to the base diagram with that signature; identity if not a clean permutation. - _dsig_crossgroup_fills emits DSIG_XGCONFIG and wraps the channel as DSIG_XGCONFIG(channel, IFLAV) in both the scalar call and the new vec channel hole, only when non-identity. Verified on EP EM > EP EM: a e > a e is the identity (absent), but a e- > a e- and e+ e- > a a get /2,1/ (identity was mis-pairing the two diagrams there); xsec 43.32 pb; p p matrix+auto_dsig byte-identical; acceptance suite 37/37 OK. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 92 ++++++++++++++++++- .../iolibs/template_files/auto_dsig_v4.inc | 2 +- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 09d704a15..1becfcc3e 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -6458,6 +6458,7 @@ def write_auto_dsig_file(self, writer, matrix_element, proc_id = ""): ' 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 @@ -7722,6 +7723,65 @@ class so it can be shared. Returns None if not a clean permutation.""" return None return [p + 1 for p in pi] + def _diagram_leg_subsets(self, me): + """Per diagram number, the set of its internal propagators' canonical + external-leg subsets -- a crossing-covariant topology signature (a + propagator is the set of external legs whose momenta flow through it, and + a subset and its complement are the same propagator). 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). + Returns (dict diagram_number -> frozenset of subsets, 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)} + subs = 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)): + subs.add(canon(s)) + out[diag.get('number')] = frozenset(subs) + 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.""" + bsub, nx = self._diagram_leg_subsets(base_me) + dsub, _ = self._diagram_leg_subsets(dep_me) + ngraphs = len(dep_me.get('diagrams')) + bsig = {frozenset(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))) + cmap = list(range(1, ngraphs + 1)) + for dd, ds in dsub.items(): + if not 1 <= dd <= ngraphs: + return list(range(1, ngraphs + 1)) + sig = frozenset(canon(frozenset(d2b[l] for l in sub)) for sub in ds) + if sig in bsig: + cmap[dd - 1] = bsig[sig] + if sorted(cmap) != list(range(1, ngraphs + 1)): + return list(range(1, ngraphs + 1)) + 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 @@ -7738,9 +7798,11 @@ def _dsig_crossgroup_fills(self, matrix_element, proc_id, crossgroup): * 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). Emitted only when non-identity (colour is the - identity for colourless processes; the channel is still passed through -- - CONFIGMAP is a later step). + (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 @@ -7773,6 +7835,23 @@ def _dsig_crossgroup_fills(self, matrix_element, proc_id, crossgroup): decl, 'DSIG_XGHEL', helmap, ncomb, 'selected_hel') col_post = self._crossgroup_remap_decl( decl, 'DSIG_XGCOL', colmap, ncol, 'selected_col') + + # 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' @@ -7783,8 +7862,9 @@ def _dsig_crossgroup_fills(self, matrix_element, proc_id, crossgroup): 'dsig_xg_decl_multi': decl_block, 'dsig_getflavor': ' FLAVOR(:) = DSIG_XGFLAV(:, IFLAV)', 'dsig_smatrix_call': ( - ' CALL SMATRIX%d(P1, DSIG_XGROUTE(IFLAV), RHEL, RCOL, channel,' - ' 1, DSIGUU, selected_hel(1), selected_col(1))' % base_proc_id + ' 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_post.format(idx='(1)', flav='IFLAV')), # vectorised (SMATRIX_MULTI) path: same routing. The MULTI wrapper @@ -7794,6 +7874,7 @@ def _dsig_crossgroup_fills(self, matrix_element, proc_id, crossgroup): ' 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_post.format(idx='(IVEC)', flav='IFLAV_VEC(IVEC)')), @@ -7898,6 +7979,7 @@ def write_auto_dsig_file(self, writer, matrix_element, proc_id = "", ' 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 diff --git a/madgraph/iolibs/template_files/auto_dsig_v4.inc b/madgraph/iolibs/template_files/auto_dsig_v4.inc index 0d1a394e4..a65fa3819 100644 --- a/madgraph/iolibs/template_files/auto_dsig_v4.inc +++ b/madgraph/iolibs/template_files/auto_dsig_v4.inc @@ -559,7 +559,7 @@ C Per-event MLM graph: igraphs(1) from REWGT (0 = no MLM) & %(dsig_smatrix_vec_flav)s, & hel_rand(IVEC), & col_rand(IVEC), - & channels(IVEC), + & %(dsig_smatrix_vec_chan)s, & IVEC, & out(IVEC), & selected_hel(IVEC), From 2184a7ba7522af3a760bf445a5cc22fa86563f4d Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 23 Jul 2026 00:47:32 +0200 Subject: [PATCH 22/43] madevent crossing: degate cross-group sharing for hadronic p p Cross-group reuse is the same engine as the lepton/photon case; it was gated off for p p only to protect the validated Track A. Relax the gate from all-or-nothing (bail out if ANY group has within-group routing) to a per-group exclusion: a group is a cross-group candidate only when every one of its members is a within-group base (no router). Multi-ME p p groups (the `j`-multiparticle ones with within-group routers) are skipped and stay Track A; the single-crossing-class groups now also share across P directories -- e.g. q q~ > g g reuses g g > q q~. Validated p p > j j: detection routes P1_qq_gg -> P1_gg_qq; the dependent carries all four maps, including the first non-trivial COLOURED COLMAP (/2,1/), CONFIGMAP (/1,3,2/), the hel-map and the union optim; its matrix1_orig.o and matrix1_optim.o symlink the base (compiled once). Cross section 6.974e8 pb, bit-identical to the --use_crossing=False reference; colour conserved in 1000/1000 events (both); Track A multi-ME groups byte-identical to before the degate; acceptance suite 37/37 OK. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 1becfcc3e..46556c3d0 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -3025,27 +3025,29 @@ def compute_crossgroup_routing(self, subproc_groups): """ 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): - for mi, me in enumerate(group.get('matrix_elements')): + mes_g = group.get('matrix_elements') + 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 for the whole run. + # 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 {} - # Cross-group routing is the lepton/photon mechanism -- distinct beam - # particles, each initial state landing in its OWN single-process group. - # It must NOT touch the hadronic p p case, where the crossings live - # inside one group and are already shared by within-group routers (Track - # A); merging those groups too is a separate, deferred optimisation. The - # p p case is exactly the one with within-group crossing routing, so if - # any group routes a flavor within itself, leave the whole run to Track A. - for group in subproc_groups: - mes_g = group.get('matrix_elements') - g_bases, _ = self.partition_crossing_classes(mes_g) - if len(g_bases) < len(mes_g): - return {} mes = [me for (_, _, me) in flat] bases, routing = self.partition_crossing_classes(mes) result = {} From 4ec2ae7d55cac1020546a47f09c53260fe056937 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 23 Jul 2026 07:44:45 +0200 Subject: [PATCH 23/43] aloha: drop the T-channel (spacelike) propagator width at runtime Outside the complex-mass scheme a spacelike (t-channel, P^2<0) propagator has no pole to regulate, so keeping the width i*M*Gamma in the denominator is spurious (it breaks gauge cancellations). The ALOHA propagator routine now tests the sign of P^2 at runtime and drops the width for spacelike momenta, keeping it for timelike (s-channel) ones. This replaces the old code-generation mechanism (helas_call_writers rewrote the call to pass ZERO as the width for is_t_channel() wavefunctions). That was topology-based and matched only madevent's fk_ widths, so standalone / standalone_cpp (which pass MDL_ widths) never got the treatment; doing it inside ALOHA applies it consistently to every tree-level backend. The existing zerowidth_tchannel option (default True) now drives the new aloha.t_channel_width flag instead of the call-rewrite; True keeps the proper treatment, False restores the width everywhere. It is a generation-time (output) option, so setting it at madevent run time now raises a clear error. Fortran/C++/Python (and GPU via C++) writers updated; ignored under the complex-mass scheme. IOTest references for the changed propagator routines still need regenerating. Co-Authored-By: Claude Opus 4.8 --- aloha/__init__.py | 8 ++++ aloha/aloha_writers.py | 49 +++++++++++++++++++--- madgraph/interface/common_run_interface.py | 8 ++++ madgraph/interface/madgraph_interface.py | 37 ++++++++++------ madgraph/iolibs/helas_call_writers.py | 9 ++-- 5 files changed, 90 insertions(+), 21 deletions(-) diff --git a/aloha/__init__.py b/aloha/__init__.py index c9605e375..90d934a27 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 c9ec6da6c..5bbe4f5e0 100755 --- a/aloha/aloha_writers.py +++ b/aloha/aloha_writers.py @@ -956,8 +956,25 @@ def sort_fct(a, b): out.write(' denom = %(COUP)s/(%(denom)s)\n' % {'COUP': coup_name,\ 'denom':self.write_obj(self.routine.denominator)}) else: - out.write(' denom = %(COUP)s/(P%(i)s(0)**2-P%(i)s(1)**2-P%(i)s(2)**2-P%(i)s(3)**2 - M%(i)s * (M%(i)s -CI* W%(i)s))\n' % \ - {'i': self.outgoing, 'COUP': coup_name}) + p2 = 'P%(i)s(0)**2-P%(i)s(1)**2-P%(i)s(2)**2-P%(i)s(3)**2' \ + % {'i': self.outgoing} + wdenom = '%(p2)s - M%(i)s * (M%(i)s -CI* W%(i)s)' \ + % {'i': self.outgoing, 'p2': p2} + if aloha.t_channel_width: + out.write(' denom = %(COUP)s/(%(wd)s)\n' % + {'COUP': coup_name, 'wd': wdenom}) + else: + # spacelike (t-channel) propagator: no pole to regulate, + # so drop the width unless in the complex-mass scheme. + out.write(' if (dble(%(p2)s).gt.0d0) then\n' + % {'p2': p2}) + out.write(' denom = %(COUP)s/(%(wd)s)\n' % + {'COUP': coup_name, 'wd': wdenom}) + out.write(' else\n') + out.write(' denom = %(COUP)s/(%(p2)s - M%(i)s**2)\n' + % {'i': self.outgoing, 'COUP': coup_name, + 'p2': p2}) + out.write(' endif\n') else: if self.routine.denominator: if 'P1N' not in self.tag: @@ -2050,8 +2067,19 @@ def sort_fct(a, b): out.write(' denom = %(pre_coup)s%(coup)s%(post_coup)s/(%(denom)s);\n' % \ mydict) else: - out.write(' denom = %(pre_coup)s%(coup)s%(post_coup)s/((P%(i)s[0]*P%(i)s[0])-(P%(i)s[1]*P%(i)s[1])-(P%(i)s[2]*P%(i)s[2])-(P%(i)s[3]*P%(i)s[3]) - M%(i)s * (M%(i)s -cI* W%(i)s));\n' % \ - mydict) + p2 = '(P%(i)s[0]*P%(i)s[0])-(P%(i)s[1]*P%(i)s[1])-(P%(i)s[2]*P%(i)s[2])-(P%(i)s[3]*P%(i)s[3])' % mydict + wd = '%(p2)s - M%(i)s * (M%(i)s -cI* W%(i)s)' % dict(mydict, p2=p2) + if aloha.t_channel_width: + out.write(' denom = %(pre_coup)s%(coup)s%(post_coup)s/(%(wd)s);\n' % dict(mydict, wd=wd)) + else: + # spacelike (t-channel) propagator: no pole to regulate, + # so drop the width unless in the complex-mass scheme. + p2sign = '(%s)%s' % (p2, self.realoperator) if aloha.loop_mode else p2 + out.write(' if (%s > 0.){\n' % p2sign) + out.write(' denom = %(pre_coup)s%(coup)s%(post_coup)s/(%(wd)s);\n' % dict(mydict, wd=wd)) + out.write(' } else {\n') + out.write(' denom = %(pre_coup)s%(coup)s%(post_coup)s/(%(p2)s - M%(i)s*M%(i)s);\n' % dict(mydict, p2=p2)) + out.write(' }\n') else: if self.routine.denominator: raise Exception('modify denominator are not compatible with complex mass scheme') @@ -2583,8 +2611,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/madgraph/interface/common_run_interface.py b/madgraph/interface/common_run_interface.py index 6105b9fdc..0f7fb4805 100755 --- a/madgraph/interface/common_run_interface.py +++ b/madgraph/interface/common_run_interface.py @@ -218,6 +218,14 @@ def check_set(self, args): self.help_set() raise self.InvalidCmd('set needs an option and an argument') + if args[0] == 'zerowidth_tchannel': + raise self.InvalidCmd( + "'zerowidth_tchannel' is a generation-time option: the T-channel " + "width treatment is now baked into the matrix element (ALOHA) at " + "'output' time and cannot be changed at run time. Choose it in MG5 " + "before output ('set zerowidth_tchannel True|False') and regenerate " + "the process.") + if args[0] not in self._set_options + list(self.options.keys()): self.help_set() raise self.InvalidCmd('Possible options for set are %s' % \ diff --git a/madgraph/interface/madgraph_interface.py b/madgraph/interface/madgraph_interface.py index 81e4ed7d8..e5482a55d 100755 --- a/madgraph/interface/madgraph_interface.py +++ b/madgraph/interface/madgraph_interface.py @@ -9196,19 +9196,25 @@ 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 set2_store_rwgt_info(self,args, log=True): """Set whether the code should generate systematics information in the output LHE file at NLO @@ -9867,22 +9873,29 @@ def export(self, nojpeg = False, main_file_name = "", group_processes=True, """Export a generated amplitude to file.""" + # 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') diff --git a/madgraph/iolibs/helas_call_writers.py b/madgraph/iolibs/helas_call_writers.py index b8587b894..5a65ef1ef 100755 --- a/madgraph/iolibs/helas_call_writers.py +++ b/madgraph/iolibs/helas_call_writers.py @@ -285,9 +285,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 From d0fae022813954b6ca5e4334b219003e2ce78048 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 23 Jul 2026 12:48:29 +0200 Subject: [PATCH 24/43] madevent cross-group crossing (Track B): fix colour selection and helicity recycling Two correctness fixes for cross-group (Track B) crossing, where a dependent subprocess routes to a base group's symlinked, crossing-aware matrix element. 1. Colour selection. The base SMATRIX picked the event colour flow with select_color, which masks the base-order JAMP2 with the DEPENDENT binary's ICOLAMP + ICONFIG (mismatched in flow order AND config space); the picked flow could be incompatible with the sampled config and addmothers then failed to reduce its ICOLUP (a p p > t t~ j j refine crash). The base now publishes its per-flow JAMP2 (COMMON/TO_XG_JAMP2) and the dependent permutes it into its own flow order and runs its own SELECT_COLOR (the XG_SELCOL helper) -- a native colour selection. Emitted only for MEs that are cross-group bases, so every other madevent ME stays byte-identical. 2. Helicity recycling. The recycled optim bakes the helicity configs into its wavefunction calls and takes no runtime NHEL, but a crossed dependent is evaluated with a permuted NHELUSE from APPLY_CROSSING. The full helicity sum is invariant under that permutation, but the optim's base good-hel SUBSET is not the dependent's, so its partial sum was over the wrong configs (~2% low for e.g. photon-lepton Compton crossings). A cross-group base now keeps ALL helicity configs in the shared optim (exact for every crossing), still recycling wavefunctions. Validated: p p > t t~ j j completes, total within MC error, colour conserved; lepton/photon EP EM > EP EM crossing now matches the non-crossing reference (was ~1.8% low); p p > j j and p p > j j j unchanged. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 93 ++++++++++++++++++- .../iolibs/template_files/auto_dsig_v4.inc | 1 + .../matrix_madevent_group_v4.inc | 2 + .../matrix_madevent_group_v4_hel.inc | 2 + madgraph/madevent/gen_ximprove.py | 16 +++- 5 files changed, 105 insertions(+), 9 deletions(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 46556c3d0..693f4942b 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -692,6 +692,11 @@ def export_processes(self, matrix_elements, fortran_model, second_exporter=None, # 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.' @@ -6450,6 +6455,7 @@ def write_auto_dsig_file(self, writer, matrix_element, proc_id = ""): 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'] = ( @@ -7316,6 +7322,25 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, 'flavor_mask_decl':'', 'flavor_mask_setup':''} + # Cross-group (Track B) 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 (see _dsig_crossgroup_fills / + # XG_SELCOL). 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()): + replace_dict['xg_jamp2_decl'] = ( + 'C Cross-group (Track B): 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 @@ -7835,8 +7860,30 @@ def _dsig_crossgroup_fills(self, matrix_element, proc_id, crossgroup): ncol = len(colmap[0]) if colmap else 0 hel_post = self._crossgroup_remap_decl( decl, 'DSIG_XGHEL', helmap, ncomb, 'selected_hel') - col_post = self._crossgroup_remap_decl( - decl, 'DSIG_XGCOL', colmap, ncol, 'selected_col') + # 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 @@ -7862,13 +7909,14 @@ def _dsig_crossgroup_fills(self, matrix_element, proc_id, crossgroup): '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_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. @@ -7879,7 +7927,7 @@ def _dsig_crossgroup_fills(self, matrix_element, proc_id, crossgroup): 'dsig_smatrix_vec_chan': chan_vec, 'dsig_smatrix_vec_post': ( hel_post.format(idx='(IVEC)', flav='IFLAV_VEC(IVEC)') - + col_post.format(idx='(IVEC)', flav='IFLAV_VEC(IVEC)')), + + col_vec_call), } def _crossgroup_remap_decl(self, decl, name, maps, size, var): @@ -7898,6 +7946,42 @@ def _crossgroup_remap_decl(self, decl, name, maps, size, var): '{v}{{idx}} = {name}({v}{{idx}}, {{flav}})').format( v=var, n=size, name=name) + def _crossgroup_colsel_helper(self, proc_id, ncol, nflav, col_flat): + """Emit XG_SELCOL, the cross-group (Track B) colour-selection + helper for a dependent subprocess. 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 + 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.""" + 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, + ' 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, 1, ICOL, IVEC)', + ' END', + ]) + #=========================================================================== # write_auto_dsig_file #=========================================================================== @@ -7971,6 +8055,7 @@ def write_auto_dsig_file(self, writer, matrix_element, proc_id = "", 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'] = ( diff --git a/madgraph/iolibs/template_files/auto_dsig_v4.inc b/madgraph/iolibs/template_files/auto_dsig_v4.inc index a65fa3819..34466b2bd 100644 --- a/madgraph/iolibs/template_files/auto_dsig_v4.inc +++ b/madgraph/iolibs/template_files/auto_dsig_v4.inc @@ -587,5 +587,6 @@ c if hel=0 return the number of helicity state possible for that particl return end +%(dsig_xg_helper)s %(ADDITIONAL_FCT)s diff --git a/madgraph/iolibs/template_files/matrix_madevent_group_v4.inc b/madgraph/iolibs/template_files/matrix_madevent_group_v4.inc index abe9510e5..c81b04936 100644 --- a/madgraph/iolibs/template_files/matrix_madevent_group_v4.inc +++ b/madgraph/iolibs/template_files/matrix_madevent_group_v4.inc @@ -91,6 +91,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) @@ -258,6 +259,7 @@ c Set right sign for ANS, based on sign of chosen helicity ENDIF %(smatrix_me_iden_line)s +%(xg_jamp2_pub)s call select_color(rcol, jamp2, iconfig,%(proc_id)s, icol, ivec) END 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 5c02863a5..c7b1326a8 100644 --- a/madgraph/iolibs/template_files/matrix_madevent_group_v4_hel.inc +++ b/madgraph/iolibs/template_files/matrix_madevent_group_v4_hel.inc @@ -71,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 @@ -171,6 +172,7 @@ c Set right sign for ANS, based on sign of chosen helicity ENDIF %(smatrix_me_iden_line)s +%(xg_jamp2_pub)s call select_color(rcol, jamp2, iconfig,%(proc_id)s, icol, ivec) END diff --git a/madgraph/madevent/gen_ximprove.py b/madgraph/madevent/gen_ximprove.py index dd332e1e4..c26f0cd43 100755 --- a/madgraph/madevent/gen_ximprove.py +++ b/madgraph/madevent/gen_ximprove.py @@ -301,11 +301,17 @@ def get_helicity(self, to_submit=True, clean=True): # Convert to sorted list for reproducibility #good_hels = sorted(list(good_hels)) good_set = set(all_good_hels[me_index]) - # Cross-group: expand to the UNION good-hel of the class. - gbase = frozenset(good_set) - for pi in helunion.get(me_index, []): - good_set |= {h for h in range(1, len(pi) + 1) - if pi[h - 1] in gbase} + # Cross-group base: the shared optim is also evaluated with each + # dependent's CROSSED helicity configs, but the recycled MATRIX + # bakes the base's helicity configs (it takes no runtime NHEL). + # The full helicity SUM is invariant under the crossing's helicity + # permutation, whereas the base's own good-hel SUBSET is not the + # dependent's -- dropping configs here biases a crossed dependent. + # So keep EVERY config for a base of a crossing class; wavefunction + # recycling is retained, only the good-hel config filter is off. + perms = helunion.get(me_index, []) + if perms: + good_set = set(range(1, len(perms[0]) + 1)) good_hels = [str(x) for x in sorted(good_set)] if self.run_card['hel_zeroamp']: From 5a09d7ade4e7a7b1cb7b862ae748a765105eb661 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 23 Jul 2026 17:13:01 +0200 Subject: [PATCH 25/43] madevent crossing: fix crossed-leg event helicity label (drop the extra NSF sign) The LHE event helicity (SPINUP, unwgt.f jpart(7,i)=nhel(i)) is the raw NHEL table value, never NHEL*IC. APPLY_CROSSING permutes NHEL but flips only the IC/NSF flags, so the correct crossed label for leg k is base_row[PERM[k]] with NO extra sign: the base MATRIX gives leg k physical helicity NHEL(k)*IC(k)=base_row[PERM[k]]*SGN[k]*IC_IN[PERM[k]]=base_row[PERM[k]]*IC_dep[k], matching the dependent's native label iff NHEL_dep[k]=base_row[PERM[k]]. Both event-helicity maps were multiplying in SGN, double-counting the crossing flip and flipping every fermion/vector leg that swaps initial<->final. Invisible to xsec/colour/flavour (all helicity-summed) and to non-chiral p p > j j (where the per-leg density is (++)==(--)), but wrong for chiral finals: on p p > w+ w- j j the incoming/outgoing antiquark helicities came out fully flipped (~34 sigma). Two maps carried the bug, both fixed by using the UNSIGNED crossed config: - Track B cross-group dependent: _crossgroup_helmap (DSIG_XGHEL). Added a `signed` flag to _crossed_helicity_configs; the helmap now passes signed=False. _crossgroup_base_helperm keeps signed=True -- its good-hel-set remap IS the GHREMAP sigma (table-space, validated by _GOODHEL_PROBE), which needs the sign. - Track A within-group router: write_matrix_router_file returned the base's selected IHEL straight through (COLMAP was applied to ICOL, nothing to IHEL). It now applies the same unsigned _crossgroup_helmap to IHEL, mirroring COLMAP. Validated: p p > w+ w- j j crossing vs --use_crossing=False (10k evt) -> every quark/antiquark/W/gluon helicity bin consistent (max |pull| 1.2 sigma, was 34); the discriminating chiral standalone density matrix (u d~ > w+ g crossed to u g > w+ d, the crossed d 100%-polarised) matches native per-helicity to machine precision via both the compiled Fortran GET_DENSITY_IDX and the f2py PY_GET_DENSITY_IDX wrapper (2 new acceptance tests). xsec unchanged (labels do not affect |M|^2); goodhel/GHREMAP tests green. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 64 ++++++-- .../test_standalone_cross_symmetry.py | 138 ++++++++++++++++++ 2 files changed, 192 insertions(+), 10 deletions(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 693f4942b..3304a2746 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -7710,16 +7710,39 @@ def write_crossgroup_parallel_makefile(self, subproc_path): #=========================================================================== # _dsig_crossgroup_fills #=========================================================================== - def _crossed_helicity_configs(self, base_me, cross): - """The base helicity rows after applying the crossing: crossed[hb][k] = - base_row[PERM[k]]*SGN[k] (PERM 0-based source leg, SGN the IC sign), i.e. - what APPLY_CROSSING makes of each base NHEL row. Returns (base_rows, - crossed_rows) as lists of tuples in the base NHEL order.""" + def _crossed_helicity_configs(self, base_me, cross, signed=True): + """The base helicity rows transformed by the crossing. Two consumers need + two DIFFERENT transforms, selected by `signed`: + + * signed=True -- the good-hel-set remap (_crossgroup_base_helperm): + crossed[hb][k] = base_row[PERM[k]]*SGN[k]. This is the table-space + permutation sigma the GHREMAP relation validates (_GOODHEL_PROBE): a + base row is good WHEN CROSSED iff sigma^-1 of it is good for the base's + own process, so the shared optim's good-hel union is + G_base U sigma(G_base). 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. + + * signed=False -- the event helicity LABEL (_crossgroup_helmap): + 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)] - S = [tables['ic'][cross * nx + k] for k in 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 @@ -7727,9 +7750,12 @@ def _crossgroup_helmap(self, dep_me, base_me, cross): """1-based map from a base helicity index to the DEPENDENT helicity index carrying the physically-crossed configuration. The base SMATRIX selects a helicity in ITS own NHEL enumeration, but the event is written through the - dependent's own get_helicities, so the index must be translated. Returns - the identity if that is not a clean permutation of the dependent's table.""" - _, crossed = self._crossed_helicity_configs(base_me, cross) + dependent's own get_helicities, so the index must be translated. The label + uses the UNSIGNED crossed config (signed=False): the LHE helicity is the + raw NHEL value APPLY_CROSSING permutes, not NHEL*IC (see + _crossed_helicity_configs). Returns the identity if that is not a clean + permutation of the dependent's table.""" + _, crossed = self._crossed_helicity_configs(base_me, cross, signed=False) dh = [tuple(x) for x in dep_me.get_helicity_matrix()] dhpos = {cfg: i for i, cfg in enumerate(dh)} hmap = [dhpos.get(c, -1) for c in crossed] @@ -7742,7 +7768,9 @@ def _crossgroup_base_helperm(self, base_me, cross): index whose NHEL row equals the crossed row of hb. So the dependent for this crossing has good helicity hb iff pi[hb] is good for the base -- which is how gen_ximprove expands the base optim over the UNION good-hel of the - class so it can be shared. Returns None if not a clean permutation.""" + class so it can be shared. Uses the SIGNED crossed config (the GHREMAP + sigma), unlike the event-label helmap. Returns None if not a clean + permutation.""" bh, crossed = self._crossed_helicity_configs(base_me, cross) bhpos = {cfg: i for i, cfg in enumerate(bh)} pi = [bhpos.get(c, -1) for c in crossed] @@ -9321,11 +9349,27 @@ def write_matrix_router_file(self, writer, matrix_element, fortran_model, nflav_base = len(base_me.get_external_flavors_with_iden()) cross = (iflav - 1) // nflav_base colmap = self._router_colmap(matrix_element, base_me, cross) + helmap = self._crossgroup_helmap(matrix_element, base_me, cross) kw = 'IF' if flav0 == 0 else 'ELSE IF' dispatch.append(' %s (IFLAV.EQ.%d) THEN' % (kw, flav0 + 1)) dispatch.append( ' CALL SMATRIX%d(P, %d, RHEL, RCOL, channel, IVEC, ANS,' ' IHEL, ICOL)' % (base_index + 1, iflav)) + # The base returns its selected helicity/colour in the BASE's own + # enumeration; the event is written through THIS module's get_nhel + # / ICOLUP, so remap each to the module's convention (identity = + # skip). HELMAP uses the UNSIGNED crossed config (see + # _crossgroup_helmap): the LHE helicity label is the raw NHEL value, + # NOT NHEL*IC, so a crossed fermion/vector leg must not pick up an + # extra sign -- without this the crossed leg's helicity is flipped + # (invisible on non-chiral p p > j j, wrong for e.g. w+ w- j j). + if helmap and helmap != list(range(1, len(helmap) + 1)): + hname = 'HELMAP_%s_%d' % (proc_id, flav0 + 1) + decl.append(' INTEGER %s(%d)' % (hname, len(helmap))) + decl.append(' DATA %s /%s/' % ( + hname, ','.join(str(x) for x in helmap))) + dispatch.append(' IF (IHEL.GE.1.AND.IHEL.LE.%d)' + ' IHEL = %s(IHEL)' % (len(helmap), hname)) # A non-identity colmap has to be applied; skip it when it is the # identity (single flow, or the flow orders already agree). if colmap and colmap != list(range(1, len(colmap) + 1)): diff --git a/tests/acceptance_tests/test_standalone_cross_symmetry.py b/tests/acceptance_tests/test_standalone_cross_symmetry.py index f1adf5ddb..1501af8d8 100644 --- a/tests/acceptance_tests/test_standalone_cross_symmetry.py +++ b/tests/acceptance_tests/test_standalone_cross_symmetry.py @@ -52,6 +52,7 @@ from __future__ import absolute_import +import json import math import os import re @@ -73,6 +74,17 @@ PROC_QQ_GG = 'u u~ > g g' PROC_QG_QG = 'u g > u g' +# 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~). @@ -253,6 +265,34 @@ def good_set(flav_idx): ''' +# Subprocess probe for the CROSSED spin-density matrix through the f2py wrapper +# PY_GET_DENSITY_IDX -- the only path by which a python caller can request a +# crossed density matrix (the FLAVOR-array PY_GET_DENSITY resolves through +# GET_FLAVOR_INDEX, which only returns 1..NFLAV and so cannot carry a crossing). +# Prints, per external leg, the three interference terms (++),(+-),(--) of that +# leg's density matrix, so the parent can compare a crossed evaluation against a +# natively generated reference term by term. Run in a subprocess because an +# f2py .so leaks into the importing interpreter and clashes across dirs/tests. +_DENSITY_PROBE = r''' +import sys, json +import numpy as np +sys.path.insert(0, %(pdir)r) +import matrix2py as m +m.py_initialisemodel(%(card)r) +momenta = %(momenta)s # [[E,px,py,pz], ...] per leg +P = np.asfortranarray(np.array(momenta, dtype=float).T) # (4, nexternal) +flav_idx = %(flav_idx)d +allow_hel = np.array([1, -1], dtype=np.int32) +out = {} +for leg in %(legs)s: + pos = np.array([leg], dtype=np.int32) + inter = np.asarray(m.py_get_density_idx( + P, pos, 1, allow_hel, 2, flav_idx, 0.0, 0.0)).ravel() + out[str(leg)] = [[float(z.real), float(z.imag)] for z in inter] +print('DENSITY_JSON ' + json.dumps(out)) +''' + + class TestStandaloneCrossSymmetry(unittest.TestCase): """u u~ > g g and u g > u g must reproduce each other under crossing.""" @@ -506,6 +546,32 @@ def _density(self, pdir, momenta, iflav, leg): 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] @@ -889,6 +955,78 @@ def test_density_matrix_diagonal_matches_smatrix(self): % (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. From 2b22dd56661bc5bf31cc85f5d4e2724a9e6ff661 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Fri, 24 Jul 2026 00:40:20 +0200 Subject: [PATCH 26/43] standalone helicity: replace the NHEL table with a canonical encoder/decoder Represent a helicity configuration as a single mixed-radix "canonical code" over the per-leg helicity states (STATES/NHSTATE), with the last external leg as the least-significant digit -- matching get_helicity_matrix()'s itertools.product order, so for a non-polarized process the code of row i is exactly i (nothing is relabelled). A polarization restriction ({0}/{L}/...) keeps the full per-leg multiplicity as the radix (helicity 0 / longitudinal stays a first-class state) and leaves the allowed-code list HELALLOW as the selected, non-contiguous subset. matrix_standalone_v4.inc: drop the explicit NHEL config DATA table; add DECODE_HEL / ENCODE_HEL / FILL_NHEL. PROCESS_NHEL is now materialized at runtime by decoding HELALLOW (FILL_NHEL, called from SMATRIX / GET_NHEL / GET_DENSITY_IDX), keeping the density-matrix and f2py interfaces intact. The external helicity label (USERHEL) is the canonical code. f2py: the get_nhel_entry accessor (all_matrix.f) now fills the table via GET_NHEL instead of copying the PROCESS_NHEL common raw -- otherwise an early caller (reweighting builds its per-config helicity map at init, before any matrix-element evaluation) would read the not-yet-materialized table as zeros. GET_NHEL is defined by every standalone matrix.f, so this also links for split-order processes (which have no FILL_NHEL). Validated: e+ e- > mu+ mu- and e+ e- > w+{0} w- |M|^2 bit-identical to the old table; 39/39 standalone cross-symmetry tests (fortran + cpp + mg7; density + crossing + polarization + f2py); reweight per-event weights bit-identical (p p > t t~ mass reweight). Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 93 +++++++++++++++++- .../template_files/matrix_standalone_v4.inc | 96 ++++++++++++++++++- 2 files changed, 179 insertions(+), 10 deletions(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 3304a2746..ed0699002 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -2108,6 +2108,73 @@ def get_helicity_lines(self, matrix_element,array_name='NHEL', add_nb_comb=False return "\n".join(helicity_line_list) + @staticmethod + def _fortran_data_stmt(name, values, per_line=10): + """Emit a fixed-form 'DATA name /v1,v2,.../' statement with + continuation lines (column-6 '&') so long value lists stay within the + Fortran line-length limit. name may contain an implied-DO, e.g. + '(STATES(I,1),I=1,3)'.""" + strs = ["%d" % v for v in values] + if len(strs) <= per_line: + return " DATA %s /%s/" % (name, ",".join(strs)) + lines = [" DATA %s /" % name] + for i in range(0, len(strs), per_line): + seg = strs[i:i + per_line] + tail = "," if i + per_line < len(strs) else "/" + lines.append(" & %s%s" % (",".join(seg), tail)) + return "\n".join(lines) + + def _helstate_data(self, matrix_element): + """Return the Fortran DATA blocks for the canonical helicity + encoder/decoder that replaces the explicit NHEL config table. + + A helicity configuration is encoded as a single mixed-radix integer + (the 'canonical code') over the per-leg helicity states, with the last + external leg as the least-significant digit -- matching the + itertools.product ordering used by get_helicity_matrix(). For a + non-polarized process this makes the code of the i-th row exactly i, so + HELALLOW is simply [1..NCOMB] and nothing is relabelled; a polarization + restriction ({0}/{L}/...) keeps the *full* per-leg multiplicity as the + radix (so helicity 0 / longitudinal stays a first-class state) and + leaves HELALLOW as the selected, non-contiguous subset of codes. + + Returns a dict with keys: + maxhel - max per-leg helicity multiplicity (STATES 1st dim) + nhstate_data - DATA for NHSTATE(NEXTERNAL) (states per leg) + states_data - DATA for STATES(MAXHEL,NEXTERNAL) (helicity values) + hel_allow_data - DATA for HELALLOW(NCOMB) (allowed codes) + """ + model = matrix_element.get('processes')[0].get('model') + pdict = model.get('particle_dict') + ext = matrix_element.get_external_wavefunctions() + # Full per-leg helicity states, allow_reverse=True so the value order + # matches get_helicity_matrix() for non-polarized legs (code==row). + states = [pdict[wf.get('pdg_code')].get_helicity_states(True) + for wf in ext] + nstate = [len(s) for s in states] + nexternal = len(ext) + maxhel = max(nstate) if nstate else 1 + + # Allowed canonical codes: encode each enumerated helicity row. + allowed = [] + for row in matrix_element.get_helicity_matrix(): + code = 0 + for k, val in enumerate(row): + code = code * nstate[k] + states[k].index(val) + allowed.append(code + 1) + + states_lines = [] + for k in range(nexternal): + vals = [states[k][i] if i < nstate[k] else 0 + for i in range(maxhel)] + states_lines.append(self._fortran_data_stmt( + '(STATES(I,%d),I=1,%d)' % (k + 1, maxhel), vals)) + + return {'maxhel': maxhel, + 'nhstate_data': self._fortran_data_stmt('NHSTATE', nstate), + 'states_data': "\n".join(states_lines), + 'hel_allow_data': self._fortran_data_stmt('HELALLOW', allowed)} + def get_ic_line(self, matrix_element): """Return the IC definition line coming after helicities, required by switchmom in madevent""" @@ -4745,11 +4812,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 = '' @@ -5315,9 +5389,18 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, ncomb = matrix_element.get_helicity_combinations() replace_dict['ncomb'] = ncomb - # Extract helicity lines + # Extract helicity lines. helicity_lines (the explicit NHEL config DATA + # table) is still consumed by the msP/msF/splitOrders standalone + # templates; matrix_standalone_v4.inc instead uses the canonical + # encoder/decoder tables below (NHSTATE/STATES/HELALLOW) and + # materializes PROCESS_NHEL at runtime via FILL_NHEL. helicity_lines = self.get_helicity_lines(matrix_element) replace_dict['helicity_lines'] = helicity_lines + hel_data = self._helstate_data(matrix_element) + replace_dict['maxhel'] = hel_data['maxhel'] + replace_dict['nhstate_data'] = hel_data['nhstate_data'] + replace_dict['states_data'] = hel_data['states_data'] + replace_dict['hel_allow_data'] = hel_data['hel_allow_data'] # Extract overall denominator # Averaging initial state color, spin, and identical FS particles diff --git a/madgraph/iolibs/template_files/matrix_standalone_v4.inc b/madgraph/iolibs/template_files/matrix_standalone_v4.inc index 4cf1c6842..0411de3c7 100644 --- a/madgraph/iolibs/template_files/matrix_standalone_v4.inc +++ b/madgraph/iolibs/template_files/matrix_standalone_v4.inc @@ -104,7 +104,10 @@ C common/%(proc_prefix)shelreset/HELRESET data HELRESET/.true./ -%(helicity_lines)s +C Allowed canonical helicity codes (mixed-radix over the per-leg states). +C The explicit NHEL config table is materialized at runtime by FILL_NHEL. + INTEGER HELALLOW(NCOMB) +%(hel_allow_data)s %(den_factor_line)s INTEGER POLARIZATIONS(0:NEXTERNAL,0:5) @@ -147,6 +150,7 @@ C zero. Short-circuit before touching the 1..NFLAV GOODHEL/NTRY arrays. 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 DO IHEL=1,NEXTERNAL JC(IHEL) = +1 @@ -167,7 +171,7 @@ C For this reason, we simply remove the filterin when there is only three ex ENDIF ANS = 0D0 DO IHEL=1,NCOMB - IF (USERHEL.EQ.-1.OR.USERHEL.EQ.IHEL) THEN + IF (USERHEL.EQ.-1.OR.USERHEL.EQ.HELALLOW(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 @@ -277,11 +281,12 @@ 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 @@ -656,6 +661,7 @@ C part of the normalisation (RESCALE=0 = impossible crossing). IF (RESCALE.EQ.0D0) THEN return ENDIF + CALL %(proc_prefix)sFILL_NHEL() %(density_cross_apply)s DO IHEL =1, NB_NHEL THISNHEL(:) = NHELUSE(:, IHEL) @@ -1034,6 +1040,86 @@ C ---------- END + SUBROUTINE %(proc_prefix)sDECODE_HEL(CODE, THISNHEL) +C Decode a canonical mixed-radix helicity CODE (1..NCOMBFULL) into the +C per-leg helicity values THISNHEL(NEXTERNAL). The last external leg is the +C least-significant digit, matching the itertools.product ordering used to +C build the allowed-code list HELALLOW. + IMPLICIT NONE + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER MAXHEL + PARAMETER (MAXHEL=%(maxhel)d) + INTEGER CODE, THISNHEL(NEXTERNAL) + INTEGER I, K, R, D + INTEGER NHSTATE(NEXTERNAL), STATES(MAXHEL,NEXTERNAL) +%(nhstate_data)s +%(states_data)s + R = CODE - 1 + DO K=NEXTERNAL,1,-1 + D = MOD(R, NHSTATE(K)) + THISNHEL(K) = STATES(D+1, K) + R = R / NHSTATE(K) + ENDDO + RETURN + END + + SUBROUTINE %(proc_prefix)sENCODE_HEL(THISNHEL, CODE) +C Inverse of DECODE_HEL: encode per-leg helicity values THISNHEL into the +C canonical mixed-radix code (used by the crossing-aware routines). + IMPLICIT NONE + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER MAXHEL + PARAMETER (MAXHEL=%(maxhel)d) + INTEGER THISNHEL(NEXTERNAL), CODE + INTEGER I, K, D + INTEGER NHSTATE(NEXTERNAL), STATES(MAXHEL,NEXTERNAL) +%(nhstate_data)s +%(states_data)s + CODE = 0 + DO K=1,NEXTERNAL + DO D=1,NHSTATE(K) + IF (STATES(D,K).EQ.THISNHEL(K)) GOTO 5 + ENDDO + D = 1 + 5 CONTINUE + CODE = CODE*NHSTATE(K) + (D-1) + ENDDO + CODE = CODE + 1 + RETURN + END + + SUBROUTINE %(proc_prefix)sFILL_NHEL() +C Materialize the PROCESS_NHEL config table by decoding the list of allowed +C canonical helicity codes (HELALLOW). Runs once; the table is a runtime +C cache of the encoder/decoder representation, kept for the density-matrix +C and python (f2py) interfaces. + IMPLICIT NONE + INTEGER NEXTERNAL + PARAMETER (NEXTERNAL=%(nexternal)d) + INTEGER NCOMB + PARAMETER ( NCOMB=%(ncomb)d) + INTEGER NHEL(NEXTERNAL,NCOMB) + COMMON/%(proc_prefix)sPROCESS_NHEL/NHEL + INTEGER HELALLOW(NCOMB) + INTEGER I, K, THIS(NEXTERNAL) + LOGICAL DONE + SAVE DONE +%(hel_allow_data)s + DATA DONE /.FALSE./ + IF (DONE) RETURN + DO I=1,NCOMB + CALL %(proc_prefix)sDECODE_HEL(HELALLOW(I), THIS) + DO K=1,NEXTERNAL + NHEL(K,I) = THIS(K) + ENDDO + ENDDO + DONE = .TRUE. + RETURN + END + + %(crossing_routines)s From 9d2341498e78b588b54ea266bb95e314f892d6fe Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Fri, 24 Jul 2026 09:09:28 +0200 Subject: [PATCH 27/43] madevent crossing: get_nhel decoder + runtime crossed-helicity encode Adopt the canonical helicity code (phase 1) in madevent and use it to replace the precomputed base->dependent helicity map with a runtime encode. get_nhel (auto_dsig): the per-event helicity label is the mixed-radix code, so GET_NHEL decodes it (per-leg NHSTATE/STATES) instead of indexing an NHEL config table. Byte-neutral: the stored id is the full-table row, which for a non-polarized process equals the canonical code, and the per-leg STATES order matches get_helicity_lines by construction. Drops the auto_dsig NHEL table. Crossing: a dependent's event is written through its own get_nhel, which decodes the DEPENDENT code, while the shared base SMATRIX selects a BASE code -- so relabel by permuting the code's mixed-radix digits with the crossing permutation (GET_CROSS_PERM). This is exact because dep_states[k] == base_states[PERM[k]] with no reversal (the incoming->antiparticle wf flip and the crossing's conjugate + initial/final swap cancel), making the relabel a pure digit permutation over the base NHSTATE -- no STATES, no sign. Applied in both the within-group router (Track A, was HELMAP) and the cross-group auto_dsig (Track B, was DSIG_XGHEL); both precomputed maps are removed, as are the now dead _crossgroup_helmap / _crossgroup_remap_decl. Validated byte-identical vs the old maps: u u~ > d d~ (get_nhel decode == table, all 16 codes; identical events/xsec), p p > j j (both tracks; xsec 6.967e8), p p > w+ j (chiral W; xsec 2.163e4); 39/39 standalone cross-symmetry tests pass. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 131 ++++++++++-------- .../iolibs/template_files/auto_dsig_v4.inc | 30 ++-- 2 files changed, 95 insertions(+), 66 deletions(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index ed0699002..dd3bafcdd 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -7829,23 +7829,6 @@ def _crossed_helicity_configs(self, base_me, cross, signed=True): crossed = [tuple(row[P[k]] * S[k] for k in range(nx)) for row in bh] return bh, crossed - def _crossgroup_helmap(self, dep_me, base_me, cross): - """1-based map from a base helicity index to the DEPENDENT helicity index - carrying the physically-crossed configuration. The base SMATRIX selects a - helicity in ITS own NHEL enumeration, but the event is written through the - dependent's own get_helicities, so the index must be translated. The label - uses the UNSIGNED crossed config (signed=False): the LHE helicity is the - raw NHEL value APPLY_CROSSING permutes, not NHEL*IC (see - _crossed_helicity_configs). Returns the identity if that is not a clean - permutation of the dependent's table.""" - _, crossed = self._crossed_helicity_configs(base_me, cross, signed=False) - dh = [tuple(x) for x in dep_me.get_helicity_matrix()] - dhpos = {cfg: i for i, cfg in enumerate(dh)} - hmap = [dhpos.get(c, -1) for c in crossed] - if -1 in hmap or sorted(hmap) != list(range(len(crossed))): - return list(range(1, len(crossed) + 1)) - return [h + 1 for h in hmap] - def _crossgroup_base_helperm(self, base_me, cross): """1-based base->base helicity permutation of a crossing: pi[hb] = the base index whose NHEL row equals the crossed row of hb. So the dependent for @@ -7960,17 +7943,38 @@ def _dsig_crossgroup_fills(self, matrix_element, proc_id, crossgroup): ' INTEGER DSIG_XGROUTE(%d)' % len(all_flv), ' DATA DSIG_XGROUTE /%s/' % ','.join(str(x) for x in flav_idx)] - # Per-flavor event helicity + colour maps (base index -> dependent index). - ncomb = base_me.get_helicity_combinations() - helmap = [self._crossgroup_helmap(matrix_element, base_me, - (iflav - 1) // nflav_base) - for iflav 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 - hel_post = self._crossgroup_remap_decl( - decl, 'DSIG_XGHEL', helmap, ncomb, 'selected_hel') + # 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 @@ -8041,22 +8045,6 @@ def _dsig_crossgroup_fills(self, matrix_element, proc_id, crossgroup): + col_vec_call), } - def _crossgroup_remap_decl(self, decl, name, maps, size, var): - """Helper for _dsig_crossgroup_fills: if any flavor's map is non-identity, - append the DATA declaration of a (size, nflav) remap table to ``decl`` and - return a str.format template with fields {idx} (the (1)/(IVEC) slot) and - {flav} (the flavor expression). Otherwise return an empty string. The DATA - is column-major (index fastest, then flavor).""" - identity = list(range(1, size + 1)) - if all(m == identity for m in maps): - return '' - flat = ','.join(str(x) for col in maps for x in col) - decl.append(' INTEGER %s(%d,%d)' % (name, size, len(maps))) - decl.append(' DATA %s /%s/' % (name, flat)) - return ('\n IF ({v}{{idx}}.GE.1.AND.{v}{{idx}}.LE.{n}) ' - '{v}{{idx}} = {name}({v}{{idx}}, {{flav}})').format( - v=var, n=size, name=name) - def _crossgroup_colsel_helper(self, proc_id, ncol, nflav, col_flat): """Emit XG_SELCOL, the cross-group (Track B) colour-selection helper for a dependent subprocess. It permutes the base ME's published @@ -8258,8 +8246,15 @@ def write_auto_dsig_file(self, writer, matrix_element, proc_id = "", replace_dict['ncomb']= ncomb helicity_lines = self.get_helicity_lines(matrix_element, add_nb_comb=True) replace_dict['helicity_lines'] = helicity_lines + # Canonical helicity decoder tables for GET_NHEL: the per-event helicity + # label is the mixed-radix code, so GET_NHEL decodes it (per-leg states) + # rather than indexing an NHEL config table. + hel_data = self._helstate_data(matrix_element) + replace_dict['maxhel'] = hel_data['maxhel'] + replace_dict['nhstate_data'] = hel_data['nhstate_data'] + replace_dict['states_data'] = hel_data['states_data'] - context = {'read_write_good_hel':True} + 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 @@ -9427,32 +9422,51 @@ def write_matrix_router_file(self, writer, matrix_element, fortran_model, 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 + baked_nhs = {} # base_index -> baked base-NHSTATE array name 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 = self._router_colmap(matrix_element, base_me, cross) - helmap = self._crossgroup_helmap(matrix_element, base_me, cross) kw = 'IF' if flav0 == 0 else 'ELSE IF' dispatch.append(' %s (IFLAV.EQ.%d) THEN' % (kw, flav0 + 1)) dispatch.append( ' CALL SMATRIX%d(P, %d, RHEL, RCOL, channel, IVEC, ANS,' ' IHEL, ICOL)' % (base_index + 1, iflav)) - # The base returns its selected helicity/colour in the BASE's own - # enumeration; the event is written through THIS module's get_nhel - # / ICOLUP, so remap each to the module's convention (identity = - # skip). HELMAP uses the UNSIGNED crossed config (see - # _crossgroup_helmap): the LHE helicity label is the raw NHEL value, - # NOT NHEL*IC, so a crossed fermion/vector leg must not pick up an - # extra sign -- without this the crossed leg's helicity is flipped - # (invisible on non-chiral p p > j j, wrong for e.g. w+ w- j j). - if helmap and helmap != list(range(1, len(helmap) + 1)): - hname = 'HELMAP_%s_%d' % (proc_id, flav0 + 1) - decl.append(' INTEGER %s(%d)' % (hname, len(helmap))) - decl.append(' DATA %s /%s/' % ( - hname, ','.join(str(x) for x in helmap))) - dispatch.append(' IF (IHEL.GE.1.AND.IHEL.LE.%d)' - ' IHEL = %s(IHEL)' % (len(helmap), hname)) + # Encode the crossed helicity code (skip cross 0 = identity). + if cross != 0: + encode_used = 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', + ] # A non-identity colmap has to be applied; skip it when it is the # identity (single flow, or the flow orders already agree). if colmap and colmap != list(range(1, len(colmap) + 1)): @@ -9464,6 +9478,9 @@ def write_matrix_router_file(self, writer, matrix_element, fortran_model, ' ICOL = %s(ICOL)' % (len(colmap), cname)) if dispatch: dispatch.append(' ENDIF') + 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) tpl = open(pjoin(_file_path, 'iolibs', 'template_files', diff --git a/madgraph/iolibs/template_files/auto_dsig_v4.inc b/madgraph/iolibs/template_files/auto_dsig_v4.inc index 34466b2bd..23b623038 100644 --- a/madgraph/iolibs/template_files/auto_dsig_v4.inc +++ b/madgraph/iolibs/template_files/auto_dsig_v4.inc @@ -573,17 +573,29 @@ 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 From 562abbbe5c2332cee4554cc3fa884bb24d47063c Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Fri, 24 Jul 2026 10:10:59 +0200 Subject: [PATCH 28/43] test: madevent crossed W+ helicity asymmetry (p p > w+ j) regression End-to-end guard for the crossed-helicity label in the LHE (phase-4 GET_NHEL decoder + phase-5 runtime crossing encode). p p > w+ j puts the W+ -- a massive vector with three helicity states -- in a leg the crossing moves (u g > w+ d, ...), so a bug in the base->crossed helicity relabel scrambles its helicity. The test runs a small madevent generation and asserts the W+ polarisation is physical: all three helicity states populated, the two transverse states chirally asymmetric, and the longitudinal (0) fraction inside a physical window -- a scrambled relabel reads a quark leg's +-1 into the W+ slot and breaks this. Thresholds are loose (survive PDF/param updates) but catch the failure modes. Co-Authored-By: Claude Opus 4.8 --- .../test_standalone_cross_symmetry.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/acceptance_tests/test_standalone_cross_symmetry.py b/tests/acceptance_tests/test_standalone_cross_symmetry.py index 1501af8d8..741f37ca5 100644 --- a/tests/acceptance_tests/test_standalone_cross_symmetry.py +++ b/tests/acceptance_tests/test_standalone_cross_symmetry.py @@ -1971,3 +1971,77 @@ def test_partition_pp_jj(self): eliminated_any = True self.assertTrue(eliminated_any, 'no module was eliminated by crossing in p p > j j') + + +class TestMadeventCrossingHelicity(unittest.TestCase): + """End-to-end regression for the crossed-helicity label written to the LHE. + + The madevent helicity path is the phase-4 GET_NHEL decoder plus the phase-5 + runtime crossing encode (the base-selected helicity code is relabelled into + the dependent's canonical code by permuting its mixed-radix digits with the + crossing permutation, replacing the old DSIG_XGHEL / router HELMAP tables). + p p > w+ j is the sharp test: its crossed subprocesses (u g > w+ d, ...) put + the W+ -- a massive vector with THREE helicity states -- in a leg the + crossing moved, so a bug in the relabel scrambles the W+ helicity. The W+ + polarisation is physically CHIRAL (asymmetric transverse states) with a + populated longitudinal (0) state; a scrambled relabel typically reads a + quark leg's +-1 into the W+ slot and destroys that structure. + + This runs a full (small) madevent generation, so it is a slow test. + """ + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix='cross_mev_hel_') + + def tearDown(self): + if os.path.isdir(self.tmpdir): + shutil.rmtree(self.tmpdir) + + def test_w_helicity_asymmetry_ppwj(self): + from madgraph import MG5DIR + from madgraph.various import lhe_parser + outdir = pjoin(self.tmpdir, 'ppwj') + card = pjoin(self.tmpdir, 'cmd.txt') + with open(card, 'w') as f: + f.write('generate p p > w+ j\n' + 'output madevent %s -f\n' + 'launch\n' + 'set nevents 1000\n' + 'set iseed 777\n' % outdir) + subprocess.call([sys.executable, pjoin(MG5DIR, 'bin', 'mg5_aMC'), card]) + + lhe = pjoin(outdir, 'Events', 'run_01', 'unweighted_events.lhe.gz') + self.assertTrue(os.path.isfile(lhe), + 'madevent produced no LHE file (%s)' % lhe) + + counts = {-1: 0, 0: 0, 1: 0} + nevt = 0 + for event in lhe_parser.EventFile(lhe): + for part in event: + if part.pid == 24 and part.status == 1: # the final-state W+ + hel = int(round(part.helicity)) + self.assertIn(hel, (-1, 0, 1), + 'W+ has undefined/non-physical helicity %r -- ' + 'helicity output off or scrambled' + % part.helicity) + counts[hel] += 1 + nevt += 1 + total = sum(counts.values()) + self.assertGreater(nevt, 100, 'too few events generated (%d)' % nevt) + self.assertEqual(total, nevt, 'expected exactly one final-state W+ per ' + 'event (got %d W+ in %d events)' % (total, nevt)) + + fm, f0, fp = (counts[-1] / total, counts[0] / total, counts[1] / total) + # All three W+ helicity states populated, incl. the longitudinal 0. + for hel in (-1, 0, 1): + self.assertGreater(counts[hel], 0, + 'W+ helicity %d not populated: %s' % (hel, counts)) + # The two transverse states are chirally asymmetric. + self.assertGreater(abs(fm - fp), 0.05, + 'W+ transverse helicities not chirally asymmetric: %s' + % counts) + # The longitudinal fraction sits in a physical window (a scrambled + # relabel collapses or inflates it out of this range). + self.assertTrue(0.02 < f0 < 0.45, + 'W+ longitudinal fraction unphysical: %.3f (%s)' + % (f0, counts)) From ba11fe77fe09136fa9d2f2eaf4851b1e8a487d64 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Fri, 24 Jul 2026 14:52:35 +0200 Subject: [PATCH 29/43] colour: canonical colour-flow code (generator + injectivity test) First step of the colour analogue of the canonical helicity encoding. 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 the anticolour slots by leg (a gluon holds one slot of each kind, a sextet two), digit i is the anticolour slot that colour slot i connects to and code = sum_i digit_i * N^i. Adds _color_flow_canon / _color_flow_code / _color_flow_codes, and refactors _router_colmap to share the canonical form rather than keep its own copy (the generated output is byte-identical; only run artefacts differ). The code is injective over a process's colour basis -- verified up to g g > g g g (24 flows over 5 colour slots) by the new test -- so it identifies a flow without a per-process flow table. It is also crossing-covariant: relabelling the legs with the crossing permutation carries a base flow's code onto the crossed process's own flow code (checked against _router_colmap for p p > j j and p p > j j j, all flows, zero mismatches), which is what will let a crossed subprocess drop the base->dependent colour map. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 95 +++++++++++++++---- .../test_standalone_cross_symmetry.py | 56 +++++++++++ 2 files changed, 135 insertions(+), 16 deletions(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index dd3bafcdd..5a72d6876 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -9347,6 +9347,83 @@ def _module_color_flows(self, matrix_element): 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 + + 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 _router_colmap(self, router_me, base_me, cross): """Map each base colour-flow index to this subprocess's flow index. @@ -9371,22 +9448,8 @@ def _router_colmap(self, router_me, base_me, cross): inv[leg] = s def canon(flow): - # Topology (label independent): pair each label's colour leg with its - # anticolour leg, incoming legs swapping the two roles. - col, anti = {}, {} - for leg, (c, a) in enumerate(flow): - if rstates[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) + # Topology (label independent), shared with the colour-flow code. + return self._color_flow_canon(flow, rstates) rindex = {} for j, fl in enumerate(rflows): diff --git a/tests/acceptance_tests/test_standalone_cross_symmetry.py b/tests/acceptance_tests/test_standalone_cross_symmetry.py index 741f37ca5..faba653a3 100644 --- a/tests/acceptance_tests/test_standalone_cross_symmetry.py +++ b/tests/acceptance_tests/test_standalone_cross_symmetry.py @@ -2045,3 +2045,59 @@ def test_w_helicity_asymmetry_ppwj(self): self.assertTrue(0.02 < f0 < 0.45, 'W+ longitudinal fraction unphysical: %.3f (%s)' % (f0, counts)) + + +class TestColorFlowCode(unittest.TestCase): + """The canonical COLOUR-FLOW code, the colour analogue of the canonical + helicity code. + + A colour flow is labelled by its connectivity once the INITIAL-state legs + swap their colour/anticolour roles (the LHE convention runs initial-state + colour lines 'through', so without that flip a label sits in the same slot + on two legs and the flow is not a colour<->anticolour bijection). Ordering + the colour and anticolour slots by leg, digit i is the anticolour slot that + colour slot i connects to and code = sum_i digit_i * N^i. + + Two properties make it usable as an event label and make crossing + transparent (both verified here): + (a) every basis flow is a clean bijection, i.e. it encodes at all; + (b) the code is INJECTIVE over a process's colour basis, so the code + identifies the flow and no per-process flow table is needed. + Crossing-covariance (relabelling legs by the crossing permutation carries a + base flow's code onto the crossed process's own flow code) is exercised by + the crossing machinery itself: _router_colmap matches flows through the + same _color_flow_canon helper. + """ + + # (process, expected number of colour flows) -- includes g g > g g g, whose + # 24 flows over 5 colour slots is the widest case that stays quick. + PROCS = [('u u~ > g g', 2), ('g g > g g', 6), ('u u~ > u u~', 2), + ('g g > t t~', 2), ('u u~ > g g g', 6), ('g g > g g g', 24)] + + def test_color_flow_code_bijective_and_injective(self): + import madgraph.core.helas_objects as helas_objects + import madgraph.iolibs.export_v4 as export_v4 + exp = export_v4.ProcessExporterFortranMEGroup.__new__( + export_v4.ProcessExporterFortranMEGroup) + checked = 0 + for proc, nflow_exp in self.PROCS: + cmd = cmd_interface.MasterCmd() + cmd.exec_cmd('generate %s' % proc, printcmd=False) + me = helas_objects.HelasMultiProcess(cmd._curr_amps) + for m in me.get('matrix_elements'): + if not m.get('color_basis'): + continue + codes = exp._color_flow_codes(m) + # (a) every flow is a clean colour<->anticolour bijection + self.assertIsNotNone( + codes, '%s: a colour flow is not a clean bijection -- the ' + 'initial-state colour/anticolour flip is required' % proc) + self.assertEqual(len(codes), nflow_exp, + '%s: expected %d colour flows, got %d' + % (proc, nflow_exp, len(codes))) + # (b) the code identifies the flow + self.assertEqual(len(set(codes)), len(codes), + '%s: colour-flow codes collide: %s' + % (proc, codes)) + checked += 1 + self.assertTrue(checked, 'no coloured matrix element was checked') From 23ae1018c8ce64d93f073d284917206ea25c3956 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Fri, 24 Jul 2026 15:25:22 +0200 Subject: [PATCH 30/43] colour: decoder for the canonical colour-flow code + round-trip test Adds the inverse of _color_flow_code. Decoding needs only the code and the process's SLOT STRUCTURE (_color_flow_slots: which legs carry a colour resp. an anticolour index) -- and that structure is FLOW-INDEPENDENT, being fixed by the colour representations after the initial-state flip rather than by which flow is picked. It is therefore the colour analogue of the per-leg helicity-state counts, and it is all a consumer needs to rebuild a flow from its code. The new test asserts, for every flow of every process checked (up to g g > g g g, 24 flows over 5 colour slots), that decode(code(flow)) reproduces the flow's canonical connectivity exactly and that the slot structure does not vary between flows. That round trip is the guarantee required before colour tags are rebuilt from the code instead of read from the generated ICOLUP table. Still keeps ICOLUP as the source of the tag LABELS, so the generated output and the events are unchanged; only the machinery is added. Known gap: a leg carrying two slots of the same kind (a sextet) needs encode and decode to agree on the tie-break between its slots -- untested, flagged in the docstring. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 31 ++++++++++++++ .../test_standalone_cross_symmetry.py | 41 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 5a72d6876..1d248d882 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -9407,6 +9407,37 @@ def _color_flow_code(conns): 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 diff --git a/tests/acceptance_tests/test_standalone_cross_symmetry.py b/tests/acceptance_tests/test_standalone_cross_symmetry.py index faba653a3..76c8563fd 100644 --- a/tests/acceptance_tests/test_standalone_cross_symmetry.py +++ b/tests/acceptance_tests/test_standalone_cross_symmetry.py @@ -2101,3 +2101,44 @@ def test_color_flow_code_bijective_and_injective(self): % (proc, codes)) checked += 1 self.assertTrue(checked, 'no coloured matrix element was checked') + + def test_color_flow_code_round_trip(self): + """decode(code(flow)) reproduces the flow's canonical connectivity, and + the slot structure is FLOW-INDEPENDENT (it is process data, fixed by the + colour representations). Together these are what allow the colour tags + to be rebuilt from the code alone instead of read out of the generated + ICOLUP table -- the step this encoding is aiming at. + """ + import madgraph.core.helas_objects as helas_objects + import madgraph.iolibs.export_v4 as export_v4 + exp = export_v4.ProcessExporterFortranMEGroup.__new__( + export_v4.ProcessExporterFortranMEGroup) + checked = 0 + for proc, _nflow in self.PROCS: + cmd = cmd_interface.MasterCmd() + cmd.exec_cmd('generate %s' % proc, printcmd=False) + me = helas_objects.HelasMultiProcess(cmd._curr_amps) + for m in me.get('matrix_elements'): + if not m.get('color_basis'): + continue + states = [l.get('state') for l in + m.get('processes')[0].get_legs_with_decays()] + slots = None + for flow in exp._module_color_flows(m): + conns = exp._color_flow_canon(flow, states) + this = exp._color_flow_slots(conns) + if slots is None: + slots = this + # the slot structure must not depend on the flow + self.assertEqual(this, slots, + '%s: slot structure varies between flows ' + '(%s vs %s)' % (proc, this, slots)) + code = exp._color_flow_code(conns) + self.assertIsNotNone(code, '%s: flow did not encode' % proc) + back = exp._color_flow_decode(code, slots[0], slots[1]) + self.assertEqual(back, conns, + '%s: code %d does not round-trip\n got %s' + '\n want %s' + % (proc, code, sorted(back), sorted(conns))) + checked += 1 + self.assertTrue(checked, 'no colour flow was round-tripped') From f75fff431a06b628e2036fcfd092d01794c3f20b Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Fri, 24 Jul 2026 16:13:28 +0200 Subject: [PATCH 31/43] test: LHE colour-flow structure + ratio for p p > t t~ End-to-end guard on the colour written to the event, the colour counterpart of the p p > w+ j helicity-asymmetry test. Structure: every event's 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 matched by exactly one anticolour label. That is what breaks first if the flow written to the event is ever rebuilt wrongly, which is exactly the risk when the tags start being decoded from the canonical colour-flow code instead of read from the ICOLUP table. Physics: g g > t t~ dominates (measured 87%), and its TWO colour flows (three connections each) are both populated and balanced -- measured 50.5/49.5, the symmetry of the two ways to connect the gluons to the top line. A colour selection stuck on one flow, or producing a wrong topology, fails here. Thresholds are loose (gg > 0.6, each flow within 0.25-0.75) so PDF/param drift does not false-fail. Co-Authored-By: Claude Opus 4.8 --- .../test_standalone_cross_symmetry.py | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/tests/acceptance_tests/test_standalone_cross_symmetry.py b/tests/acceptance_tests/test_standalone_cross_symmetry.py index 76c8563fd..5c9558e66 100644 --- a/tests/acceptance_tests/test_standalone_cross_symmetry.py +++ b/tests/acceptance_tests/test_standalone_cross_symmetry.py @@ -2142,3 +2142,102 @@ def test_color_flow_code_round_trip(self): % (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 p p > t t~. + + 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. + + On top of the structure it pins the physics: g g > t t~ dominates, and its + TWO colour flows are both populated and balanced (they are related by the + symmetry of the two ways to connect the gluons to the top line). A colour + selection that got stuck on one flow, or that produced a wrong topology, + fails here. 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_pp_ttx(self): + from madgraph import MG5DIR + from madgraph.various import lhe_parser + outdir = pjoin(self.tmpdir, 'ttx') + card = pjoin(self.tmpdir, 'cmd.txt') + with open(card, 'w') as f: + f.write('generate p p > t t~\n' + 'output madevent %s -f\n' + 'launch\n' + 'set nevents 2000\n' + 'set iseed 555\n' % outdir) + subprocess.call([sys.executable, pjoin(MG5DIR, 'bin', 'mg5_aMC'), card]) + + lhe = pjoin(outdir, 'Events', 'run_01', 'unweighted_events.lhe.gz') + self.assertTrue(os.path.isfile(lhe), + 'madevent produced no LHE file (%s)' % lhe) + + nevt = ngg = 0 + gg_flows = {} + 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)) + ini = sorted(int(p.pid) for p in parts if p.status == -1) + if ini == [21, 21]: + ngg += 1 + key = tuple(sorted((col[l][0], anti[l][0]) for l in col)) + gg_flows[key] = gg_flows.get(key, 0) + 1 + + self.assertGreater(nevt, 100, 'too few events generated (%d)' % nevt) + # (2) physics: gluon fusion dominates t t~ at the LHC + self.assertGreater(ngg / nevt, 0.6, + 'g g > t t~ should dominate, got %.3f' % (ngg / nevt)) + # (3) it has exactly two colour flows, both populated and balanced + self.assertEqual(len(gg_flows), 2, + 'g g > t t~ should show exactly 2 colour flows, got ' + '%d: %s' % (len(gg_flows), gg_flows)) + for key, cnt in gg_flows.items(): + self.assertEqual(len(key), 3, + 'g g > t t~ flow should have 3 colour connections, ' + 'got %d: %s' % (len(key), key)) + self.assertTrue(0.25 < cnt / ngg < 0.75, + 'g g > t t~ colour flows unbalanced: %s of %d' + % (gg_flows, ngg)) From d35498a24652cf720cb0c64be274e2934600e5a0 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Fri, 24 Jul 2026 16:23:56 +0200 Subject: [PATCH 32/43] test: use u u~ > u u~ for the LHE colour-flow guard (strongly asymmetric) Replaces the p p > t t~ colour test, which was too weak to be useful: the two g g > t t~ colour flows are related by a symmetry and split 50/50 (measured 50.5/49.5), so the test passed just as happily with the two flow labels SWAPPED -- precisely the bug it was meant to catch. u u~ > u u~ instead splits ~98/2 (measured 2932/68 of 3000 events), so the test can pin down WHICH flow is which and a swap inverts the ratio. The dominant topology is identified topologically -- whether each colour connection stays inside the initial/final groups (signature II+FF, ~0.977) or crosses between them (IF+FI, ~0.023) -- rather than by raw leg indices, so the check does not depend on leg ordering. The structural half is kept and still applies per event: the tags must form a perfect colour<->anticolour matching in the canonical (initial-flipped) form, which is what breaks first once colour tags are rebuilt from the canonical colour-flow code instead of read from ICOLUP. Also measured on the way (not asserted, for the record): u d > u d exposes a single LHE flow -- the event-level basis is leading-colour, so the 1/N piece is not a separately selectable flow -- and g g > g g splits 2 dominant (~0.317 each) vs 4 suppressed (~0.09 each). Co-Authored-By: Claude Opus 4.8 --- .../test_standalone_cross_symmetry.py | 71 +++++++++++-------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/tests/acceptance_tests/test_standalone_cross_symmetry.py b/tests/acceptance_tests/test_standalone_cross_symmetry.py index 5c9558e66..a436697e1 100644 --- a/tests/acceptance_tests/test_standalone_cross_symmetry.py +++ b/tests/acceptance_tests/test_standalone_cross_symmetry.py @@ -2145,7 +2145,7 @@ def test_color_flow_code_round_trip(self): class TestMadeventColorFlowRatio(unittest.TestCase): - """End-to-end guard on the COLOUR written to the LHE, for p p > t t~. + """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 @@ -2155,11 +2155,16 @@ class TestMadeventColorFlowRatio(unittest.TestCase): 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. - On top of the structure it pins the physics: g g > t t~ dominates, and its - TWO colour flows are both populated and balanced (they are related by the - symmetry of the two ways to connect the gluons to the top line). A colour - selection that got stuck on one flow, or that produced a wrong topology, - fails here. Runs a small madevent generation, so it is a slow test. + 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): @@ -2184,25 +2189,25 @@ def _canon(parts): anti.setdefault(a, []).append(i) return col, anti - def test_color_flow_ratio_pp_ttx(self): + def test_color_flow_ratio_uux_uux(self): from madgraph import MG5DIR from madgraph.various import lhe_parser - outdir = pjoin(self.tmpdir, 'ttx') + outdir = pjoin(self.tmpdir, 'uux') card = pjoin(self.tmpdir, 'cmd.txt') with open(card, 'w') as f: - f.write('generate p p > t t~\n' + f.write('generate u u~ > u u~\n' 'output madevent %s -f\n' 'launch\n' 'set nevents 2000\n' - 'set iseed 555\n' % outdir) + 'set iseed 909\n' % outdir) subprocess.call([sys.executable, pjoin(MG5DIR, 'bin', 'mg5_aMC'), card]) lhe = pjoin(outdir, 'Events', 'run_01', 'unweighted_events.lhe.gz') self.assertTrue(os.path.isfile(lhe), 'madevent produced no LHE file (%s)' % lhe) - nevt = ngg = 0 - gg_flows = {} + nevt = 0 + sigs = {} for event in lhe_parser.EventFile(lhe): parts = [p for p in event] nevt += 1 @@ -2220,24 +2225,28 @@ def test_color_flow_ratio_pp_ttx(self): self.assertEqual(len(anti[lbl]), 1, 'anticolour label %s appears on %d legs ' '(event %d)' % (lbl, len(anti[lbl]), nevt)) - ini = sorted(int(p.pid) for p in parts if p.status == -1) - if ini == [21, 21]: - ngg += 1 - key = tuple(sorted((col[l][0], anti[l][0]) for l in col)) - gg_flows[key] = gg_flows.get(key, 0) + 1 + # 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) physics: gluon fusion dominates t t~ at the LHC - self.assertGreater(ngg / nevt, 0.6, - 'g g > t t~ should dominate, got %.3f' % (ngg / nevt)) - # (3) it has exactly two colour flows, both populated and balanced - self.assertEqual(len(gg_flows), 2, - 'g g > t t~ should show exactly 2 colour flows, got ' - '%d: %s' % (len(gg_flows), gg_flows)) - for key, cnt in gg_flows.items(): - self.assertEqual(len(key), 3, - 'g g > t t~ flow should have 3 colour connections, ' - 'got %d: %s' % (len(key), key)) - self.assertTrue(0.25 < cnt / ngg < 0.75, - 'g g > t t~ colour flows unbalanced: %s of %d' - % (gg_flows, ngg)) + # (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) From 1f3cbbb4f9bec9c8eae7ec523ee0166c26faa2b1 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Fri, 24 Jul 2026 16:45:48 +0200 Subject: [PATCH 33/43] madevent crossing: route the router's colour translation through the canonical code Track A step (ii) of the colour encoding: the base returns its selected colour flow in ITS flow order, and the router must express it in this subprocess's. That went through COLMAP__, one explicit map per base-flavor pair, precomputed by _router_colmap. It now goes through the canonical colour-flow code instead: decode the base's code with the base's slot structure -> relabel both legs of every connection with the crossing permutation -> re-encode in this subprocess's slot order -> look the result up in this subprocess's own code table _color_code_tables(me) supplies the three arrays that needs -- codes, colour-slot legs, anticolour-slot legs -- and they are per-ME, so every crossing sharing a base reuses them, where COLMAP needed one array per pair. It is the same trick already used for the helicity, with one difference noted in _color_flow_code: the helicity relabel is a pure digit REORDER, while colour needs a permutation CONJUGATION (digit positions and digit values both relabel). The direction of that relabel is not ambiguous even though it looks like it should be: get_crossing_permutation builds two DISJOINT transpositions and marks the overlapping 3-cycle codes invalid, so a crossing is always an involution and perm == inv. A probe over p p > j j, j j j, w+ j j, t t~ j and t t~ j j confirmed it on all 16 crossing pairs, with the relabelled connections landing on exactly the dependent's own slot structure every time. _router_colmap is kept and is still the fallback for an ME with no usable code (no colour basis, or a flow that is not a clean colour<->anticolour bijection); none of the processes exercised here needed it. Not converted, deliberately: Track B's DSIG_XGCOL. It is not a label relabel like COLMAP -- it permutes the base's published per-flow JAMP2 into the dependent's flow order so the dependent can run its OWN select_color (the native reselect). That reorder is intrinsic, it survives the next step too, and it is already derived from the same canonical form, so moving it to runtime would rebuild the identical array. Validated: the emitted fortran, transcribed and simulated, reproduces the old COLMAP /4,3,2,1/ for 1_gQx_ttxQx <- 1_gQ_ttxQ (cross=5); all four code-path routers compile; and event records are BYTE-IDENTICAL against the previous generation for p p > t t~ j (500 ev, seed 321) and p p > j j j (500 ev, seed 4242), same cross-section. 43/43 crossing acceptance tests pass. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 114 ++++++++++++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 7 deletions(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 1d248d882..d34f9629e 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -9455,6 +9455,33 @@ def _color_flow_codes(self, matrix_element): 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 + 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]) + def _router_colmap(self, router_me, base_me, cross): """Map each base colour-flow index to this subprocess's flow index. @@ -9505,10 +9532,16 @@ def write_matrix_router_file(self, writer, matrix_element, fortran_model, crossed FLAV_IDX from partition_crossing_classes; the heavy MATRIX is not emitted. get_nhel lives in auto_dsig.f, so it is unaffected. - The base returns the selected colour flow in the base's flow order; a - per-flavor COLMAP maps it to this subprocess's flow (same topology) so - the event's ICOLUP is right (see _router_colmap). Momenta, PDGs and the - helicity index already come out in this subprocess's own convention.""" + The base returns the selected colour flow in the base's flow order, + which must be translated to this subprocess's so the event's ICOLUP is + right. That goes through the canonical colour-flow CODE: decode the + base's code, relabel the legs with the crossing permutation, re-encode + and look the result up in this subprocess's own code table (see + _color_flow_code). The tables are per-ME and shared by every crossing of + the same base, and the same code is what _router_colmap computes at + generation time -- kept as the fallback for an ME with no usable code. + 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( @@ -9523,7 +9556,10 @@ def write_matrix_router_file(self, writer, matrix_element, fortran_model, # 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) for flav0, (base_index, iflav) in enumerate(routing): base_me = matrix_elements[base_index] nflav_base = len(base_me.get_external_flavors_with_iden()) @@ -9534,9 +9570,11 @@ def write_matrix_router_file(self, writer, matrix_element, fortran_model, dispatch.append( ' CALL SMATRIX%d(P, %d, RHEL, RCOL, channel, IVEC, ANS,' ' IHEL, ICOL)' % (base_index + 1, iflav)) + 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 @@ -9561,9 +9599,63 @@ def write_matrix_router_file(self, writer, matrix_element, fortran_model, ' ENDDO', ' IHEL = IHEL + 1', ] - # A non-identity colmap has to be applied; skip it when it is the - # identity (single flow, or the flow orders already agree). - if colmap and colmap != list(range(1, len(colmap) + 1)): + # The base's flow index has to be translated to this subprocess's; + # 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/' % ( @@ -9572,6 +9664,14 @@ def write_matrix_router_file(self, writer, matrix_element, fortran_model, ' 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 From 48b360eaa4978ec641acf661605ea9d8495746e5 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Fri, 24 Jul 2026 17:52:18 +0200 Subject: [PATCH 34/43] madevent: rebuild event colour tags from the canonical code, drop the ICOLUP table Step (i) of the colour encoding. The event's colour tags came from the ICOLUP table baked into leshouche.inc; addmothers now rebuilds them from the canonical colour-flow code, and leshouche.inc stops writing the table. What did NOT change is the runtime label: ICOL stays the flow INDEX, because it also indexes JAMP2 and ICOLAMP in the selection. Only the source of the tags moves. That is what keeps code 0 harmless -- a process with a single colour connection encodes to 0 (u u~ > z, u d~ > w+, and p p > e+ e- in a real run), which would have collided with ICOL=0 meaning "no colour" had the code become the label. New per-P-directory colorflow.inc carries NCOLSLOT / ICOLCSL / ICOLASL / ICOLCODE -- the per-flow codes plus the slot structure, which is flow independent, so it is a handful of integers where ICOLUP was 2*nexternal*nflow. addmothers decodes it: connection k joins colour slot ICOLCSL(k) to anticolour slot ICOLASL(digit+1), each connection gets its own tag, and the two roles are swapped back on the initial-state legs, undoing the reversal color_flow_decomposition applies to follow the les houches convention. NCOLSLOT=0 marks an ME with no usable code -- no colour basis, a SEXTET (stored as a negative tag in the opposite slot, a sign a decoder cannot restore, now refused explicitly), or an epsilon structure. leshouche.inc still writes ICOLUP for those and addmothers still reads it, so the fallback is intact. The colour helpers moved from ProcessExporterFortranMEGroup up to ProcessExporterFortranME: non-group madevent ships addmothers.f too and needs them. MadWeight is untouched -- it inherits ProcessExporterFortran, ships no addmothers.f, and keeps drop_icolup=False, so its leshouche.inc is unchanged. The python readers of leshouche.inc (madweight Cards.read_leshouches_file, madevent_interface.get_subP_ids) parse only IDUP. This RENUMBERS the tags: color_flow_decomposition numbers them by the colour string's traversal order, not by connection order, so the rebuilt tags are a relabelling. Validated by colour CONNECTIVITY equivalence (tags relabelled by order of first appearance) with every other event field required to match exactly: p p > t t~ j 500 ev and p p > j j j 500 ev (grouped) and u u~ > u u~ 800 ev (non-grouped) all give 0 colour mismatches and 0 other-field mismatches at unchanged cross-sections. Only 13 of the 500 t t~ j events kept their raw tags, so the check is not passing by coincidence. p p > e+ e- exercises the code-0 path end to end: 400/400 events put the colour on the initial quark and the anticolour on the antiquark. 43/43 crossing acceptance tests pass. IOTests were run read-only (-R): the 26 differing references are identical with and without this change, i.e. all pre-existing on this branch, and none of them is a leshouche/colour file. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 387 ++++++++++++-------- madgraph/iolibs/template_files/addmothers.f | 42 ++- 2 files changed, 273 insertions(+), 156 deletions(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index d34f9629e..6c8935304 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -1813,11 +1813,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 @@ -1855,7 +1866,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]: @@ -7080,6 +7091,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 @@ -9063,6 +9078,205 @@ def write_driver(self, writer, ncomb, n_grouped_proc, v5=True): else: return replace_dict + def _module_color_flows(self, matrix_element): + """Return the colour-flow decomposition (leshouche ICOLUP) of an ME as a + list, one entry per flow, of (colour, anticolour) per leg in leg order. + None if the ME has no colour basis.""" + if not matrix_element.get('color_basis'): + return None + proc = matrix_element.get('processes')[0] + legs = proc.get_legs_with_decays() + ninitial = matrix_element.get_nexternal_ninitial()[1] + repr_dict = {l.get('number'): + proc.get('model').get_particle(l.get('id')).get_color() + * (-1) ** (1 + l.get('state')) for l in legs} + flows = matrix_element.get('color_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 #=========================================================================== @@ -9331,157 +9545,6 @@ class ProcessExporterFortranMEGroup(ProcessExporterFortranME): #=========================================================================== # write_matrix_router_file #=========================================================================== - def _module_color_flows(self, matrix_element): - """Return the colour-flow decomposition (leshouche ICOLUP) of an ME as a - list, one entry per flow, of (colour, anticolour) per leg in leg order. - None if the ME has no colour basis.""" - if not matrix_element.get('color_basis'): - return None - proc = matrix_element.get('processes')[0] - legs = proc.get_legs_with_decays() - ninitial = matrix_element.get_nexternal_ninitial()[1] - repr_dict = {l.get('number'): - proc.get('model').get_particle(l.get('id')).get_color() - * (-1) ** (1 + l.get('state')) for l in legs} - flows = matrix_element.get('color_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 - 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]) - def _router_colmap(self, router_me, base_me, cross): """Map each base colour-flow index to this subprocess's flow index. @@ -9982,6 +10045,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: @@ -10500,11 +10567,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): diff --git a/madgraph/iolibs/template_files/addmothers.f b/madgraph/iolibs/template_files/addmothers.f index 85fb5b596..a776539b6 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 From 4b59d3b739ee21dbf97590132961845f139f50ee Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Fri, 24 Jul 2026 20:57:09 +0200 Subject: [PATCH 35/43] mg7: emit the canonical colour-flow code into subprocesses.json C4, data layer. get_subprocess_info now also writes color_codes (the canonical colour-flow code of each flow) and color_slots (the flow-independent colour / anticolour slot legs needed to decode a code), using the SAME encoding as the fortran madevent output -- get_color_code_tables delegates to export_v4's _color_flow_canon / _color_flow_code / _color_flow_slots, so a code means the same thing on both backends. color_codes/color_slots are null when the flows have no usable code (a sextet's negative tag, or an epsilon structure); a consumer falls back to color_flows. The existing color_flows (ICOLUP-style per-flow tags) is kept: besides being the fallback, the LHE writer still derives INTERNAL propagator/decay-line colours from it, so it is not redundant even where a code exists. Validated against the fortran colorflow.inc: u u~ > g g gives codes [7,11] and slots color=[2,3,4]/acolor=[1,3,4] (identical to the madevent P1_gg_qq tables); u u~ > e+ e- gives [0] (the single-connection code, matching p p > e+ e- in madevent). This is only the encoder side -- the runtime that decodes a code to LHE tags is a separate step (the mg7 colour selection is currently a stub, see api.cpp color_out=0). Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_mg7.py | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/madgraph/iolibs/export_mg7.py b/madgraph/iolibs/export_mg7.py index d2cd624d6..1ae1dbc47 100644 --- a/madgraph/iolibs/export_mg7.py +++ b/madgraph/iolibs/export_mg7.py @@ -157,6 +157,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: @@ -174,8 +207,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 ]] * len(self.all_flavors_same_initial) #TODO: this is wrong for multiple flavors!!! + color_codes, color_slots = self.get_color_code_tables( + color_flow_dicts, legs) else: color_flows = [[[[0, 0]] * n_external]] * len(self.all_flavors_same_initial) #TODO: this is wrong for multiple flavors!!! + 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. @@ -218,7 +254,15 @@ def get_subprocess_info(self, proc_dir, lib_me_path): "me_path": lib_me_path, "path": proc_dir, "flavors": flavors, + # ICOLUP-style per-flow tags. Still needed by the LHE writer to + # reconstruct the colour of INTERNAL (propagator/decay) lines, so it + # cannot be dropped just because the code gives the external legs. "color_flows": color_flows, + # canonical colour-flow code of each flow + the (flow independent) + # slot structure to decode it; null when the flows have no usable + # code, in which case a consumer falls back to "color_flows". + "color_codes": color_codes, + "color_slots": color_slots, "pdg_color_types": pdg_color_types, "diagram_count": len(self.diagrams), "helicities": list(self.matrix_element.get_helicity_matrix()), From 62f3ffba5eb92ea2f34ec74ae1335f839198ca15 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Fri, 24 Jul 2026 21:11:45 +0200 Subject: [PATCH 36/43] madmatrix: bake the colour-flow code into coloramps.h C4, ME side. coloramps.h now carries colorflowcode[ncolor] -- the canonical colour-flow code of each flow -- next to the icolamp colour masks, plus a colorflowcode_valid flag. This is the "encoding" the ME returns: given the flow index it already selects (select_col_and_diag, 0-based icolC), the code is colorflowcode[icolC], a self-describing integer the LHE writer can decode without an ICOLUP-style tag table. edit_coloramps computes the codes with get_color_code_tables -- the same encoder that fills subprocesses.json -- so the C++ table, the json "color_codes", and the fortran colorflow.inc all agree. colorflowcode_valid is false for flows with no usable code (a sextet's two-slot leg, an epsilon structure); the code array is then filler and a consumer falls back to the per-flow tag table. Note the valid flag is separate from the code value, so code 0 (a single colour connection, e.g. u u~ > e+ e-) is unambiguous -- unlike the fortran icol=0 "no colour" sentinel, which is why the fortran side keeps the index as the label. The flow-independent slot structure a decoder also needs is not baked here; it is static per subprocess and already travels in subprocesses.json as "color_slots". Validated at generation: u u~ > g g bakes colorflowcode = {7,11} (identical to the json color_codes and the fortran ICOLCODE), u u~ > e+ e- bakes {0} with valid=true; the generated CPPProcess.cc (which includes coloramps.h) passes g++ -std=c++17 -fsyntax-only. The runtime that returns and decodes the code (api.cpp selection wiring + the madspace decoder) is specified separately -- the mg7 event-level colour selection is still a stub (api.cpp color_out=0), the same gap the helicity encode has. Co-Authored-By: Claude Opus 4.8 --- .../template_files/madmatrix/coloramps.h | 18 +++++++++++++ madmatrix/model_handling.py | 25 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/madgraph/iolibs/template_files/madmatrix/coloramps.h b/madgraph/iolibs/template_files/madmatrix/coloramps.h index 027f1aa44..1a69fd614 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/madmatrix/model_handling.py b/madmatrix/model_handling.py index 85359fb98..0dbac9717 100644 --- a/madmatrix/model_handling.py +++ b/madmatrix/model_handling.py @@ -2076,6 +2076,31 @@ 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} + color_flow_dicts = self.color_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.write(template % replace_dict) ff.close() From 8b7512d9e1971aee943fdf9ebe33a1a431cb76ff Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sat, 25 Jul 2026 18:51:54 +0200 Subject: [PATCH 37/43] crossing: unify the good-helicity remap on a runtime encode (drop the GHREMAP table) The good-helicity filter (GOODHEL) is shared across the crossings of a flavor, but a crossing permutes and sign-flips the helicities, so a crossed row and its identity counterpart are different rows. The standalone backend bridged that with a baked GHREMAP(NCROSS*NCOMB) table (crossed row -> identity row); madevent did not bridge it at all -- it simply disabled the filter for crossed MEs (the '.OR. .TRUE.' gate) and computed every helicity. Both now use one shared runtime routine, CROSS_GHIDX: the crossed->identity map is a fixed permutation, so it is cheaper to recompute it from the config than to store it -- permute and sign-flip the row's config with the crossing's PERM/SGN (GET_CROSS_PERM), then re-encode it in the canonical mixed-radix order (the same STATES/NHSTATE the encoder/decoder use). All that survives as DATA is a small per-crossing GHFILT flag (NCROSS ints, was NCROSS*NCOMB) marking which crossings are filterable; a non-filterable one (initial-initial swap, inapplicable, or a non-bijection) returns GHIDX=0 -> compute every helicity, never train. For CROSS=0 it returns IHEL, so the uncrossed path is unchanged. This removes the GHREMAP table from the standalone and, more usefully, gives madevent good-helicity filtering for crossed MEs for the first time (it had been computing all NCOMB helicities). madevent precomputes GHIDXA(I) once per SMATRIX call and gates/trains the shared GOODHEL(FLAV_USE) column through it; the index is clamped with MAX(GHIDXA(I),1) because the gate reads GOODHEL before the GHIDXA(I).EQ.0 guard and fortran does not short-circuit .OR. (madevent builds with -fbounds-check). Encoding validated in python against compute_ghremap (0 mismatches, t t~ / t t~ j / t t~ j j). 43/43 crossing acceptance tests pass on a clean run. Unified madevent p p > t t~ j gives 576.6 +- 3.1 pb vs the unfiltered 578.8 (0.4%, within MC error), confirming the filter skips only zero-contribution helicities. Speed context (standalone microbenchmark, this branch): the runtime encode is ~10x a table load in isolation but invisible once the ME runs (<=4% for the lightest ME, ~0% for t t~ j j); the table's only real cost was generated-code size, now removed. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 114 +++++++++++++----- .../matrix_madevent_group_v4.inc | 6 +- .../matrix_standalone_crossing_v4.inc | 48 ++++++++ 3 files changed, 136 insertions(+), 32 deletions(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 36597c2e7..fbfb20d9b 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -2512,15 +2512,17 @@ def fill_crossing_replace_dict(self, matrix_element, replace_dict, (key, value % {'proc_prefix': prefix, 'den_factor_line': replace_dict['den_factor_line']}) for key, value in self.CROSSING_SNIPPETS.items())) - # The GHREMAP DATA is process dependent (it depends on the helicity - # table and the crossing permutations), so it is appended here rather - # than living in the fixed CROSSING_SNIPPETS. The fortran NHEL table is - # emitted with get_helicity_matrix()'s default order (allow_reverse - # True), so the remap must be built in that same order. - ghremap = self.compute_ghremap(matrix_element, allow_reverse=True) - replace_dict['smatrix_cross_decl'] += '\n' + \ - self.format_integer_data_lines( - 'GHREMAP', [0 if row is None else row + 1 for row in ghremap]) + # CROSS_GHIDX (in the crossing routines below) recomputes the crossed + # -> identity helicity row map at runtime; it needs only the small + # per-crossing GHFILT flag plus the STATES/NHSTATE the encoder uses (in + # get_helicity_matrix()'s default allow_reverse=True order, so the map is + # built in the same order the NHEL table is emitted). + hel_data = self._helstate_data(matrix_element) + replace_dict['maxhel'] = hel_data['maxhel'] + replace_dict['nhstate_data'] = hel_data['nhstate_data'] + replace_dict['states_data'] = hel_data['states_data'] + replace_dict['ghfilt_data'] = self.format_integer_data_lines( + 'GHFILT', self.compute_ghfilt(matrix_element, allow_reverse=True)) replace_dict['pdg_cross_snippets'] = tuple( snippet % {'proc_prefix': prefix} for snippet in self.PDG_CROSS_SNIPPETS_ON) @@ -2563,6 +2565,8 @@ def fill_crossing_replace_dict_me(self, matrix_element, replace_dict, '\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': @@ -2597,10 +2601,17 @@ def fill_crossing_replace_dict_me(self, matrix_element, replace_dict, 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)} + 'iden_cross_lines': self.get_iden_cross_lines(matrix_element), + 'maxhel': hel_data['maxhel'], + 'nhstate_data': hel_data['nhstate_data'], + 'states_data': hel_data['states_data'], + 'ghfilt_data': self.format_integer_data_lines( + 'GHFILT', self.compute_ghfilt(matrix_element, + allow_reverse=True))} replace_dict.update({ 'smatrix_me_cross_decl': ( ' INTEGER NFLAV\n' @@ -2610,7 +2621,12 @@ def fill_crossing_replace_dict_me(self, matrix_element, replace_dict, ' REAL*8 PUSE(0:3,NEXTERNAL)\n' ' INTEGER NHELUSE(NEXTERNAL,NCOMB)\n' ' INTEGER %(cp)sGET_SPINCOL_CROSS\n' - ' INTEGER %(cp)sGET_IDENT_CROSS' + ' 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' ) % {'nflav': nflav, 'cp': cp}, # 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. @@ -2627,14 +2643,30 @@ def fill_crossing_replace_dict_me(self, matrix_element, replace_dict, ' IC0(XKCR) = 1\n' ' ENDDO\n' ' CALL %(cp)sAPPLY_CROSSING_TABLE(IFLAV, NCOMB, P, NHEL,\n' - ' & IC0, PUSE, NHELUSE, IC, FLAV_USE)' + ' & 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' ) % {'cp': cp}, 'me_flav_key': 'FLAV_USE', - # A crossing permutes/flips helicities, so the shared GOODHEL filter - # (keyed by the reduced flavor) no longer gates its rows; compute - # every helicity for a crossing-enabled ME (optimise with a remap - # later). For CROSS=0 the crossed arrays equal the originals. - 'smatrix_me_goodhel_or': ' .OR. .TRUE.', + # 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 @@ -2749,12 +2781,12 @@ def fill_crossing_replace_dict_me(self, matrix_element, replace_dict, INTEGER NHELUSE(NEXTERNAL,NCOMB) INTEGER ICUSE(NEXTERNAL) INTEGER DUMFLAV -C GHREMAP maps a crossed helicity row to the identity row whose GOODHEL -C bit gates it (see smatrix_goodhel_gate); the DATA statements follow. - INTEGER NCROSS - PARAMETER (NCROSS=(NEXTERNAL+1)*(NEXTERNAL+1)) +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 GHREMAP(0:NCROSS*NCOMB-1)""", + 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. @@ -2765,7 +2797,11 @@ def fill_crossing_replace_dict_me(self, matrix_element, replace_dict, RETURN ENDIF""", - 'smatrix_cross_apply': """C Apply the crossing ONCE, here, rather than once per helicity: the whole + '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 @@ -2778,12 +2814,13 @@ def fill_crossing_replace_dict_me(self, matrix_element, replace_dict, '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. GHREMAP sends crossed -C row IHEL to the identity row that gates it (sigma^-1); 0 means the -C crossing is not filterable (an initial-initial swap, or a crossing that -C cannot be applied) so its every helicity is computed. For CROSSUSE=0 -C GHREMAP is the identity, so this is exactly the historical gate. - GHIDX = GHREMAP(CROSSUSE*NCOMB + IHEL - 1) +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 @@ -3268,6 +3305,25 @@ def compute_ghremap(self, matrix_element, allow_reverse=True): 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.""" + remap = self.compute_ghremap(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.""" diff --git a/madgraph/iolibs/template_files/matrix_madevent_group_v4.inc b/madgraph/iolibs/template_files/matrix_madevent_group_v4.inc index c81b04936..c92db91c4 100644 --- a/madgraph/iolibs/template_files/matrix_madevent_group_v4.inc +++ b/madgraph/iolibs/template_files/matrix_madevent_group_v4.inc @@ -152,7 +152,7 @@ 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,%(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 + 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 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 @@ -185,8 +185,8 @@ C ---------- 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,%(me_flav_key)s,%(proc_id)s) .AND. (DABS(TS(I)).GT.ANS*LIMHEL/NCOMB)) THEN - GOODHEL(I,%(me_flav_key)s,%(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(%(me_flav_key)s,%(proc_id)s) ENDIF diff --git a/madgraph/iolibs/template_files/matrix_standalone_crossing_v4.inc b/madgraph/iolibs/template_files/matrix_standalone_crossing_v4.inc index 4147ee62d..dd193f970 100644 --- a/madgraph/iolibs/template_files/matrix_standalone_crossing_v4.inc +++ b/madgraph/iolibs/template_files/matrix_standalone_crossing_v4.inc @@ -229,3 +229,51 @@ C into it, via SRC_CROSS_TABLE. RETURN END + + SUBROUTINE %(proc_prefix)sCROSS_GHIDX(CROSS, PERM, SGN, NHELCOL, + & GHIDX) +C Runtime good-helicity remap: send a crossed helicity row (given by its +C BASE-table config NHELCOL and the crossing's PERM/SGN from GET_CROSS_PERM) +C to the identity row whose shared GOODHEL bit gates it. This replaces the +C baked GHREMAP(NCROSS*NCOMB) table: the map is a fixed permutation, so it is +C cheaper to recompute it from the config than to store it -- permute and +C sign-flip the config, then re-encode it in the canonical mixed-radix order +C (the same STATES/NHSTATE the encoder/decoder use). +C GHIDX=0 when the crossing is not filterable (GHFILT flag: initial-initial +C swap, inapplicable, or a non-bijection); the caller then computes every +C helicity and never trains. For CROSS=0 (PERM identity, SGN +1) this returns +C IHEL, so the uncrossed path is unchanged. + IMPLICIT NONE + INCLUDE 'nexternal.inc' + INTEGER MAXHEL + PARAMETER (MAXHEL=%(maxhel)d) + INTEGER NCROSS + PARAMETER (NCROSS=(NEXTERNAL+1)*(NEXTERNAL+1)) + INTEGER CROSS, PERM(NEXTERNAL), SGN(NEXTERNAL) + INTEGER NHELCOL(NEXTERNAL), GHIDX + INTEGER I, K, D, TGT(NEXTERNAL) + INTEGER GHFILT(0:NCROSS-1) + INTEGER NHSTATE(NEXTERNAL), STATES(MAXHEL,NEXTERNAL) +%(ghfilt_data)s +%(nhstate_data)s +%(states_data)s + IF (GHFILT(CROSS).EQ.0) THEN + GHIDX = 0 + RETURN + ENDIF + DO K=1,NEXTERNAL + TGT(PERM(K)) = SGN(K)*NHELCOL(K) + ENDDO + GHIDX = 0 + DO K=1,NEXTERNAL + DO D=1,NHSTATE(K) + IF (STATES(D,K).EQ.TGT(K)) GOTO 7 + ENDDO + D = 1 + 7 CONTINUE + GHIDX = GHIDX*NHSTATE(K) + (D-1) + ENDDO + GHIDX = GHIDX + 1 + + RETURN + END From d424f1eb2b7459f336362451c9cf812346674161 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sat, 25 Jul 2026 21:43:04 +0200 Subject: [PATCH 38/43] crossing: record crossed subprocesses as metadata instead of dropping them (merge_crossing='record') Stage A of generating crossing-related subprocesses only once. At generation, MultiProcess.generate_matrix_elements already detects crossings (the crossing- invariant sorted_legs signature) and has two modes: merge_crossing=False reuses the base diagrams but still appends a separate amplitude -> separate ME -> separate directory (the redundant P1_gQx_ttxQx / P1_QQx_ttxg dirs); merge_crossing=True skips the crossed process entirely but DROPS it, silently losing that partonic contribution. This adds a third mode, merge_crossing='record': on a crossing hit, do not generate a separate amplitude, but record the crossed process (with the base and crossed leg permutations) on the base amplitude's new 'crossed_processes' slot. The partonic contribution is not lost -- the base's crossing-aware SMATRIX can evaluate it via the crossed FLAV_IDX -- and the exporter/driver will reach it from this metadata (Stages B/C). No diagrams are built for the crossed process. The slot is added to Amplitude and carried onto HelasMatrixElement (populated in its constructor from the base amplitude). Nothing is wired into the interface yet, so the default output is unchanged (merge_crossing=False) -- verified: standalone p p > t t~ j still writes 4 directories. The two Amplitude unit tests that pin the exact property set are updated; diagram_generation (50) and helas_objects (65) unit suites pass. Verified at generation: with merge_crossing='record', p p > t t~ j yields 2 base MEs (g g > t t~ g, g Q > t t~ Q); the g Q base carries the 4 crossed subprocesses (g q~ > t t~ q~, q q~ > t t~ g and their beam-swaps) with permutations -- the complete partonic set (the base handles g q + q g via its mirror flag). Co-Authored-By: Claude Opus 4.8 --- madgraph/core/diagram_generation.py | 27 ++++++++++++++++++- madgraph/core/helas_objects.py | 14 +++++++++- .../core/test_diagram_generation.py | 6 +++-- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/madgraph/core/diagram_generation.py b/madgraph/core/diagram_generation.py index 1f239abf5..1b78c0b31 100755 --- a/madgraph/core/diagram_generation.py +++ b/madgraph/core/diagram_generation.py @@ -444,6 +444,13 @@ def default_setup(self): # has_mirror_process is True if the same process but with the # two incoming particles interchanged has been generated self['has_mirror_process'] = False + # Crossed subprocesses folded into this amplitude and NOT generated on + # their own (merge_crossing='record'): each entry is + # (crossed Process, base_permutation, crossed_permutation), enough for + # the exporter to reach the crossed process through this amplitude's + # crossing-aware SMATRIX (see MultiProcess.cross_amplitude for the same + # permutation pair). Empty in the historical modes. + self['crossed_processes'] = [] def __init__(self, argument=None): """Allow initialization with Process""" @@ -470,6 +477,9 @@ def filter(self, name, value): if name == 'has_mirror_process': if not isinstance(value, bool): raise self.PhysicsObjectError("%s is not a valid boolean" % str(value)) + if name == 'crossed_processes': + if not isinstance(value, list): + raise self.PhysicsObjectError("%s is not a valid list" % str(value)) return True def get(self, name): @@ -487,7 +497,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""" @@ -1944,6 +1955,20 @@ def get_flavor(id, fsleg): non_permuted_procs.append(fast_proc) logger.info("Crossed process found for %s, reuse diagrams." % \ process.base_string()) + elif merge_crossing == 'record': + # Found crossing - do NOT generate a separate + # amplitude, but record the crossed process on the + # base so the exporter can still reach it through the + # base's crossing-aware SMATRIX (its partonic + # contribution is not lost, unlike merge_crossing=True). + amplitudes[crossed_index].get('crossed_processes')\ + .append((process, permutations[crossed_index], + permutation)) + logger.info("Crossed process %s recorded on %s " + "(not generated)." % + (process.base_string(), + amplitudes[crossed_index].get('process') + .base_string())) else: logger.info("Crossed process found for %s, do not generate diagrams." % \ process.base_string()) diff --git a/madgraph/core/helas_objects.py b/madgraph/core/helas_objects.py index 46d3b015e..6963917d1 100755 --- a/madgraph/core/helas_objects.py +++ b/madgraph/core/helas_objects.py @@ -3984,6 +3984,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 @@ -4023,6 +4028,9 @@ def filter(self, name, value): if name == 'has_mirror_process': if not isinstance(value, bool): raise self.PhysicsObjectError("%s is not a valid boolean" % str(value)) + if name == 'crossed_processes': + if not isinstance(value, list): + raise self.PhysicsObjectError("%s is not a valid list" % str(value)) return True def get_sorted_keys(self): @@ -4030,7 +4038,8 @@ def get_sorted_keys(self): return ['processes', 'identical_particle_factor', 'diagrams', 'color_basis', 'color_matrix', - 'base_amplitude', 'has_mirror_process'] + 'base_amplitude', 'has_mirror_process', + 'crossed_processes'] # Enhanced get function def get(self, name): @@ -4055,6 +4064,9 @@ 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 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() diff --git a/tests/unit_tests/core/test_diagram_generation.py b/tests/unit_tests/core/test_diagram_generation.py index 729c50ce3..8913d3b11 100755 --- a/tests/unit_tests/core/test_diagram_generation.py +++ b/tests/unit_tests/core/test_diagram_generation.py @@ -53,7 +53,8 @@ class AmplitudeTest(unittest.TestCase): def setUp(self): self.mydict = {'diagrams':self.mydiaglist, 'process':self.myprocess, - 'has_mirror_process': False} + 'has_mirror_process': False, + 'crossed_processes': []} self.myamplitude = diagram_generation.Amplitude(self.mydict) @@ -124,7 +125,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)) From 36f74a1d389ec680caacc6739107fa3d3e44ab09 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sat, 25 Jul 2026 21:51:08 +0200 Subject: [PATCH 39/43] crossing (standalone): fold crossed subprocesses into the base directory (merge_crossing='record') Stage B. With merge_crossing='record' (env-gated for staged rollout) the crossed subprocesses are no longer generated, so the standalone exporter -- which writes one directory per matrix element -- stops emitting the redundant P1_gQx_ttxQx / P1_QQx_ttxg directories: p p > t t~ j drops from 4 SubProcess dirs to 2 (gg, gQ). The crossed partonic contributions are not lost: the base gQ matrix.f is BYTE-IDENTICAL to the full-generation one (verified), so its already-validated crossing-aware SMATRIX evaluates the g q~ / q q~ configs at the crossed FLAV_IDX exactly as before. check_sa.f now exercises them: the crossing-demo loop in _get_check_sa_crossing_example (previously a dormant IF(.FALSE.) example) is enabled precisely when the ME carries crossed_processes, so the folded subprocesses -- which no longer have a directory of their own -- are the one place this driver evaluates them, and it does. Default output is unchanged: without the env var no crossing is recorded, crossed_processes is empty, the demo gate stays .false., and standalone still writes 4 directories. TEMP: madevent still generates the crossed MEs separately (Stage C re-points its Track A/B summation at the metadata); the env gate becomes the default for crossing-enabled output once both backends read the metadata. Co-Authored-By: Claude Opus 4.8 --- madgraph/interface/madgraph_interface.py | 9 +++++++++ madgraph/iolibs/export_v4.py | 12 ++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/madgraph/interface/madgraph_interface.py b/madgraph/interface/madgraph_interface.py index cbd12f4c3..0263dab29 100755 --- a/madgraph/interface/madgraph_interface.py +++ b/madgraph/interface/madgraph_interface.py @@ -3403,7 +3403,16 @@ def do_add(self, line): # 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 (records the + # crossed process on the base instead of dropping it) so the crossed + # subprocess is never generated but is still reached through the base's + # crossing-aware SMATRIX. TEMP: gated on an env var during staged rollout + # (standalone consumes it first, madevent next); becomes the default for + # crossing-enabled output once both backends read the metadata. merge_crossing = False + if os.environ.get('MG_MERGE_CROSSING') == 'record': + merge_crossing = 'record' # Check the validity of the arguments self.check_add(args) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index fbfb20d9b..4177e9e00 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -5748,15 +5748,19 @@ def _get_check_sa_crossing_example(self, matrix_element, proc_prefix): # 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. The whole section is - # gated behind IF(.FALSE.) so it is present only as a ready-to-enable - # example -- flip it to .TRUE. to actually print the crossed processes. + # (FLAV_IDX = I*NFLAV+J) instead of a bare literal. The loop is gated + # behind IF(.FALSE.) unless crossed subprocesses were folded into this ME + # (merge_crossing='record'): then those partonic contributions have no + # directory of their own and this driver is the only place they are + # exercised, so the demo is enabled to actually evaluate them. n_table, _ = self._build_flav_table_flat(matrix_element) + loop_gate = ('.true.' if matrix_element.get('crossed_processes') + else '.false.') sep = (' write (*,*) "-----------------------------------------' '------------------------------------"') lines = [ - ' if(.false.) then', + ' if(%s) then' % loop_gate, ' write (*,*)', ' write (*,*) " Crossing-symmetry examples (crossed processes):"', ' write (*,*)', From d3db049c2a2a8be6447f203ce2da4706dab9e57c Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sat, 25 Jul 2026 22:26:05 +0200 Subject: [PATCH 40/43] crossing (madevent/grouped): reconstruct crossed subprocesses from metadata (merge_crossing='record') Stage C. merge_crossing='record' does not generate the crossed subprocesses, so the standalone output folds them into the base directory. The grouped backends (madevent, mg7/cudacpp) need each crossing back as an integration unit -- it is a distinct partonic channel with its own PDF and phase space -- so this expands the recorded crossed_processes metadata into crossed amplitudes just before grouping, reusing the base's diagrams via MultiProcess.cross_amplitude (no diagram regeneration). The existing grouping + crossing routing (Track A/B) then handles them exactly as an unmerged (merge_crossing=False) generation. Record mode records 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), so the reconstruction folds the beam-swap back into has_mirror_process, matching what generate_matrix_elements does -- otherwise an extra mirror directory (e.g. P1_qg_ttxq) would appear. Result: record-mode grouped output is BYTE-IDENTICAL to a default build. Verified matrix1_orig.f / auto_dsig1.f / leshouche.inc identical across every P dir for p p > t t~ j, p p > w+ j and p p > j j; p p > t t~ j xsec 576.6 pb in both modes; mg7 record mode generates cleanly. The block is gated on any ME carrying crossed_processes, so a default (merge_crossing=False) generation is untouched. Co-Authored-By: Claude Opus 4.8 --- madgraph/interface/madgraph_interface.py | 52 ++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/madgraph/interface/madgraph_interface.py b/madgraph/interface/madgraph_interface.py index 0263dab29..4d643d1cb 100755 --- a/madgraph/interface/madgraph_interface.py +++ b/madgraph/interface/madgraph_interface.py @@ -9969,6 +9969,58 @@ 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. + if any(amp.get('crossed_processes') for amp in non_dc_amps): + 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')) + + # Read the recorded crossings before clearing them. + originals = [(amp, amp.get('crossed_processes')) + for amp in non_dc_amps] + expanded = diagram_generation.AmplitudeList() + seen = {} # fast_proc -> amplitude, for mirror folding + for amp, _crossed in originals: + amp.set('crossed_processes', []) + expanded.append(amp) + seen[_fastproc(amp)] = amp + # 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); fold the beam-swap back into has_mirror_process + # here, exactly as generate_matrix_elements would. + 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 + non_dc_amps = expanded + if non_dc_amps: subproc_groups.extend(\ group_subprocs.SubProcessGroup.group_amplitudes(\ From 88b5c83b680e43946fa4ddf7118a77d6b3815de1 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sat, 25 Jul 2026 22:38:57 +0200 Subject: [PATCH 41/43] crossing: make merge_crossing='record' the default (standalone folds crossings by default) Flips the staged env gate to the real default: a crossing-enabled generation now uses merge_crossing='record', so the crossed subprocesses are recorded on the base instead of generated on their own. Consequences: * fortran `standalone` output writes one directory per base matrix element -- the crossings are folded into the base's crossing-aware SMATRIX (p p > t t~ j: 4 SubProcess dirs -> 2). This is the visible goal. * every summation / event-generation backend (madevent, me7, mg7/cudacpp) reconstructs the crossings from the metadata at output (do_output), so its output is BYTE-IDENTICAL to before -- madevent p p > t t~ j still 3 dirs, base matrix.f identical. The do_output reconstruction is skipped only for self._export_format=='standalone' (which folds); reconstructing is the safe default for anything else. --use_crossing=False keeps the complete unmerged generation, and MG_MERGE_CROSSING=off is a debug escape hatch to the same. NOTE: this changes the fortran standalone crossing OUTPUT STRUCTURE, so the standalone-crossing IOTest reference files must be regenerated. Co-Authored-By: Claude Opus 4.8 --- madgraph/interface/madgraph_interface.py | 35 +++++++++++++++++------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/madgraph/interface/madgraph_interface.py b/madgraph/interface/madgraph_interface.py index 4d643d1cb..1a83eba09 100755 --- a/madgraph/interface/madgraph_interface.py +++ b/madgraph/interface/madgraph_interface.py @@ -3404,15 +3404,19 @@ def do_add(self, line): # 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 (records the - # crossed process on the base instead of dropping it) so the crossed - # subprocess is never generated but is still reached through the base's - # crossing-aware SMATRIX. TEMP: gated on an env var during staged rollout - # (standalone consumes it first, madevent next); becomes the default for - # crossing-enabled output once both backends read the metadata. - merge_crossing = False - if os.environ.get('MG_MERGE_CROSSING') == 'record': - merge_crossing = 'record' + # merge_crossing='record' keeps the partonic contribution: the crossed + # process is recorded on the base (not generated on its own) and reached + # through the base's crossing-aware SMATRIX. This is the DEFAULT for a + # crossing-enabled generation -> the standalone output is one directory + # per base ME, and the grouped backends (madevent/mg7) reconstruct the + # crossed subprocesses at output time (see do_output). A process that + # breaks crossing (s-channel constraint, decay chain, loop, ...) falls + # back to full generation per-process inside generate_matrix_elements. + # --use_crossing=False keeps the complete unmerged generation, and + # MG_MERGE_CROSSING=off is a debug escape hatch to the same. + merge_crossing = 'record' if use_crossing else False + if os.environ.get('MG_MERGE_CROSSING') == 'off': + merge_crossing = False # Check the validity of the arguments self.check_add(args) @@ -9980,7 +9984,18 @@ def generate_matrix_elements(self, group_processes=True): # regeneration). The normal grouping + crossing routing then # handles them exactly as an unmerged (merge_crossing=False) # generation would. - if any(amp.get('crossed_processes') for amp in non_dc_amps): + # The plain fortran 'standalone' exporter consumes the + # crossed_processes metadata directly -- it folds the crossings + # into the base directory and reaches them through the base's + # crossing-aware SMATRIX (see write_check_sa), so it 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). + if self._export_format != 'standalone' and \ + any(amp.get('crossed_processes') + for amp in non_dc_amps): if self.options['group_subprocesses'] == 'Auto': collect_mirror = True else: From a7c766c8bb24f8d2050baf1e94419da149d56955 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sat, 25 Jul 2026 22:49:37 +0200 Subject: [PATCH 42/43] test: partition test generates unmerged (merge_crossing='record' is now the default) TestCrossingPartition exercises partition_crossing_classes, which routes each subprocess flavor of the FULL (unmerged) matrix-element list to a base -- exactly what the madevent output reconstructs from the recorded crossings before grouping. With merge_crossing='record' now the default, a bare `generate` folds the crossings away at generation, so there was nothing left for the routing to eliminate. Force MG_MERGE_CROSSING=off around the generation so the test sees the unmerged modules. Co-Authored-By: Claude Opus 4.8 --- .../test_standalone_cross_symmetry.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/acceptance_tests/test_standalone_cross_symmetry.py b/tests/acceptance_tests/test_standalone_cross_symmetry.py index a436697e1..9b22a054e 100644 --- a/tests/acceptance_tests/test_standalone_cross_symmetry.py +++ b/tests/acceptance_tests/test_standalone_cross_symmetry.py @@ -1945,7 +1945,20 @@ def _groups(self, proc): cmd = cmd_interface.MasterCmd() cmd.run_cmd('import model sm') cmd.run_cmd('define j = g u u~') - cmd.run_cmd('generate %s' % proc) + # partition_crossing_classes operates on the FULL (unmerged) matrix-element + # list -- exactly what the madevent output reconstructs from the recorded + # crossings before grouping. Generate unmerged here so the routing has the + # crossed modules to eliminate (the default merge_crossing='record' would + # fold them away at generation, leaving nothing to route). + old = os.environ.get('MG_MERGE_CROSSING') + os.environ['MG_MERGE_CROSSING'] = 'off' + try: + cmd.run_cmd('generate %s' % proc) + finally: + if old is None: + os.environ.pop('MG_MERGE_CROSSING', None) + else: + os.environ['MG_MERGE_CROSSING'] = old groups = group_subprocs.SubProcessGroup.group_amplitudes( cmd._curr_amps, 'madevent') for g in groups: From c539500d1244c56f31d5e7a798959b50224a8484 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sat, 25 Jul 2026 23:53:20 +0200 Subject: [PATCH 43/43] crossing (check_sa): demo only the folded subprocesses, at their standalone RAMBO point The crossed-process demonstration in check_sa.f now shows exactly the crossings that are real subprocesses of the generation (folded in via merge_crossing='record'), each at the very momenta a standalone (non-crossed) run of that subprocess would evaluate -- so the crossed value is copy/paste-comparable with the subprocess's own check. - _crossed_signatures: match each folded crossed process LABEL-AWARE against the runtime-reachable PDG set, so a merged _quark leg matches any same-sign flavor and a flavor-changing (W) vertex pairs correctly (fixes the earlier same-flavor rep signature that no W crossing could reach). Returns representative signed-PDG signatures + a 'complete' flag; incomplete falls back to the full applicable-crossing loop. - _get_check_sa_crossing_example: emit those signatures into XCSIG and show a crossing only when GET_PDG_FOR_FLAVOR matches one; scan FLIP1/ FLIP2 over all legs (1..NEXTERNAL) so single-leg crossings are reached (the old NINCOMING+1.. range only hit double crossings). Print the base phase-space point P row k = P(:,k) with the crossed PDG XPDG(k): every shown crossing keeps the massive particles final and only relabels massless partons, so its per-slot mass pattern equals P and a standalone run draws the identical RAMBO point. - drop the now-unused APPLY_CROSSING/XPUSE/XNHIN/XICIN plumbing. Verified: p p > t t~ j and p p > w+ j demos reproduce the momenta and matrix element of standalone g d~ > t t~ d~ / d~ d > t t~ g / g d~ > w+ u~ runs row-for-row; crossing acceptance suite green. Co-Authored-By: Claude Opus 4.8 --- madgraph/iolibs/export_v4.py | 202 ++++++++++++++++++---- madgraph/iolibs/template_files/check_sa.f | 8 + 2 files changed, 178 insertions(+), 32 deletions(-) diff --git a/madgraph/iolibs/export_v4.py b/madgraph/iolibs/export_v4.py index 4177e9e00..caa1e0ccd 100755 --- a/madgraph/iolibs/export_v4.py +++ b/madgraph/iolibs/export_v4.py @@ -5722,23 +5722,99 @@ def write_matrix_element_v4(self, writer, matrix_element, fortran_model, #=========================================================================== # write_check_sa #=========================================================================== + 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 (whose python twin + is compute_crossing_pdg_entries). Matching on the PDG rather than the + extended index avoids the NFLAV-convention gap between the crossing-PDG + enumeration and the runtime flavor table. + + A recorded crossed process may carry merged multiparticle labels (e.g. + _quark = 81). Rather than resolve each such leg independently to one + flavor -- which would fabricate an unphysical signature for a + flavor-changing vertex, e.g. a W coupling two same-flavor quarks -- each + recorded process is matched LABEL-AWARE against the reachable set, which + already encodes the correct flavor pairings; the first reachable + instantiation is taken as the representative. Mirror pairs are collapsed + (the chosen signature's beam swap is also marked seen). 'complete' is + False only when a recorded process has NO reachable instantiation, so + the caller falls back to the full loop rather than hide a real + crossing.""" + crossed = matrix_element.get('crossed_processes') + 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? A merged + # label matches any member flavor of the same sign; a concrete + # particle matches only itself. + 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] + # signatures the runtime can actually reach (physical crossings) + reachable = [tuple(pdg) for (_i, _c, _f, pdg) in + self.compute_crossing_pdg_entries(matrix_element)] + sigs, seen, complete = [], set(), True + for (proc, _bp, _xp) in crossed: + legs = [l.get('id') for l in proc.get('legs')] + 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 in reachable: + 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: + complete = False + continue + 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), so the driver is - unchanged. Otherwise it loops over every way of crossing particle 1 and - particle 2 with a final-state particle, and for each flavor evaluates - the crossed matrix element and prints its signed PDGs and value. - - The crossing code is CROSS = FLIP1*(NEXTERNAL+1) + FLIP2 with FLIP1 the - partner of particle 1 and FLIP2 the partner of particle 2 -- matching + unchanged. 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, and - NFLAV is emitted as the literal matrix.f value so the encoding matches - exactly. Overlapping/degenerate crossings (e.g. FLIP1==FLIP2) are left - in: GET_PDG_FOR_FLAVOR reports all-zero and SMATRIX returns 0 for them, - which is itself an informative part of the demonstration. + j_part = CROSS mod (NEXTERNAL+1)); FLAV_IDX = CROSS*NFLAV + flav, with + NFLAV emitted as the literal matrix.f value so the encoding matches + exactly. Degenerate crossings (e.g. FLIP1==FLIP2) decode to all-zero + PDGs and are skipped by both the match and the fallback. """ use_crossing = self.opt.get('use_crossing', True) and \ not any(self.breaks_crossing_symmetry(proc) @@ -5754,38 +5830,100 @@ def _get_check_sa_crossing_example(self, matrix_element, proc_prefix): # directory of their own and this driver is the only place they are # exercised, so the demo is enabled to actually evaluate them. n_table, _ = self._build_flav_table_flat(matrix_element) - loop_gate = ('.true.' if matrix_element.get('crossed_processes') - else '.false.') + if not matrix_element.get('crossed_processes'): + # Nothing folded in: keep the dormant example (present but disabled). + loop_gate = '.false.' + else: + loop_gate = '.true.' + 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, + ] - sep = (' write (*,*) "-----------------------------------------' - '------------------------------------"') lines = [ ' if(%s) then' % loop_gate, ' write (*,*)', - ' write (*,*) " Crossing-symmetry examples (crossed processes):"', + ' write (*,*) " Crossed processes (folded into this matrix' + ' element):"', ' write (*,*)', ' NFLAV = %d' % n_table, - ' DO FLIP1=NINCOMING+1,NEXTERNAL', - ' DO FLIP2=NINCOMING+1,NEXTERNAL', + ] + 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', - 'C CROSS = (partner of particle 1)*(NEXTERNAL+1)', - 'C + (partner of particle 2)', ' I = FLIP1*(NEXTERNAL+1) + FLIP2', ' FLAV_IDX = I*NFLAV+J', ' CALL %sGET_PDG_FOR_FLAVOR(FLAV_IDX, XPDG)' % proc_prefix, - ' CALL %sSMATRIX(P, FLAV_IDX, MATELEM)' % proc_prefix, - ' write(*,*) "PARTICLE #1 crossed with particle #", FLIP1', - ' write(*,*) "PARTICLE #2 crossed with particle #", FLIP2', - ' write (*,*) "PDG", (XPDG(K),K=1,NEXTERNAL),' - " 'FLAV_IDX', FLAV_IDX", - ' write (*,*) "Matrix element = ", MATELEM,' - ' " GeV^",-(2*nexternal-8)', - sep, - ' ENDDO', - ' ENDDO', - ' ENDDO', - ' endif', ] + 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=''): diff --git a/madgraph/iolibs/template_files/check_sa.f b/madgraph/iolibs/template_files/check_sa.f index 6e95acfb5..431972850 100644 --- a/madgraph/iolibs/template_files/check_sa.f +++ b/madgraph/iolibs/template_files/check_sa.f @@ -47,6 +47,14 @@ PROGRAM DRIVER C combinations; used only by the crossing-symmetry demonstration below. INTEGER XPDG(NEXTERNAL) INTEGER FLIP1, FLIP2, NFLAV +C Per-leg loop index and the two match flags of the crossing demonstration. + INTEGER XCK + LOGICAL XCVALID, XCMATCH +C Representative signed-PDG signatures of the crossed subprocesses folded +C into this matrix element; a crossing is demonstrated when its runtime PDG +C (GET_PDG_FOR_FLAVOR) matches one of them. + INTEGER XCSIG(NEXTERNAL, (NEXTERNAL+1)*(NEXTERNAL+1)) + INTEGER XCNSIG, XCS C C EXTERNAL C