From cae3da848b41d3c160b3a01f54fc1fd0b17a598b Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Fri, 9 Oct 2026 06:24:02 +0200 Subject: [PATCH 1/5] Interference processes: [LIxtree=QCD] and [treextree] left/right syntax Two processes on one line, of which only the interference 2 Re(A_left A_right^*) is computed: generate g g > h > t t~ [LIxtree=QCD] g g > t t~ (loop-induced x tree) generate g g > h > t t~ [LIxtree=QCD] (right side implied) generate p p > z > e+ e- [treextree] p p > a > e+ e- (tree x tree) This automates the recipe of the wiki page LoopInducedTimesTree (UVtree copies of the vertices in a special model, BKGQCD order, five source patches) without exposing it. Syntax (madgraph_interface, master_interface, base_objects) - keyword case-insensitive; each side takes its own s-channels, exclusions and amplitude orders; squared-order constraints constrain the interference and go after ']' (refused on the right-hand side); both sides must have the same legs in the same order (right may be wider); LIxtree without right side = tree process of the same legs. - Process keys interference_mode / interference_process, carried by get_process_with_legs, printed back in the input syntax. Generation - A hidden coupling order INTERF (hierarchy 0, added to the model in memory, never printed) tags the right-hand amplitudes; INTERF^2==1 and INTERF as split order keep the left x right products only, also in the generated code. - LIxtree: the right-hand trees are generated per subprocess and stored as loop_UVCT_diagrams (UVCT_orders {INTERF:1}, unit coupling); the ref_orders of a Born-less loop process now include the UVCT amplitudes (without it MadLoop stops: "Could not find squared orders"). Subprocesses without surviving loops are dropped with their trees. - treextree: the right-hand diagrams are appended after the left-hand generation and the squared-order filter is applied to the union; HELAS copies the tag onto the amplitude orders; no crossing reuse. - treextree output limited to madevent and standalone_fortran (the C++ outputs have no squared split orders and would return |L+R|^2). Run card and systematics - interference processes get the existing interference defaults (dynamical_scale_choice 3, no systematics by default); - config_nqcd.inc: one alpha_s power for all channels, half the QCD order of the left x right products (QCD x EW u d > u d: 1, not 2 or 0); - systematics summary: no scale line and wrong-signed percentages for a negative total cross-section (bounds started at 0/maxsize). Validation (tests/acceptance_tests/test_loop_induced_x_tree.py) - g g > h > t t~ [LIxtree=QCD] at a fixed point: interference equal to the wiki recipe, loop^2 to [sqrvirt=QCD], tree^2 (helicity filter off) to the tree-level ME, to 10 digits; - u u~ Z x photon: equal to |Z+a|^2 - |Z|^2 - |a|^2 to 10 digits. madevent: mH=500 GeV LIxtree -0.957(8) pb vs wiki recipe -0.965(2) pb; p p > e+ e-, mll>200: treextree 0.0598(28) pb vs 0.0629(48) pb from the three squared samples. --- madgraph/core/base_objects.py | 76 +++- madgraph/core/diagram_generation.py | 147 +++++++- madgraph/core/helas_objects.py | 16 + madgraph/interface/madgraph_interface.py | 326 +++++++++++++++++- madgraph/interface/master_interface.py | 16 +- madgraph/iolibs/export_v4.py | 79 +++++ madgraph/loop/loop_base_objects.py | 10 + madgraph/loop/loop_diagram_generation.py | 64 +++- madgraph/loop/loop_helas_objects.py | 8 +- madgraph/various/banner.py | 7 +- madgraph/various/systematics.py | 55 +-- .../test_loop_induced_x_tree.py | 166 +++++++++ tests/unit_tests/core/test_base_objects.py | 20 +- .../unit_tests/core/test_tree_interference.py | 164 +++++++++ .../interface/test_interference_syntax.py | 247 +++++++++++++ .../loop/test_loop_induced_x_tree.py | 172 +++++++++ 16 files changed, 1527 insertions(+), 46 deletions(-) create mode 100644 tests/acceptance_tests/test_loop_induced_x_tree.py create mode 100644 tests/unit_tests/core/test_tree_interference.py create mode 100644 tests/unit_tests/interface/test_interference_syntax.py create mode 100644 tests/unit_tests/loop/test_loop_induced_x_tree.py diff --git a/madgraph/core/base_objects.py b/madgraph/core/base_objects.py index 94a0229c24..8f3b95edd0 100755 --- a/madgraph/core/base_objects.py +++ b/madgraph/core/base_objects.py @@ -34,6 +34,14 @@ logger = logging.getLogger('madgraph.base_objects') pjoin = os.path.join + +# Coupling order carried only by the right-hand (tree-level) amplitudes of an +# interference process ('left [LIxtree=QCD] right'): the squared-order +# constraint INTERF^2==1 then keeps the products of one left-hand and one +# right-hand amplitude, i.e. 2 Re(A_left A_right^*), and nothing else. It is +# added to the model in memory with hierarchy 0, so that WEIGHTED is +# unchanged, and is never printed in a process definition. +INTERFERENCE_ORDER = 'INTERF' if madgraph.ordering: set = misc.OrderedSet @@ -3738,6 +3746,13 @@ def default_setup(self): # contribution 'QCD=4 QED=0', the pure interference 'QCD=2 QED=2' and # the pure QED contribution of order 'QCD=0 QED=4'. self['split_orders'] = [] + # Interference of two amplitude sets, 2Re(A_left A_right*), written + # as 'left [LIxtree=ORDERS] right' (loop-induced times tree) or + # 'left [treextree] right'. 'interference_mode' is '' for an ordinary + # process; 'interference_process' is then the right-hand process, + # carrying its own orders, s-channel requirements and exclusions. + self['interference_mode'] = '' + self['interference_process'] = None def filter(self, name, value): """Filter for valid process property values.""" @@ -3827,6 +3842,14 @@ def filter(self, name, value): import madgraph.interface.madgraph_interface as mg if value not in mg.MadGraphCmd._valid_nlo_modes: raise self.PhysicsObjectError("%s is not a valid NLO_mode" % str(value)) + + if name == 'interference_mode': + if value not in ['', 'LIxtree', 'treextree']: + raise self.PhysicsObjectError("%s is not a valid interference_mode" % str(value)) + + if name == 'interference_process': + if value is not None and not isinstance(value, Process): + raise self.PhysicsObjectError("%s is not a valid Process" % str(value)) return True def has_multiparticle_label(self): @@ -3913,7 +3936,29 @@ def get_sorted_keys(self): 'forbidden_onsh_s_channels', 'forbidden_s_channels', 'forbidden_particles', 'is_decay_chain', 'decay_chains', 'legs_with_decays', 'perturbation_couplings', 'has_born', - 'NLO_mode', 'split_orders', 'born_sq_orders'] + 'NLO_mode', 'split_orders', 'born_sq_orders', + 'interference_mode', 'interference_process'] + + def get_interference_mode(self): + """'LIxtree', 'treextree' or '' (also for a process unpickled from + a version that did not know about interference processes).""" + + return dict.get(self, 'interference_mode', '') or '' + + def interference_string(self): + """The tail of the process syntax for an interference process: the + '[ treextree ]' bracket (the LIxtree one is printed with the + perturbation orders) followed by the right-hand process, each piece + ending with a space. Empty for an ordinary process.""" + + mode = self.get_interference_mode() + if not mode: + return '' + mystr = '[ treextree ] ' if mode == 'treextree' else '' + right = self['interference_process'] + if right is not None: + mystr += right.nice_string(prefix=False, print_weighted=False) + ' ' + return mystr def nice_string(self, indent=0, print_weighted=True, prefix=True, print_perturbated=True): """Returns a nicely formated string about current process @@ -3973,6 +4018,8 @@ def nice_string(self, indent=0, print_weighted=True, prefix=True, print_perturba for key in sorted(self['orders'].keys()): if not print_weighted and key == 'WEIGHTED': continue + if key == INTERFERENCE_ORDER: + continue value = int(self['orders'][key]) if key in self['squared_orders']: if self.get_squared_order_type(key) in ['<=', '==', '='] and \ @@ -4003,6 +4050,8 @@ def nice_string(self, indent=0, print_weighted=True, prefix=True, print_perturba if self['NLO_mode']!='tree': if self['NLO_mode']=='virt' and not self['has_born']: mystr = mystr + 'sqrvirt = ' + elif self.get_interference_mode() == 'LIxtree': + mystr = mystr + 'LIxtree = ' else: mystr = mystr + self['NLO_mode'] + ' = ' for order in sorted(self['perturbation_couplings']): @@ -4015,6 +4064,8 @@ def nice_string(self, indent=0, print_weighted=True, prefix=True, print_perturba for key in sorted(self['squared_orders'].keys()): if not print_weighted and key == 'WEIGHTED': continue + if key == INTERFERENCE_ORDER: + continue if key in self['constrained_orders']: if self['constrained_orders'][key][0] == self['squared_orders'][key]/2 and \ self['constrained_orders'][key][1] == self.get_squared_order_type(key): @@ -4047,6 +4098,8 @@ def nice_string(self, indent=0, print_weighted=True, prefix=True, print_perturba forbpart = self['model'].get('particle_dict')[forb_id] mystr = mystr + forbpart.get_name() + ' ' + mystr = mystr + self.interference_string() + # Remove last space mystr = mystr[:-1] @@ -4104,7 +4157,7 @@ def input_string(self): prevleg = leg if self['orders']: - keys = list(self['orders'].keys()) + keys = [k for k in self['orders'] if k != INTERFERENCE_ORDER] keys.sort(reverse=True) mystr = mystr + " ".join([key + '=' + repr(self['orders'][key]) \ for key in keys]) + ' ' @@ -4112,7 +4165,8 @@ def input_string(self): # Add squared orders if self['squared_orders']: mystr = mystr + " ".join([key + '^2=' + repr(self['squared_orders'][key]) \ - for key in self['squared_orders']]) + ' ' + for key in self['squared_orders'] + if key != INTERFERENCE_ORDER]) + ' ' # Add perturbation orders if self['perturbation_couplings']: @@ -4923,7 +4977,8 @@ def nice_string(self, indent=0, print_weighted=False, prefix=True): if self['orders']: mystr = mystr + " ".join([key + '=' + repr(self['orders'][key]) \ - for key in sorted(self['orders'])]) + ' ' + for key in sorted(self['orders']) + if key != INTERFERENCE_ORDER]) + ' ' if self['constrained_orders']: mystr = mystr + " ".join('%s%s%d' % (key, operator, value) for @@ -4936,6 +4991,8 @@ def nice_string(self, indent=0, print_weighted=False, prefix=True): if self['NLO_mode']!='tree': if self['NLO_mode']=='virt' and not self['has_born']: mystr = mystr + 'sqrvirt = ' + elif self.get_interference_mode() == 'LIxtree': + mystr = mystr + 'LIxtree = ' else: mystr = mystr + self['NLO_mode'] + ' = ' for order in self['perturbation_couplings']: @@ -4946,7 +5003,10 @@ def nice_string(self, indent=0, print_weighted=False, prefix=True): mystr = mystr + " ".join([key + '^2%s%d'%\ (self.get_squared_order_type(key),self['squared_orders'][key]) \ for key in self['squared_orders'].keys() \ - if print_weighted or key!='WEIGHTED']) + ' ' + if (print_weighted or key!='WEIGHTED') and \ + key != INTERFERENCE_ORDER]) + ' ' + + mystr = mystr + self.interference_string() # Remove last space mystr = mystr[:-1] @@ -4989,7 +5049,11 @@ def get_process_with_legs(self, LegList): 'overall_orders': self.get('overall_orders'), 'split_orders': self.get('split_orders'), 'born_sq_orders': self.get('born_sq_orders'), - 'NLO_mode': self.get('NLO_mode') + 'NLO_mode': self.get('NLO_mode'), + # the right-hand process stays the ProcessDefinition template: + # its legs are matched to these ones at generation time + 'interference_mode': self.get_interference_mode(), + 'interference_process': dict.get(self, 'interference_process') }) def get_process(self, initial_state_ids, final_state_ids): diff --git a/madgraph/core/diagram_generation.py b/madgraph/core/diagram_generation.py index fcd1653229..7726faa968 100755 --- a/madgraph/core/diagram_generation.py +++ b/madgraph/core/diagram_generation.py @@ -41,6 +41,87 @@ class NoDiagramException(InvalidCmd): pass +#=============================================================================== +# Interference of two amplitude sets: 'left [LIxtree=QCD] right' (loop-induced +# times tree, see loop_diagram_generation) and 'left [treextree] right' +#=============================================================================== +INTERFERENCE_ORDER = base_objects.INTERFERENCE_ORDER +# Output formats able to select squared split orders, hence to return the +# interference alone (the others would give |A_left + A_right|^2) +TREE_INTERFERENCE_FORMATS = ['madevent', 'standalone_fortran'] + +def register_interference_order(model): + """Add the hidden coupling order tagging the right-hand amplitudes of an + interference process to the model, in memory and idempotently. Its + hierarchy is 0, so that the WEIGHTED order of every diagram is unchanged, + and no interaction carries it.""" + + if INTERFERENCE_ORDER not in model.get('order_hierarchy'): + model.get('order_hierarchy')[INTERFERENCE_ORDER] = 0 + coupling_orders = model.get('coupling_orders') + if INTERFERENCE_ORDER not in coupling_orders: + coupling_orders.add(INTERFERENCE_ORDER) + +def prepare_interference_process(procdef): + """Complete the ProcessDefinition of an interference process + ('left [LIxtree=QCD] right' or 'left [treextree] right', see + MadGraphCmd.extract_interference_process) before its generation: + + - the hidden order INTERFERENCE_ORDER is registered in the model; + - the left-hand process gets the squared-order constraint INTERF^2==1 + and INTERF as split order, so that only the products of a left-hand + amplitude with a right-hand one are kept, also in the generated code; + - the right-hand process gets, when none is given, the minimal WEIGHTED + order exactly as 'generate ' would. + + The right-hand diagrams themselves are generated per subprocess, with the + legs of that subprocess: Amplitude.add_interference_diagrams (treextree) + and LoopAmplitude.set_interference_trees (LIxtree).""" + + mode = procdef.get_interference_mode() + if not mode: + return + if any(value < 0 for value in procdef.get('squared_orders').values()): + raise InvalidCmd("Negative squared-order constraints are not " + "supported for %s interference processes." % mode) + model = procdef.get('model') + register_interference_order(model) + + squared_orders = dict(procdef.get('squared_orders')) + squared_orders[INTERFERENCE_ORDER] = 1 + procdef.set('squared_orders', squared_orders) + sqorders_types = dict(procdef.get('sqorders_types')) + sqorders_types[INTERFERENCE_ORDER] = '==' + procdef.set('sqorders_types', sqorders_types) + if INTERFERENCE_ORDER not in procdef.get('split_orders'): + procdef.set('split_orders', + list(procdef.get('split_orders')) + [INTERFERENCE_ORDER]) + + right = procdef.get('interference_process') + if not right.get('orders') and not right.get('born_sq_orders'): + right.set('orders', MultiProcess.find_optimal_process_orders(right)) + right.check_expansion_orders() + +def specialise_interference_process(process): + """The right-hand process of an interference subprocess, with the + external legs of that subprocess. It is also stored back in the + subprocess, which then shows the tree process it interferes with.""" + + right = process.get('interference_process') + legs = base_objects.LegList([copy.copy(leg) for leg in process.get('legs')]) + if isinstance(right, base_objects.ProcessDefinition): + right_process = right.get_process_with_legs(legs) + else: + # already specialised to these legs (regeneration) + right_process = copy.copy(right) + right_process.set('legs', legs) + right_process.set('interference_mode', '') + right_process.set('interference_process', None) + # the process number is the one of the whole (left-hand) definition + right_process.set('id', 0) + process.set('interference_process', right_process) + return right_process + #=============================================================================== # DiagramTag mother class #=============================================================================== @@ -1267,7 +1348,17 @@ def generate_diagrams(self, returndiag=False, diagram_filter=False): # in this way. We shall do this only if the diagrams are not asked to # be returned, as it is the case for NLO because it this case the # interference are not necessarily among the diagrams generated here only. - if not returndiag and len(res)>0: + # 'left [treextree] right': the squared-order constraints are about + # the products with the right-hand diagrams, added at the end (see + # add_interference_diagrams); only the amplitude-level constraints of + # this (left-hand) process apply here + treextree = not returndiag and \ + process.get_interference_mode() == 'treextree' + if treextree: + for name, (value, operator) in \ + process.get('constrained_orders').items(): + res.filter_constrained_orders(name, value, operator) + elif not returndiag and len(res)>0: res = self.apply_squared_order_constraints(res) if diagram_filter: @@ -1300,6 +1391,9 @@ def generate_diagrams(self, returndiag=False, diagram_filter=False): pertur = sorted(self.get('process')['perturbation_couplings'])[0] self.get('process').get('legs').sort(pert=pertur) + if treextree: + res = self.add_interference_diagrams(res) + # Set diagrams to res if not asked to be returned if not returndiag: self['diagrams'] = res @@ -1307,6 +1401,52 @@ def generate_diagrams(self, returndiag=False, diagram_filter=False): else: return not failed_crossing, res + def add_interference_diagrams(self, left_diagrams): + """For an interference process 'left [treextree] right', add to the + diagrams of this (left-hand) process those of the right-hand process + with the same external legs, each tagged with one unit of the hidden + order INTERFERENCE_ORDER, and apply the squared-order constraints of + the process to the whole list: with INTERF^2==1 (see + prepare_interference_process) only the products of a left-hand and a + right-hand diagram, 2 Re(A_left A_right^*), contribute. All diagrams of + both sides are kept as integration channels. Returns the new diagram + list, empty if either side has no diagram.""" + + process = self.get('process') + register_interference_order(process.get('model')) + right_process = specialise_interference_process(process) + + right_diagrams = base_objects.DiagramList() + if left_diagrams: + try: + right_diagrams = Amplitude(right_process).get('diagrams') + except InvalidCmd: + pass + if not left_diagrams or not right_diagrams: + logger.info("No diagram for %s: no interference with %s." % ( + (right_process if left_diagrams else process).nice_string( + prefix=False), + (process if left_diagrams else right_process).nice_string( + prefix=False))) + return base_objects.DiagramList() + + for diagram in right_diagrams: + orders = dict(diagram.get('orders')) + orders[INTERFERENCE_ORDER] = 1 + diagram.set('orders', orders) + + res = base_objects.DiagramList(list(left_diagrams) + list(right_diagrams)) + while True: + new_res = res.apply_positive_sq_orders(res, + process.get('squared_orders'), + process.get('sqorders_types')) + if len(new_res) == len(res): + break + res = new_res + logger.info("Interference of %d with %d diagrams (%d contributing)" % + (len(left_diagrams), len(right_diagrams), len(res))) + return res + def apply_squared_order_constraints(self, diag_list): """Applies the user specified squared order constraints on the diagram list in argument.""" @@ -2683,10 +2823,13 @@ def get_fs_flavor(id, fsleg): # Check for successful crossings, unless we have specified # properties that break crossing symmetry + # (an interference process carries a right-hand process with + # its own s-channel constraints: no reuse by crossing either) if not process.get('required_s_channels') and \ not process.get('forbidden_onsh_s_channels') and \ not process.get('forbidden_s_channels') and \ - not process.get('is_decay_chain') and not diagram_filter: + not process.get('is_decay_chain') and not diagram_filter \ + and not process.get_interference_mode(): try: crossed_index = success_procs.index(sorted_legs) # The relabeling of legs for loop amplitudes is cumbersome diff --git a/madgraph/core/helas_objects.py b/madgraph/core/helas_objects.py index 3d8bb1daaa..2cc22d46ed 100755 --- a/madgraph/core/helas_objects.py +++ b/madgraph/core/helas_objects.py @@ -4471,6 +4471,22 @@ def generate_helas_diagrams(self, amplitude, optimization=1,decay_ids=[]): # Add amplitude to amplitdes in helas_diagram helas_diagram.get('amplitudes').append(amp) + # A right-hand diagram of an interference process + # ('left [treextree] right') carries the hidden order tagging it, + # which no interaction has: pass it on to its amplitudes (on a + # copy, their orders being those of the interaction), so that the + # split orders of the generated code see it. + interference_tag = diagram.get('orders').get( + base_objects.INTERFERENCE_ORDER, 0) \ + if diagram.get('orders') else 0 + if interference_tag: + for amp in helas_diagram.get('amplitudes'): + orders = dict(amp.get('orders')) + orders[base_objects.INTERFERENCE_ORDER] = \ + orders.get(base_objects.INTERFERENCE_ORDER, 0) + \ + interference_tag + amp.set('orders', orders) + # After generation of all wavefunctions and amplitudes, # first sort the wavefunctions according to number diagram_wavefunctions.sort(key=lambda wf:wf.get('number')) diff --git a/madgraph/interface/madgraph_interface.py b/madgraph/interface/madgraph_interface.py index 42c481a734..b6ad789886 100755 --- a/madgraph/interface/madgraph_interface.py +++ b/madgraph/interface/madgraph_interface.py @@ -144,6 +144,113 @@ class DuplicateParticle(madgraph.InvalidCmd): same misleading message. """ +#=============================================================================== +# Interference of two amplitude sets: 'left [LIxtree=QCD] right' and +# 'left [treextree] right' +#=============================================================================== +# The bracket of an interference process. Its keyword is matched without +# regard to case, so that [LIxtree=QCD], [lixtree=QCD] and [TreeXTree] are all +# accepted; INTERFERENCE_MODES maps the lower-case spelling on the one stored +# in Process['interference_mode']. +INTERFERENCE_MODES = {'lixtree': 'LIxtree', 'treextree': 'treextree'} +_INTERFERENCE_BRACKET = re.compile( + r"\[\s*(?:(?P