From d3cff95f1842753a3195a388177df97495e9d78a Mon Sep 17 00:00:00 2001 From: Theo Heimel Date: Mon, 15 Jun 2026 17:47:44 +0200 Subject: [PATCH 01/81] started implementing mlm clustering --- madspace/CMakeLists.txt | 2 + .../compgraphs/function_builder_mixin.inc | 5 + .../madspace/compgraphs/opcode_mixin.inc | 97 ++++++++++--------- madspace/include/madspace/compgraphs/type.hpp | 3 + .../madspace/phasespace/mlm_clustering.hpp | 24 +++++ madspace/instruction_set.yaml | 25 +++++ .../src/compgraphs/instruction_set_mixin.inc | 97 ++++++++++--------- madspace/src/cpu/runtime_backward_mixin.inc | 42 ++++---- madspace/src/cpu/runtime_mixin.inc | 97 ++++++++++--------- madspace/src/driver/lhe_output.cpp | 2 + madspace/src/gpu/runtime_backward_mixin.inc | 42 ++++---- madspace/src/gpu/runtime_mixin.inc | 97 ++++++++++--------- madspace/src/phasespace/mlm_clustering.cpp | 83 ++++++++++++++++ madspace/src/python/instruction_set.hpp | 1 + 14 files changed, 385 insertions(+), 232 deletions(-) create mode 100644 madspace/include/madspace/phasespace/mlm_clustering.hpp create mode 100644 madspace/src/phasespace/mlm_clustering.cpp diff --git a/madspace/CMakeLists.txt b/madspace/CMakeLists.txt index 1e7eb0adba..6a9de9c0b4 100644 --- a/madspace/CMakeLists.txt +++ b/madspace/CMakeLists.txt @@ -158,6 +158,7 @@ add_library( src/phasespace/channel_weight_network.cpp src/phasespace/pdf.cpp src/phasespace/matrix_element.cpp + src/phasespace/mlm_clustering.cpp src/phasespace/cross_section.cpp src/phasespace/scale.cpp include/madspace/util.hpp @@ -213,6 +214,7 @@ add_library( include/madspace/phasespace/channel_weight_network.hpp include/madspace/phasespace/pdf.hpp include/madspace/phasespace/matrix_element.hpp + include/madspace/phasespace/mlm_clustering.hpp include/madspace/phasespace/cross_section.hpp include/madspace/phasespace/scale.hpp ) diff --git a/madspace/include/madspace/compgraphs/function_builder_mixin.inc b/madspace/include/madspace/compgraphs/function_builder_mixin.inc index 94dbc20fbe..392d15e162 100644 --- a/madspace/include/madspace/compgraphs/function_builder_mixin.inc +++ b/madspace/include/madspace/compgraphs/function_builder_mixin.inc @@ -461,6 +461,11 @@ Value scale_partonic_energy(Value momenta) { return instruction("scale_partonic_energy", {momenta})[0]; } +std::array mlm_clustering_hadronic(Value momenta, Value state_machine) { + auto output_vector = instruction("mlm_clustering_hadronic", {momenta, state_machine}); + return {output_vector[0], output_vector[1], output_vector[2], output_vector[3], output_vector[4]}; +} + std::array chili_forward(Value r, Value e_cm, Value m_out, Value pt_min, Value y_max) { auto output_vector = instruction("chili_forward", {r, e_cm, m_out, pt_min, y_max}); return {output_vector[0], output_vector[1]}; diff --git a/madspace/include/madspace/compgraphs/opcode_mixin.inc b/madspace/include/madspace/compgraphs/opcode_mixin.inc index 079c51b9dd..4d5bc962a4 100644 --- a/madspace/include/madspace/compgraphs/opcode_mixin.inc +++ b/madspace/include/madspace/compgraphs/opcode_mixin.inc @@ -102,51 +102,52 @@ scale_transverse_energy = 100, scale_transverse_mass = 101, scale_half_transverse_mass = 102, scale_partonic_energy = 103, -chili_forward = 104, -chili_inverse = 105, -matrix_element = 106, -collect_channel_weights = 107, -interpolate_pdf = 108, -interpolate_alpha_s = 109, -matmul = 110, -relu = 111, -leaky_relu = 112, -elu = 113, -gelu = 114, -sigmoid = 115, -softplus = 116, -rqs_reshape = 117, -rqs_find_bin = 118, -rqs_forward = 119, -rqs_inverse = 120, -softmax = 121, -softmax_prior = 122, -sample_discrete = 123, -sample_discrete_inverse = 124, -sample_discrete_probs = 125, -sample_discrete_probs_inverse = 126, -discrete_histogram = 127, -permute_momenta = 128, -gather = 129, -gather_int = 130, -select_int = 131, -select = 132, -select_vector = 133, -argsort = 134, -quantile = 135, -one_hot = 136, -madnis_abs_weight = 137, -madnis_softclip = 138, -madnis_variance = 139, -madnis_single_channel_variance = 140, -madnis_multi_channel_variance = 141, -nonzero = 142, -batch_gather = 143, -batch_scatter = 144, -random = 145, -random_int = 146, -unweight = 147, -vegas_forward = 148, -vegas_inverse = 149, -vegas_histogram = 150, -histogram = 151 +mlm_clustering_hadronic = 104, +chili_forward = 105, +chili_inverse = 106, +matrix_element = 107, +collect_channel_weights = 108, +interpolate_pdf = 109, +interpolate_alpha_s = 110, +matmul = 111, +relu = 112, +leaky_relu = 113, +elu = 114, +gelu = 115, +sigmoid = 116, +softplus = 117, +rqs_reshape = 118, +rqs_find_bin = 119, +rqs_forward = 120, +rqs_inverse = 121, +softmax = 122, +softmax_prior = 123, +sample_discrete = 124, +sample_discrete_inverse = 125, +sample_discrete_probs = 126, +sample_discrete_probs_inverse = 127, +discrete_histogram = 128, +permute_momenta = 129, +gather = 130, +gather_int = 131, +select_int = 132, +select = 133, +select_vector = 134, +argsort = 135, +quantile = 136, +one_hot = 137, +madnis_abs_weight = 138, +madnis_softclip = 139, +madnis_variance = 140, +madnis_single_channel_variance = 141, +madnis_multi_channel_variance = 142, +nonzero = 143, +batch_gather = 144, +batch_scatter = 145, +random = 146, +random_int = 147, +unweight = 148, +vegas_forward = 149, +vegas_inverse = 150, +vegas_histogram = 151, +histogram = 152 diff --git a/madspace/include/madspace/compgraphs/type.hpp b/madspace/include/madspace/compgraphs/type.hpp index f0d8ada0bd..787e20b219 100644 --- a/madspace/include/madspace/compgraphs/type.hpp +++ b/madspace/include/madspace/compgraphs/type.hpp @@ -118,6 +118,9 @@ const Type batch_four_vec{DataType::dt_float, batch_size, {4}}; inline Type batch_float_array(int count) { return {DataType::dt_float, batch_size, {count}}; } +inline Type batch_int_array(int count) { + return {DataType::dt_int, batch_size, {count}}; +} inline Type batch_four_vec_array(int count) { return {DataType::dt_float, batch_size, {count, 4}}; } diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp new file mode 100644 index 0000000000..a21377a387 --- /dev/null +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "madspace/phasespace/base.hpp" +#include "madspace/phasespace/topology.hpp" + +namespace madspace { + +class MLMClustering : public FunctionGenerator { +public: + MLMClustering( + std::vector topologies, + nested_vector3 permutations, + nested_vector2 diagram_indices + ); + +private: + NamedVector build_function_impl( + FunctionBuilder& fb, const NamedVector& args + ) const override; + + std::vector _cluster_state_machine; +}; + +} // namespace madspace diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index ceeedf7051..de664a7b06 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -1947,6 +1947,31 @@ scale_partonic_energy: type: [float] desc: +mlm_clustering_hadronic: + inputs: + - name: momenta + type: [float, n, 4] + desc: + - name: state_machine + type: [int, m] + desc: + outputs: + - name: ren_scale + type: [float] + desc: + - name: fact_scale1 + type: [float] + desc: + - name: fact_scale2 + type: [float] + desc: + - name: cluster_history + type: [int, n-3] + desc: + - name: cluster_scales + type: [float, n-3] + desc: + --- title: Chili diff --git a/madspace/src/compgraphs/instruction_set_mixin.inc b/madspace/src/compgraphs/instruction_set_mixin.inc index 955d72bd25..0c1edccae6 100644 --- a/madspace/src/compgraphs/instruction_set_mixin.inc +++ b/madspace/src/compgraphs/instruction_set_mixin.inc @@ -119,52 +119,53 @@ InstructionOwner instructions[] { mi("scale_transverse_mass", 101, true, {{DataType::dt_float, false, {"n", 4}, false}}, {{DataType::dt_float, false, {}, false}}), mi("scale_half_transverse_mass", 102, true, {{DataType::dt_float, false, {"n", 4}, false}}, {{DataType::dt_float, false, {}, false}}), mi("scale_partonic_energy", 103, true, {{DataType::dt_float, false, {"n", 4}, false}}, {{DataType::dt_float, false, {}, false}}), - mi("chili_forward", 104, true, {{DataType::dt_float, false, {"3n-2"}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {"n+2", 4}, false}, {DataType::dt_float, false, {}, false}}), - mi("chili_inverse", 105, true, {{DataType::dt_float, false, {"n+2", 4}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {"3n-2"}, false}, {DataType::dt_float, false, {}, false}}), - InstructionOwner(new MatrixElementInstruction(106, true)), - mi("collect_channel_weights", 107, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_int, true, {"n"}, false}, {DataType::dt_int, true, {"c"}, true}}, {{DataType::dt_float, false, {"c"}, false}}), - mi("interpolate_pdf", 108, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, false, {"n"}, false}, {DataType::dt_float, true, {"a"}, false}, {DataType::dt_float, true, {"b"}, false}, {DataType::dt_float, true, {16, "c", "d"}, false}}, {{DataType::dt_float, false, {"n"}, false}}), - mi("interpolate_alpha_s", 109, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, true, {"b+1"}, false}, {DataType::dt_float, true, {4, "b"}, false}}, {{DataType::dt_float, false, {}, false}}), - mi("matmul", 110, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, true, {"m", "n"}, false}, {DataType::dt_float, true, {"m"}, false}}, {{DataType::dt_float, false, {"m"}, false}}), - mi("relu", 111, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), - mi("leaky_relu", 112, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), - mi("elu", 113, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), - mi("gelu", 114, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), - mi("sigmoid", 115, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), - mi("softplus", 116, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), - InstructionOwner(new RqsReshapeInstruction(117, true)), - mi("rqs_find_bin", 118, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n", "b"}, false}, {DataType::dt_float, false, {"n", "b"}, false}, {DataType::dt_float, false, {"n", "b+1"}, false}}, {{DataType::dt_float, false, {"n", 6}, false}}), - mi("rqs_forward", 119, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n", 6}, false}}, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}), - mi("rqs_inverse", 120, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n", 6}, false}}, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}), - mi("softmax", 121, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), - mi("softmax_prior", 122, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {"n"}, false}}), - mi("sample_discrete", 123, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {}, false}}, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {}, false}}), - mi("sample_discrete_inverse", 124, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_int, true, {}, false}}, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}), - mi("sample_discrete_probs", 125, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {}, false}}), - mi("sample_discrete_probs_inverse", 126, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}), - mi("discrete_histogram", 127, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {"n"}, true}}, {{DataType::dt_float, true, {"n"}, false}, {DataType::dt_int, true, {"n"}, false}}), - mi("permute_momenta", 128, true, {{DataType::dt_float, false, {"n", 4}, false}, {DataType::dt_int, true, {"m", "n"}, false}, {DataType::dt_int, false, {}, false}}, {{DataType::dt_float, false, {"n", 4}, false}}), - mi("gather", 129, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {}, false}}), - mi("gather_int", 130, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_int, false, {"n"}, false}}, {{DataType::dt_int, false, {}, false}}), - mi("select_int", 131, true, {{DataType::dt_int, false, {"n"}, false}, {DataType::dt_int, false, {"m"}, false}}, {{DataType::dt_int, false, {"m"}, false}}), - mi("select", 132, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_int, false, {"m"}, false}}, {{DataType::dt_float, false, {"m"}, false}}), - mi("select_vector", 133, true, {{DataType::dt_float, false, {"n", "k"}, false}, {DataType::dt_int, false, {"m"}, false}}, {{DataType::dt_float, false, {"m", "k"}, false}}), - mi("argsort", 134, true, {{DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_int, false, {"n"}, false}}), - mi("quantile", 135, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, true, {}, false}}, {{DataType::dt_float, true, {}, false}}), - mi("one_hot", 136, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_int, true, {"n"}, true}}, {{DataType::dt_float, false, {"n"}, false}}), - mi("madnis_abs_weight", 137, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}, {{DataType::dt_float, false, {}, false}}), - mi("madnis_softclip", 138, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}, {{DataType::dt_float, false, {}, false}}), - mi("madnis_variance", 139, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}, {{DataType::dt_float, false, {}, false}}), - mi("madnis_single_channel_variance", 140, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}, {{DataType::dt_float, false, {}, false}}), - mi("madnis_multi_channel_variance", 141, true, {{DataType::dt_float, false, {"c"}, false}, {DataType::dt_float, false, {"c"}, false}}, {{DataType::dt_float, false, {}, false}}), - InstructionOwner(new NonzeroInstruction(142, true)), - InstructionOwner(new BatchGatherInstruction(143, true)), - InstructionOwner(new BatchScatterInstruction(144, true)), - InstructionOwner(new RandomInstruction(145, true)), - InstructionOwner(new RandomIntInstruction(146, true)), - InstructionOwner(new UnweightInstruction(147, true)), - mi("vegas_forward", 148, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, true, {"n", "b"}, false}}, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}), - mi("vegas_inverse", 149, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, true, {"n", "b"}, false}}, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}), - mi("vegas_histogram", 150, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {"b"}, true}}, {{DataType::dt_float, true, {"n", "b"}, false}, {DataType::dt_int, true, {"n", "b"}, false}}), - mi("histogram", 151, true, {{DataType::dt_float, false, {std::monostate{}}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {"b"}, true}}, {{DataType::dt_float, true, {"b+2"}, false}, {DataType::dt_float, true, {"b+2"}, false}}), + mi("mlm_clustering_hadronic", 104, true, {{DataType::dt_float, false, {"n", 4}, false}, {DataType::dt_int, false, {"m"}, false}}, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, false, {"n-3"}, false}, {DataType::dt_float, false, {"n-3"}, false}}), + mi("chili_forward", 105, true, {{DataType::dt_float, false, {"3n-2"}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {"n+2", 4}, false}, {DataType::dt_float, false, {}, false}}), + mi("chili_inverse", 106, true, {{DataType::dt_float, false, {"n+2", 4}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {"3n-2"}, false}, {DataType::dt_float, false, {}, false}}), + InstructionOwner(new MatrixElementInstruction(107, true)), + mi("collect_channel_weights", 108, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_int, true, {"n"}, false}, {DataType::dt_int, true, {"c"}, true}}, {{DataType::dt_float, false, {"c"}, false}}), + mi("interpolate_pdf", 109, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, false, {"n"}, false}, {DataType::dt_float, true, {"a"}, false}, {DataType::dt_float, true, {"b"}, false}, {DataType::dt_float, true, {16, "c", "d"}, false}}, {{DataType::dt_float, false, {"n"}, false}}), + mi("interpolate_alpha_s", 110, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, true, {"b+1"}, false}, {DataType::dt_float, true, {4, "b"}, false}}, {{DataType::dt_float, false, {}, false}}), + mi("matmul", 111, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, true, {"m", "n"}, false}, {DataType::dt_float, true, {"m"}, false}}, {{DataType::dt_float, false, {"m"}, false}}), + mi("relu", 112, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), + mi("leaky_relu", 113, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), + mi("elu", 114, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), + mi("gelu", 115, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), + mi("sigmoid", 116, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), + mi("softplus", 117, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), + InstructionOwner(new RqsReshapeInstruction(118, true)), + mi("rqs_find_bin", 119, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n", "b"}, false}, {DataType::dt_float, false, {"n", "b"}, false}, {DataType::dt_float, false, {"n", "b+1"}, false}}, {{DataType::dt_float, false, {"n", 6}, false}}), + mi("rqs_forward", 120, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n", 6}, false}}, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}), + mi("rqs_inverse", 121, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n", 6}, false}}, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}), + mi("softmax", 122, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), + mi("softmax_prior", 123, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {"n"}, false}}), + mi("sample_discrete", 124, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {}, false}}, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {}, false}}), + mi("sample_discrete_inverse", 125, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_int, true, {}, false}}, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}), + mi("sample_discrete_probs", 126, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {}, false}}), + mi("sample_discrete_probs_inverse", 127, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}), + mi("discrete_histogram", 128, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {"n"}, true}}, {{DataType::dt_float, true, {"n"}, false}, {DataType::dt_int, true, {"n"}, false}}), + mi("permute_momenta", 129, true, {{DataType::dt_float, false, {"n", 4}, false}, {DataType::dt_int, true, {"m", "n"}, false}, {DataType::dt_int, false, {}, false}}, {{DataType::dt_float, false, {"n", 4}, false}}), + mi("gather", 130, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {}, false}}), + mi("gather_int", 131, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_int, false, {"n"}, false}}, {{DataType::dt_int, false, {}, false}}), + mi("select_int", 132, true, {{DataType::dt_int, false, {"n"}, false}, {DataType::dt_int, false, {"m"}, false}}, {{DataType::dt_int, false, {"m"}, false}}), + mi("select", 133, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_int, false, {"m"}, false}}, {{DataType::dt_float, false, {"m"}, false}}), + mi("select_vector", 134, true, {{DataType::dt_float, false, {"n", "k"}, false}, {DataType::dt_int, false, {"m"}, false}}, {{DataType::dt_float, false, {"m", "k"}, false}}), + mi("argsort", 135, true, {{DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_int, false, {"n"}, false}}), + mi("quantile", 136, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, true, {}, false}}, {{DataType::dt_float, true, {}, false}}), + mi("one_hot", 137, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_int, true, {"n"}, true}}, {{DataType::dt_float, false, {"n"}, false}}), + mi("madnis_abs_weight", 138, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}, {{DataType::dt_float, false, {}, false}}), + mi("madnis_softclip", 139, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}, {{DataType::dt_float, false, {}, false}}), + mi("madnis_variance", 140, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}, {{DataType::dt_float, false, {}, false}}), + mi("madnis_single_channel_variance", 141, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}, {{DataType::dt_float, false, {}, false}}), + mi("madnis_multi_channel_variance", 142, true, {{DataType::dt_float, false, {"c"}, false}, {DataType::dt_float, false, {"c"}, false}}, {{DataType::dt_float, false, {}, false}}), + InstructionOwner(new NonzeroInstruction(143, true)), + InstructionOwner(new BatchGatherInstruction(144, true)), + InstructionOwner(new BatchScatterInstruction(145, true)), + InstructionOwner(new RandomInstruction(146, true)), + InstructionOwner(new RandomIntInstruction(147, true)), + InstructionOwner(new UnweightInstruction(148, true)), + mi("vegas_forward", 149, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, true, {"n", "b"}, false}}, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}), + mi("vegas_inverse", 150, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, true, {"n", "b"}, false}}, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}), + mi("vegas_histogram", 151, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {"b"}, true}}, {{DataType::dt_float, true, {"n", "b"}, false}, {DataType::dt_int, true, {"n", "b"}, false}}), + mi("histogram", 152, true, {{DataType::dt_float, false, {std::monostate{}}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {"b"}, true}}, {{DataType::dt_float, true, {"b+2"}, false}, {DataType::dt_float, true, {"b+2"}, false}}), }; diff --git a/madspace/src/cpu/runtime_backward_mixin.inc b/madspace/src/cpu/runtime_backward_mixin.inc index 5dd193731f..01f0b0e0a9 100644 --- a/madspace/src/cpu/runtime_backward_mixin.inc +++ b/madspace/src/cpu/runtime_backward_mixin.inc @@ -52,66 +52,66 @@ case 24: case 25: backward_batch_foreach, backward_kernel_square, 2, 1, DeviceType>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 110: +case 111: backward_op_matmul(instr, locals, local_grads, device); break; -case 111: +case 112: backward_batch_foreach, backward_kernel_relu, 2, 1, DeviceType>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 112: +case 113: backward_batch_foreach, backward_kernel_leaky_relu, 2, 1, DeviceType>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 113: +case 114: backward_batch_foreach, backward_kernel_elu, 2, 1, DeviceType>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 114: +case 115: backward_batch_foreach, backward_kernel_gelu, 2, 1, DeviceType>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 115: +case 116: backward_batch_foreach, backward_kernel_sigmoid, 2, 1, DeviceType>, 1, 1, 0, 1>(instr, locals, local_grads, {}, {0}, device); break; -case 116: +case 117: backward_batch_foreach, backward_kernel_softplus, 2, 1, DeviceType>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 117: +case 118: backward_op_rqs_reshape(instr, locals, local_grads, device); break; -case 118: +case 119: backward_batch_foreach, backward_kernel_rqs_find_bin, 5, 4, 2, DeviceType>, 4, 1, 4, 0>(instr, locals, local_grads, {0,1,2,3}, {}, device); break; -case 119: +case 120: backward_batch_foreach, backward_kernel_rqs_forward, 4, 2, 2, DeviceType>, 2, 2, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; -case 120: +case 121: backward_batch_foreach, backward_kernel_rqs_inverse, 4, 2, 2, DeviceType>, 2, 2, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; -case 121: +case 122: backward_batch_foreach, backward_kernel_softmax, 2, 1, DeviceType>, 1, 1, 0, 1>(instr, locals, local_grads, {}, {0}, device); break; -case 122: +case 123: backward_batch_foreach, backward_kernel_softmax_prior, 2, 2, 1, DeviceType>, 2, 1, 0, 1>(instr, locals, local_grads, {}, {0}, device); break; -case 126: +case 127: backward_batch_foreach, backward_kernel_sample_discrete_probs_inverse, 4, 2, 1, DeviceType>, 2, 2, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; -case 129: +case 130: backward_batch_foreach, backward_kernel_gather, 2, 2, 1, DeviceType>, 2, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 132: +case 133: backward_batch_foreach, backward_kernel_select, 2, 2, 1, DeviceType>, 2, 1, 1, 0>(instr, locals, local_grads, {1}, {}, device); break; -case 137: +case 138: backward_batch_foreach, backward_kernel_madnis_abs_weight, 3, 2, 1, DeviceType>, 2, 1, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; -case 138: +case 139: backward_batch_foreach, backward_kernel_madnis_softclip, 5, 4, 1, DeviceType>, 4, 1, 4, 0>(instr, locals, local_grads, {0,1,2,3}, {}, device); break; -case 139: +case 140: backward_batch_foreach, backward_kernel_madnis_variance, 5, 4, 1, DeviceType>, 4, 1, 4, 0>(instr, locals, local_grads, {0,1,2,3}, {}, device); break; -case 140: +case 141: backward_batch_foreach, backward_kernel_madnis_single_channel_variance, 2, 2, 1, DeviceType>, 2, 1, 1, 0>(instr, locals, local_grads, {1}, {}, device); break; -case 141: +case 142: backward_batch_foreach, backward_kernel_madnis_multi_channel_variance, 3, 2, 1, DeviceType>, 2, 1, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; diff --git a/madspace/src/cpu/runtime_mixin.inc b/madspace/src/cpu/runtime_mixin.inc index 3e3cc2400b..3e067702c5 100644 --- a/madspace/src/cpu/runtime_mixin.inc +++ b/madspace/src/cpu/runtime_mixin.inc @@ -314,146 +314,149 @@ case 103: batch_foreach, kernel_scale_partonic_energy, 1, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 104: - batch_foreach, kernel_chili_forward, 5, 2, 1, DeviceType>, 5, 2>(instr, locals, device); + batch_foreach, kernel_mlm_clustering_hadronic, 2, 5, 1, DeviceType>, 2, 5>(instr, locals, device); break; case 105: - batch_foreach, kernel_chili_inverse, 5, 2, 1, DeviceType>, 5, 2>(instr, locals, device); + batch_foreach, kernel_chili_forward, 5, 2, 1, DeviceType>, 5, 2>(instr, locals, device); break; case 106: - op_matrix_element(instr, locals, device); + batch_foreach, kernel_chili_inverse, 5, 2, 1, DeviceType>, 5, 2>(instr, locals, device); break; case 107: - batch_foreach, kernel_collect_channel_weights, 3, 1, 1, DeviceType>, 3, 1>(instr, locals, device); + op_matrix_element(instr, locals, device); break; case 108: - batch_foreach, kernel_interpolate_pdf, 6, 1, 1, DeviceType>, 6, 1>(instr, locals, device); + batch_foreach, kernel_collect_channel_weights, 3, 1, 1, DeviceType>, 3, 1>(instr, locals, device); break; case 109: - batch_foreach, kernel_interpolate_alpha_s, 3, 1, 1, DeviceType>, 3, 1>(instr, locals, device); + batch_foreach, kernel_interpolate_pdf, 6, 1, 1, DeviceType>, 6, 1>(instr, locals, device); break; case 110: - op_matmul(instr, locals, device); + batch_foreach, kernel_interpolate_alpha_s, 3, 1, 1, DeviceType>, 3, 1>(instr, locals, device); break; case 111: - batch_foreach, kernel_relu, 1, 1, DeviceType>, 1, 1>(instr, locals, device); + op_matmul(instr, locals, device); break; case 112: - batch_foreach, kernel_leaky_relu, 1, 1, DeviceType>, 1, 1>(instr, locals, device); + batch_foreach, kernel_relu, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 113: - batch_foreach, kernel_elu, 1, 1, DeviceType>, 1, 1>(instr, locals, device); + batch_foreach, kernel_leaky_relu, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 114: - batch_foreach, kernel_gelu, 1, 1, DeviceType>, 1, 1>(instr, locals, device); + batch_foreach, kernel_elu, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 115: - batch_foreach, kernel_sigmoid, 1, 1, DeviceType>, 1, 1>(instr, locals, device); + batch_foreach, kernel_gelu, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 116: - batch_foreach, kernel_softplus, 1, 1, DeviceType>, 1, 1>(instr, locals, device); + batch_foreach, kernel_sigmoid, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 117: - op_rqs_reshape(instr, locals, device); + batch_foreach, kernel_softplus, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 118: - batch_foreach, kernel_rqs_find_bin, 4, 1, 2, DeviceType>, 4, 1>(instr, locals, device); + op_rqs_reshape(instr, locals, device); break; case 119: - batch_foreach, kernel_rqs_forward, 2, 2, 2, DeviceType>, 2, 2>(instr, locals, device); + batch_foreach, kernel_rqs_find_bin, 4, 1, 2, DeviceType>, 4, 1>(instr, locals, device); break; case 120: - batch_foreach, kernel_rqs_inverse, 2, 2, 2, DeviceType>, 2, 2>(instr, locals, device); + batch_foreach, kernel_rqs_forward, 2, 2, 2, DeviceType>, 2, 2>(instr, locals, device); break; case 121: - batch_foreach, kernel_softmax, 1, 1, DeviceType>, 1, 1>(instr, locals, device); + batch_foreach, kernel_rqs_inverse, 2, 2, 2, DeviceType>, 2, 2>(instr, locals, device); break; case 122: - batch_foreach, kernel_softmax_prior, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + batch_foreach, kernel_softmax, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 123: - batch_foreach, kernel_sample_discrete, 2, 2, 1, DeviceType>, 2, 2>(instr, locals, device); + batch_foreach, kernel_softmax_prior, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 124: - batch_foreach, kernel_sample_discrete_inverse, 2, 2, 1, DeviceType>, 2, 2>(instr, locals, device); + batch_foreach, kernel_sample_discrete, 2, 2, 1, DeviceType>, 2, 2>(instr, locals, device); break; case 125: - batch_foreach, kernel_sample_discrete_probs, 2, 2, 1, DeviceType>, 2, 2>(instr, locals, device); + batch_foreach, kernel_sample_discrete_inverse, 2, 2, 1, DeviceType>, 2, 2>(instr, locals, device); break; case 126: - batch_foreach, kernel_sample_discrete_probs_inverse, 2, 2, 1, DeviceType>, 2, 2>(instr, locals, device); + batch_foreach, kernel_sample_discrete_probs, 2, 2, 1, DeviceType>, 2, 2>(instr, locals, device); break; case 127: - op_discrete_histogram(instr, locals, device); + batch_foreach, kernel_sample_discrete_probs_inverse, 2, 2, 1, DeviceType>, 2, 2>(instr, locals, device); break; case 128: - batch_foreach, kernel_permute_momenta, 3, 1, 1, DeviceType>, 3, 1>(instr, locals, device); + op_discrete_histogram(instr, locals, device); break; case 129: - batch_foreach, kernel_gather, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + batch_foreach, kernel_permute_momenta, 3, 1, 1, DeviceType>, 3, 1>(instr, locals, device); break; case 130: - batch_foreach, kernel_gather_int, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + batch_foreach, kernel_gather, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 131: - batch_foreach, kernel_select_int, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + batch_foreach, kernel_gather_int, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 132: - batch_foreach, kernel_select, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + batch_foreach, kernel_select_int, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 133: - batch_foreach, kernel_select_vector, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + batch_foreach, kernel_select, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 134: - batch_foreach, kernel_argsort, 1, 1, 1, DeviceType>, 1, 1>(instr, locals, device); + batch_foreach, kernel_select_vector, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 135: - op_quantile(instr, locals, device); + batch_foreach, kernel_argsort, 1, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 136: - batch_foreach, kernel_one_hot, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + op_quantile(instr, locals, device); break; case 137: - batch_foreach, kernel_madnis_abs_weight, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + batch_foreach, kernel_one_hot, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 138: - batch_foreach, kernel_madnis_softclip, 4, 1, 1, DeviceType>, 4, 1>(instr, locals, device); + batch_foreach, kernel_madnis_abs_weight, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 139: - batch_foreach, kernel_madnis_variance, 4, 1, 1, DeviceType>, 4, 1>(instr, locals, device); + batch_foreach, kernel_madnis_softclip, 4, 1, 1, DeviceType>, 4, 1>(instr, locals, device); break; case 140: - batch_foreach, kernel_madnis_single_channel_variance, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + batch_foreach, kernel_madnis_variance, 4, 1, 1, DeviceType>, 4, 1>(instr, locals, device); break; case 141: - batch_foreach, kernel_madnis_multi_channel_variance, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + batch_foreach, kernel_madnis_single_channel_variance, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 142: - op_nonzero(instr, locals, device); + batch_foreach, kernel_madnis_multi_channel_variance, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 143: - op_batch_gather(instr, locals, device); + op_nonzero(instr, locals, device); break; case 144: - op_batch_scatter(instr, locals, device); + op_batch_gather(instr, locals, device); break; case 145: - op_random(instr, locals, device); + op_batch_scatter(instr, locals, device); break; case 146: - op_random_int(instr, locals, device); + op_random(instr, locals, device); break; case 147: - op_unweight(instr, locals, device); + op_random_int(instr, locals, device); break; case 148: - batch_foreach, kernel_vegas_forward, 2, 2, 2, DeviceType>, 2, 2>(instr, locals, device); + op_unweight(instr, locals, device); break; case 149: - batch_foreach, kernel_vegas_inverse, 2, 2, 2, DeviceType>, 2, 2>(instr, locals, device); + batch_foreach, kernel_vegas_forward, 2, 2, 2, DeviceType>, 2, 2>(instr, locals, device); break; case 150: - op_vegas_histogram(instr, locals, device); + batch_foreach, kernel_vegas_inverse, 2, 2, 2, DeviceType>, 2, 2>(instr, locals, device); break; case 151: + op_vegas_histogram(instr, locals, device); + break; +case 152: op_histogram(instr, locals, device); break; diff --git a/madspace/src/driver/lhe_output.cpp b/madspace/src/driver/lhe_output.cpp index 7624d0ac38..622c5e26d5 100644 --- a/madspace/src/driver/lhe_output.cpp +++ b/madspace/src/driver/lhe_output.cpp @@ -165,6 +165,8 @@ LHECompleter::LHECompleter( std::span(permutation.begin() + 2, permutation.end()))) { e_min.at(index) = mass; momentum_masks.at(index) = 1 << perm_index; + // TODO: is this correct or is the permutation the wrong way + // around? for (std::size_t i = 0; std::size_t color_index : colors) { prop_colors.at(colors.size() * index + i) = args.color_flows.at(matrix_flavor_index) diff --git a/madspace/src/gpu/runtime_backward_mixin.inc b/madspace/src/gpu/runtime_backward_mixin.inc index 59453fdabe..be422c24d5 100644 --- a/madspace/src/gpu/runtime_backward_mixin.inc +++ b/madspace/src/gpu/runtime_backward_mixin.inc @@ -52,66 +52,66 @@ case 24: case 25: backward_batch_foreach, 2, 1>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 110: +case 111: backward_op_matmul(instr, locals, local_grads, device); break; -case 111: +case 112: backward_batch_foreach, 2, 1>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 112: +case 113: backward_batch_foreach, 2, 1>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 113: +case 114: backward_batch_foreach, 2, 1>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 114: +case 115: backward_batch_foreach, 2, 1>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 115: +case 116: backward_batch_foreach, 2, 1>, 1, 1, 0, 1>(instr, locals, local_grads, {}, {0}, device); break; -case 116: +case 117: backward_batch_foreach, 2, 1>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 117: +case 118: backward_op_rqs_reshape(instr, locals, local_grads, device); break; -case 118: +case 119: backward_batch_foreach, 5, 4, 2>, 4, 1, 4, 0>(instr, locals, local_grads, {0,1,2,3}, {}, device); break; -case 119: +case 120: backward_batch_foreach, 4, 2, 2>, 2, 2, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; -case 120: +case 121: backward_batch_foreach, 4, 2, 2>, 2, 2, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; -case 121: +case 122: backward_batch_foreach, 2, 1>, 1, 1, 0, 1>(instr, locals, local_grads, {}, {0}, device); break; -case 122: +case 123: backward_batch_foreach, 2, 2, 1>, 2, 1, 0, 1>(instr, locals, local_grads, {}, {0}, device); break; -case 126: +case 127: backward_batch_foreach, 4, 2, 1>, 2, 2, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; -case 129: +case 130: backward_batch_foreach, 2, 2, 1>, 2, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 132: +case 133: backward_batch_foreach, 2, 2, 1>, 2, 1, 1, 0>(instr, locals, local_grads, {1}, {}, device); break; -case 137: +case 138: backward_batch_foreach, 3, 2, 1>, 2, 1, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; -case 138: +case 139: backward_batch_foreach, 5, 4, 1>, 4, 1, 4, 0>(instr, locals, local_grads, {0,1,2,3}, {}, device); break; -case 139: +case 140: backward_batch_foreach, 5, 4, 1>, 4, 1, 4, 0>(instr, locals, local_grads, {0,1,2,3}, {}, device); break; -case 140: +case 141: backward_batch_foreach, 2, 2, 1>, 2, 1, 1, 0>(instr, locals, local_grads, {1}, {}, device); break; -case 141: +case 142: backward_batch_foreach, 3, 2, 1>, 2, 1, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; diff --git a/madspace/src/gpu/runtime_mixin.inc b/madspace/src/gpu/runtime_mixin.inc index 72271c9492..6e97b57ec7 100644 --- a/madspace/src/gpu/runtime_mixin.inc +++ b/madspace/src/gpu/runtime_mixin.inc @@ -314,146 +314,149 @@ case 103: batch_foreach, 1, 1, 1>, 1, 1>(instr, locals, device); break; case 104: - batch_foreach, 5, 2, 1>, 5, 2>(instr, locals, device); + batch_foreach, 2, 5, 1>, 2, 5>(instr, locals, device); break; case 105: - batch_foreach, 5, 2, 1>, 5, 2>(instr, locals, device); + batch_foreach, 5, 2, 1>, 5, 2>(instr, locals, device); break; case 106: - op_matrix_element(instr, locals, device); + batch_foreach, 5, 2, 1>, 5, 2>(instr, locals, device); break; case 107: - batch_foreach, 3, 1, 1>, 3, 1>(instr, locals, device); + op_matrix_element(instr, locals, device); break; case 108: - batch_foreach, 6, 1, 1>, 6, 1>(instr, locals, device); + batch_foreach, 3, 1, 1>, 3, 1>(instr, locals, device); break; case 109: - batch_foreach, 3, 1, 1>, 3, 1>(instr, locals, device); + batch_foreach, 6, 1, 1>, 6, 1>(instr, locals, device); break; case 110: - op_matmul(instr, locals, device); + batch_foreach, 3, 1, 1>, 3, 1>(instr, locals, device); break; case 111: - batch_foreach, 1, 1>, 1, 1>(instr, locals, device); + op_matmul(instr, locals, device); break; case 112: - batch_foreach, 1, 1>, 1, 1>(instr, locals, device); + batch_foreach, 1, 1>, 1, 1>(instr, locals, device); break; case 113: - batch_foreach, 1, 1>, 1, 1>(instr, locals, device); + batch_foreach, 1, 1>, 1, 1>(instr, locals, device); break; case 114: - batch_foreach, 1, 1>, 1, 1>(instr, locals, device); + batch_foreach, 1, 1>, 1, 1>(instr, locals, device); break; case 115: - batch_foreach, 1, 1>, 1, 1>(instr, locals, device); + batch_foreach, 1, 1>, 1, 1>(instr, locals, device); break; case 116: - batch_foreach, 1, 1>, 1, 1>(instr, locals, device); + batch_foreach, 1, 1>, 1, 1>(instr, locals, device); break; case 117: - op_rqs_reshape(instr, locals, device); + batch_foreach, 1, 1>, 1, 1>(instr, locals, device); break; case 118: - batch_foreach, 4, 1, 2>, 4, 1>(instr, locals, device); + op_rqs_reshape(instr, locals, device); break; case 119: - batch_foreach, 2, 2, 2>, 2, 2>(instr, locals, device); + batch_foreach, 4, 1, 2>, 4, 1>(instr, locals, device); break; case 120: - batch_foreach, 2, 2, 2>, 2, 2>(instr, locals, device); + batch_foreach, 2, 2, 2>, 2, 2>(instr, locals, device); break; case 121: - batch_foreach, 1, 1>, 1, 1>(instr, locals, device); + batch_foreach, 2, 2, 2>, 2, 2>(instr, locals, device); break; case 122: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + batch_foreach, 1, 1>, 1, 1>(instr, locals, device); break; case 123: - batch_foreach, 2, 2, 1>, 2, 2>(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 124: - batch_foreach, 2, 2, 1>, 2, 2>(instr, locals, device); + batch_foreach, 2, 2, 1>, 2, 2>(instr, locals, device); break; case 125: - batch_foreach, 2, 2, 1>, 2, 2>(instr, locals, device); + batch_foreach, 2, 2, 1>, 2, 2>(instr, locals, device); break; case 126: - batch_foreach, 2, 2, 1>, 2, 2>(instr, locals, device); + batch_foreach, 2, 2, 1>, 2, 2>(instr, locals, device); break; case 127: - op_discrete_histogram(instr, locals, device); + batch_foreach, 2, 2, 1>, 2, 2>(instr, locals, device); break; case 128: - batch_foreach, 3, 1, 1>, 3, 1>(instr, locals, device); + op_discrete_histogram(instr, locals, device); break; case 129: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + batch_foreach, 3, 1, 1>, 3, 1>(instr, locals, device); break; case 130: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 131: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 132: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 133: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 134: - batch_foreach, 1, 1, 1>, 1, 1>(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 135: - op_quantile(instr, locals, device); + batch_foreach, 1, 1, 1>, 1, 1>(instr, locals, device); break; case 136: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + op_quantile(instr, locals, device); break; case 137: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 138: - batch_foreach, 4, 1, 1>, 4, 1>(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 139: - batch_foreach, 4, 1, 1>, 4, 1>(instr, locals, device); + batch_foreach, 4, 1, 1>, 4, 1>(instr, locals, device); break; case 140: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + batch_foreach, 4, 1, 1>, 4, 1>(instr, locals, device); break; case 141: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 142: - op_nonzero(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 143: - op_batch_gather(instr, locals, device); + op_nonzero(instr, locals, device); break; case 144: - op_batch_scatter(instr, locals, device); + op_batch_gather(instr, locals, device); break; case 145: - op_random(instr, locals, device); + op_batch_scatter(instr, locals, device); break; case 146: - op_random_int(instr, locals, device); + op_random(instr, locals, device); break; case 147: - op_unweight(instr, locals, device); + op_random_int(instr, locals, device); break; case 148: - batch_foreach, 2, 2, 2>, 2, 2>(instr, locals, device); + op_unweight(instr, locals, device); break; case 149: - batch_foreach, 2, 2, 2>, 2, 2>(instr, locals, device); + batch_foreach, 2, 2, 2>, 2, 2>(instr, locals, device); break; case 150: - op_vegas_histogram(instr, locals, device); + batch_foreach, 2, 2, 2>, 2, 2>(instr, locals, device); break; case 151: + op_vegas_histogram(instr, locals, device); + break; +case 152: op_histogram(instr, locals, device); break; diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp new file mode 100644 index 0000000000..e4fe0cde27 --- /dev/null +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -0,0 +1,83 @@ +#include "madspace/phasespace/mlm_clustering.hpp" + +using namespace madspace; + +MLMClustering::MLMClustering( + std::vector topologies, + nested_vector3 permutations, + nested_vector2 diagram_indices +) : + FunctionGenerator( + "MLMClustering", + {{"momenta", + batch_four_vec_array(topologies.at(0).outgoing_masses().size() + 2)}}, + {{"ren_scale", batch_float}, + {"fact_scale1", batch_float}, + {"fact_scale2", batch_float}, + {"cluster_history", + batch_int_array(topologies.at(0).outgoing_masses().size() - 1)}, + {"cluster_scales", + batch_float_array(topologies.at(0).outgoing_masses().size() - 1)}} + ) { + std::size_t n_ext = topologies.at(0).outgoing_masses().size() + 2; + nested_vector2 valid_diags(1 << n_ext); + std::vector particle_masks; + std::vector all_diags; + + // create a list of all diagram indices that are possible for a given clustering, + // where a binary encoding of the clustering is used + for (auto [topo, permutations, diag_indices] : + zip(topologies, permutations, diagram_indices)) { + for (auto [permutation, diag_index] : zip(permutations, diag_indices)) { + all_diags.push_back(diag_index); + particle_masks.assign(topo.decays().size(), 0); + for (std::size_t i = 2; i < permutation.size(); ++i) { + particle_masks.at(topo.outgoing_indices().at(permutation.at(i))) = 1 + << i; + } + + bool has_t_channel = topo.t_integration_order().size() > 0; + for (auto& decay : std::views::reverse(topo.decays())) { + if (decay.child_indices.size() == 0) { + continue; + } + if (decay.index == 0 && has_t_channel) { + continue; + } + if (decay.child_indices.size() > 2) { + throw std::logic_error("does not support 1->n decays with n > 2"); + } + std::size_t mask = + (particle_masks.at(decay.child_indices.at(0)) | + particle_masks.at(decay.child_indices.at(1))); + particle_masks.at(decay.index) = mask; + valid_diags.at(mask).push_back(diag_index); + } + + if (!has_t_channel) { + continue; + } + + // for the t-channel part, one of the initial state particles has to be + // involved in the clustering + for (std::size_t mask = 1; + std::size_t index : topo.decays().at(0).child_indices) { + mask |= particle_masks.at(index); + valid_diags.at(mask).push_back(diag_index); + } + for (std::size_t mask = 2; + std::size_t index : + std::views::reverse(topo.decays().at(0).child_indices)) { + mask |= particle_masks.at(index); + valid_diags.at(mask).push_back(diag_index); + } + } + } +} + +NamedVector MLMClustering::build_function_impl( + FunctionBuilder& fb, const NamedVector& args +) const { + auto mlm_out = fb.mlm_clustering_hadronic(args.at(0), _cluster_state_machine); + return {return_types().keys(), {mlm_out.begin(), mlm_out.end()}}; +} diff --git a/madspace/src/python/instruction_set.hpp b/madspace/src/python/instruction_set.hpp index 1b2bc8e842..2a7623c193 100644 --- a/madspace/src/python/instruction_set.hpp +++ b/madspace/src/python/instruction_set.hpp @@ -117,6 +117,7 @@ void add_instructions(py::classh& fb) { fb.def("scale_transverse_mass", &FunctionBuilder::scale_transverse_mass, py::arg("momenta")); fb.def("scale_half_transverse_mass", &FunctionBuilder::scale_half_transverse_mass, py::arg("momenta")); fb.def("scale_partonic_energy", &FunctionBuilder::scale_partonic_energy, py::arg("momenta")); + fb.def("mlm_clustering_hadronic", &FunctionBuilder::mlm_clustering_hadronic, py::arg("momenta"), py::arg("state_machine")); fb.def("chili_forward", &FunctionBuilder::chili_forward, py::arg("r"), py::arg("e_cm"), py::arg("m_out"), py::arg("pt_min"), py::arg("y_max")); fb.def("chili_inverse", &FunctionBuilder::chili_inverse, py::arg("p_ext"), py::arg("e_cm"), py::arg("m_out"), py::arg("pt_min"), py::arg("y_max")); fb.def("matrix_element", &FunctionBuilder::matrix_element, py::arg("args")); From 2d0b02ac89551e1b71186818c9240bd388457562 Mon Sep 17 00:00:00 2001 From: Theo Heimel Date: Wed, 17 Jun 2026 11:04:49 +0200 Subject: [PATCH 02/81] progress on mlm --- .../compgraphs/function_builder_mixin.inc | 4 +- madspace/instruction_set.yaml | 20 ++- .../src/compgraphs/instruction_set_mixin.inc | 2 +- madspace/src/cpu/runtime_mixin.inc | 2 +- madspace/src/gpu/runtime_mixin.inc | 2 +- madspace/src/kernels/mlm.hpp | 167 ++++++++++++++++++ madspace/src/phasespace/mlm_clustering.cpp | 156 +++++++++++++++- madspace/src/python/instruction_set.hpp | 2 +- 8 files changed, 337 insertions(+), 18 deletions(-) create mode 100644 madspace/src/kernels/mlm.hpp diff --git a/madspace/include/madspace/compgraphs/function_builder_mixin.inc b/madspace/include/madspace/compgraphs/function_builder_mixin.inc index 392d15e162..6d3f99b9a8 100644 --- a/madspace/include/madspace/compgraphs/function_builder_mixin.inc +++ b/madspace/include/madspace/compgraphs/function_builder_mixin.inc @@ -461,8 +461,8 @@ Value scale_partonic_energy(Value momenta) { return instruction("scale_partonic_energy", {momenta})[0]; } -std::array mlm_clustering_hadronic(Value momenta, Value state_machine) { - auto output_vector = instruction("mlm_clustering_hadronic", {momenta, state_machine}); +std::array mlm_clustering_hadronic(Value momenta, Value random, Value state_machine, Value masses, Value widths) { + auto output_vector = instruction("mlm_clustering_hadronic", {momenta, random, state_machine, masses, widths}); return {output_vector[0], output_vector[1], output_vector[2], output_vector[3], output_vector[4]}; } diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index de664a7b06..5283a2221f 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -1952,8 +1952,17 @@ mlm_clustering_hadronic: - name: momenta type: [float, n, 4] desc: + - name: random + type: [float] + desc: - name: state_machine - type: [int, m] + type: [int, single, m] + desc: + - name: masses + type: [double, single, k] + desc: + - name: widths + type: [double, single, k] desc: outputs: - name: ren_scale @@ -1965,12 +1974,13 @@ mlm_clustering_hadronic: - name: fact_scale2 type: [float] desc: - - name: cluster_history - type: [int, n-3] + - name: outgoing_scales + type: [float, n-2] desc: - - name: cluster_scales - type: [float, n-3] + - name: diagram_index + type: [int] desc: + vectorized: False --- title: Chili diff --git a/madspace/src/compgraphs/instruction_set_mixin.inc b/madspace/src/compgraphs/instruction_set_mixin.inc index 0c1edccae6..e71e59dc25 100644 --- a/madspace/src/compgraphs/instruction_set_mixin.inc +++ b/madspace/src/compgraphs/instruction_set_mixin.inc @@ -119,7 +119,7 @@ InstructionOwner instructions[] { mi("scale_transverse_mass", 101, true, {{DataType::dt_float, false, {"n", 4}, false}}, {{DataType::dt_float, false, {}, false}}), mi("scale_half_transverse_mass", 102, true, {{DataType::dt_float, false, {"n", 4}, false}}, {{DataType::dt_float, false, {}, false}}), mi("scale_partonic_energy", 103, true, {{DataType::dt_float, false, {"n", 4}, false}}, {{DataType::dt_float, false, {}, false}}), - mi("mlm_clustering_hadronic", 104, true, {{DataType::dt_float, false, {"n", 4}, false}, {DataType::dt_int, false, {"m"}, false}}, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, false, {"n-3"}, false}, {DataType::dt_float, false, {"n-3"}, false}}), + mi("mlm_clustering_hadronic", 104, true, {{DataType::dt_float, false, {"n", 4}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {"m"}, false}, {DataType::dt_double, true, {"k"}, false}, {DataType::dt_double, true, {"k"}, false}}, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {"n-2"}, false}, {DataType::dt_int, false, {}, false}}), mi("chili_forward", 105, true, {{DataType::dt_float, false, {"3n-2"}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {"n+2", 4}, false}, {DataType::dt_float, false, {}, false}}), mi("chili_inverse", 106, true, {{DataType::dt_float, false, {"n+2", 4}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {"3n-2"}, false}, {DataType::dt_float, false, {}, false}}), InstructionOwner(new MatrixElementInstruction(107, true)), diff --git a/madspace/src/cpu/runtime_mixin.inc b/madspace/src/cpu/runtime_mixin.inc index 3e067702c5..9ff7dee352 100644 --- a/madspace/src/cpu/runtime_mixin.inc +++ b/madspace/src/cpu/runtime_mixin.inc @@ -314,7 +314,7 @@ case 103: batch_foreach, kernel_scale_partonic_energy, 1, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 104: - batch_foreach, kernel_mlm_clustering_hadronic, 2, 5, 1, DeviceType>, 2, 5>(instr, locals, device); + batch_foreach, kernel_mlm_clustering_hadronic, 5, 5, 1, DeviceType>, 5, 5>(instr, locals, device); break; case 105: batch_foreach, kernel_chili_forward, 5, 2, 1, DeviceType>, 5, 2>(instr, locals, device); diff --git a/madspace/src/gpu/runtime_mixin.inc b/madspace/src/gpu/runtime_mixin.inc index 6e97b57ec7..dbc3488e69 100644 --- a/madspace/src/gpu/runtime_mixin.inc +++ b/madspace/src/gpu/runtime_mixin.inc @@ -314,7 +314,7 @@ case 103: batch_foreach, 1, 1, 1>, 1, 1>(instr, locals, device); break; case 104: - batch_foreach, 2, 5, 1>, 2, 5>(instr, locals, device); + batch_foreach, 5, 5, 1>, 5, 5>(instr, locals, device); break; case 105: batch_foreach, 5, 2, 1>, 5, 2>(instr, locals, device); diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp new file mode 100644 index 0000000000..f80453a117 --- /dev/null +++ b/madspace/src/kernels/mlm.hpp @@ -0,0 +1,167 @@ +#pragma once + +#include "definitions.hpp" + +namespace madspace { +namespace kernels { + +constexpr int N_EXT_MAX = 12; + +KERNELSPEC void compute_scale( + double* momentum1, + double* momentum2, + bool resonant, + bool massive_in, + bool massive_out1, + bool massive_out2 +) { + /* + initial state: + cluster_scale=sqrt(djb_clus(pi)) + ! prefer clustering when outgoing is in the direction of incoming + if(sign(1d0,pi(3)).ne.sign(1d0,pj(3))) + $ cluster_scale=cluster_scale*one_plus_tiny + + final state: + resonant: + cluster_scale=sqrt(max(sumdot(pi,pj,1d0),0d0)) + + 2 : 1 0 1 + dj_clus = DJB_clus(p1)*(1d0+1d-6) + + 3 : 1 1 0 + dj_clus = DJB_clus(p2)*(1d0+1d-6) + + 1 : 0 0 0 + 6 : 0 1 1 + 7 : 1 1 1 + pt1 = p1(1)**2+p1(2)**2 + pt2 = p2(1)**2+p2(2)**2 + if (pt1.eq.0d0 .or. pt2.eq.0d0) then + dj_clus=0d0 + return + endif + p1a = dsqrt(pt1+p1(3)**2) + p2a = dsqrt(pt2+p2(3)**2) + eta1 = 0.5d0*log((p1a+p1(3))/(p1a-p1(3))) + eta2 = 0.5d0*log((p2a+p2(3))/(p2a-p2(3))) + dj_clus = max(m1,m2)**2+min(pt1,pt2)*2d0*(cosh(eta1-eta2)- + & (p1(1)*p2(1)+p1(2)*p2(2))/dsqrt(pt1*pt2))/D**2 + if (dj_clus.lt.0d0 .or. dj_clus.ne.dj_clus) + & dj_clus=0d0 ! prevent numerical inaccuracies + + 4 : 0 1 0 + cluster_scale=sqrt(abs(dot(pj,(pi+pj))))/2d0 + + 5 : 0 0 1 + cluster_scale=sqrt(abs(dot(pi,(pi+pj))))/2d0 + + 8 : 1 0 0 + resonant: + cluster_scale=sqrt(max(sumdot(pi,pj,1d0),0d0)) + */ +} + +template +KERNELSPEC void mlm_clustering_hadronic( + FIn momenta, + FIn random, + IIn state_machine, + FIn masses, + FIn widths, + FOut ren_scale, + FOut fact_scale1, + FOut fact_scale2, + FOut outgoing_scales, + IOut diagram_index +) { + static_assert(std::is_same_v, int>); + int state = 0, cluster_count = 0; + int cluster_max = momenta.size() - 3; + double momenta_tmp[N_EXT_MAX][4]; + int cluster_history[N_EXT_MAX - 3]; + int cluster_scales[N_EXT_MAX - 3]; + + for (int i = 0; i < momenta.size(); ++i) { + for (int j = 0; j < 4; ++j) { + momenta_tmp[i][j] = momenta[i][j]; + } + } + + // Iterate over the possible clustering states. This loop iterates over the possible + // clusterings at a given points as well as the full sequence of clusterings. + // Compared to two nested loops, this means that no thread has to wait on the GPU + int win_next_state = -1, win_data = 0; + double win_scale = 1e308; + while (cluster_count < cluster_max) { + int data = state_machine[state]; + int next_state = state_machine[state + 1]; + int particle1 = data & 0xFF; + int particle2 = (data >> 8) & 0xFF; + int mass_index = (data >> 16) & 0xFF; + bool massive_in = (data >> 24) & 1; + bool massive_out1 = (data >> 25) & 1; + bool massive_out2 = (data >> 26) & 1; + bool is_last = (data >> 28) & 1; + + double scale = compute_scale_hadronic( + momenta_tmp[particle1], + momenta_tmp[particle2], + massive_in, + massive_out1, + massive_out2 + ); + + if (scale < win_scale) { + win_next_state = next_state; + win_scale = scale; + win_data = data; + } + if (is_last) { + state = win_next_state; + cluster_history[cluster_count] = win_data; + cluster_scales[cluster_count] = win_scale; + ++cluster_count; + } else { + ++state; + } + } + + // now determine the scales: + // renormalization scale: geometric mean of clustering scales, replacing those + // with is_qcd==false with the maximal scale + // factorization scale: minimal scale with is_qcd==true + double fac_scale = 1e308, max_scale = 0; + for (int i = 0; i < cluster_max; ++i) { + double scale = cluster_scales[i]; + bool is_qcd = (cluster_history[i] >> 27) & 1; + if (is_qcd && scale < fac_scale) { + fac_scale = scale; + } + if (scale > max_scale) { + max_scale = scale; + } + } + + double ren_scale = 1.; + for (int i = 0; i < cluster_max; ++i) { + double scale = cluster_scales[i]; + bool is_qcd = (cluster_history[i] >> 27) & 1; + ren_scale *= is_qcd ? scale : max_scale; + } + ren_scale = pow(ren_scale, 1. / cluster_max); + if (fac_scale > ren_scale) { + fac_scale = ren_scale; + } + + // if multiple diagrams exist, pick one randomly + int diag_count = state_machine[state]; + int rand_index = static_cast(static_cast(random) * diag_count); + if (rand_index >= diag_count) { + rand_index = diag_count - 1; + } + diagram_index = state_machine[state + rand_index + 1]; +} + +} // namespace kernels +} // namespace madspace diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index e4fe0cde27..ec5d2b78d6 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -1,7 +1,111 @@ #include "madspace/phasespace/mlm_clustering.hpp" +#include +#include + using namespace madspace; +namespace { + +using StateKey = std::pair, std::vector>; +struct StateItem { + int next_state; + int particle1; + int particle2; + int mass_index; + bool massive_in; + bool massive_out1; + bool massive_out2; + bool is_qcd; +}; + +void find_clusterings( + const std::vector& particle_masks, + const std::vector& diagrams, + const nested_vector2& valid_diags, + nested_vector2& states, + std::map& state_map, + int prev_index +) { + int n_masks = particle_masks.size(); + for (int i = 0; i < n_masks - 1; ++i) { + for (int j = i + 1; j < n_masks; ++j) { + int mask_i = particle_masks.at(i), mask_j = particle_masks.at(j); + int mask = mask_i | mask_j; + auto& valid = valid_diags.at(mask); + if (valid.size() == 0) { + continue; + } + std::vector new_masks, new_diags; + new_masks.insert( + new_masks.end(), particle_masks.begin(), particle_masks.begin() + i + ); + new_masks.push_back(mask); + new_masks.insert( + new_masks.end(), + particle_masks.begin() + i + 1, + particle_masks.begin() + j + ); + new_masks.insert( + new_masks.end(), particle_masks.begin() + j + 1, particle_masks.end() + ); + for (int index : diagrams) { + if (std::find(valid.begin(), valid.end(), index) != valid.end()) { + new_diags.push_back(index); + } + } + + StateKey key; + if (new_masks.size() == 3) { + key = {{}, new_diags}; + } else { + key = {new_masks, new_diags}; + } + int index; + if (auto search = state_map.find(key); search == state_map.end()) { + index = search->second; + } else { + index = states.size(); + state_map[key] = index; + states.push_back({}); + } + states.at(prev_index) + .push_back({ + .next_state = index, + .particle1 = std::countr_zero(static_cast(mask_i)), + .particle2 = std::countr_zero(static_cast(mask_j)), + .mass_index = 0, + .massive_in = false, + .massive_out1 = false, + .massive_out2 = false, + .is_qcd = false, + }); + + if (new_masks.size() == 3) { + auto& current_states = states.at(index); + for (int diag_index : new_diags) { + current_states.push_back({ + .next_state = diag_index, + .particle1 = 0, + .particle2 = 0, + .mass_index = 0, + .massive_in = false, + .massive_out1 = false, + .massive_out2 = false, + .is_qcd = false, + }); + } + } else { + find_clusterings( + new_masks, new_diags, valid_diags, states, state_map, index + ); + } + } + } +} + +} // namespace + MLMClustering::MLMClustering( std::vector topologies, nested_vector3 permutations, @@ -20,9 +124,9 @@ MLMClustering::MLMClustering( batch_float_array(topologies.at(0).outgoing_masses().size() - 1)}} ) { std::size_t n_ext = topologies.at(0).outgoing_masses().size() + 2; - nested_vector2 valid_diags(1 << n_ext); - std::vector particle_masks; - std::vector all_diags; + nested_vector2 valid_diags(1 << n_ext); + std::vector particle_masks; + std::vector all_diags; // create a list of all diagram indices that are possible for a given clustering, // where a binary encoding of the clustering is used @@ -47,7 +151,7 @@ MLMClustering::MLMClustering( if (decay.child_indices.size() > 2) { throw std::logic_error("does not support 1->n decays with n > 2"); } - std::size_t mask = + int mask = (particle_masks.at(decay.child_indices.at(0)) | particle_masks.at(decay.child_indices.at(1))); particle_masks.at(decay.index) = mask; @@ -60,12 +164,11 @@ MLMClustering::MLMClustering( // for the t-channel part, one of the initial state particles has to be // involved in the clustering - for (std::size_t mask = 1; - std::size_t index : topo.decays().at(0).child_indices) { + for (int mask = 1; std::size_t index : topo.decays().at(0).child_indices) { mask |= particle_masks.at(index); valid_diags.at(mask).push_back(diag_index); } - for (std::size_t mask = 2; + for (int mask = 2; std::size_t index : std::views::reverse(topo.decays().at(0).child_indices)) { mask |= particle_masks.at(index); @@ -73,6 +176,45 @@ MLMClustering::MLMClustering( } } } + + std::vector masks; + masks.reserve(n_ext); + for (int i = 0; i < n_ext; ++i) { + masks.push_back(1 << i); + } + nested_vector2 states{{}}; + std::map state_map; + state_map[{{masks}, {all_diags}}] = 0; + find_clusterings(masks, all_diags, valid_diags, states, state_map, 0); + + std::vector first_indices; + first_indices.reserve(states.size()); + for (int offset = 0; auto& state : states) { + first_indices.push_back(offset); + if (state.at(0).particle1 == 0 && state.at(0).particle2 == 0) { + offset += 1 + state.size(); + } else { + offset += 2 * state.size(); + } + } + for (auto& state : states) { + if (state.at(0).particle1 == 0 && state.at(0).particle2 == 0) { + _cluster_state_machine.push_back(state.size()); + for (auto& item : state) { + _cluster_state_machine.push_back(item.next_state); + } + } else { + for (auto& item : state) { + _cluster_state_machine.push_back( + (item.particle1 << 0) + (item.particle2 << 8) + + (item.mass_index << 16) + (item.massive_in << 24) + + (item.massive_out1 << 25) + (item.massive_out2 << 26) + + (item.is_qcd << 27) + ((&item == &state.back()) << 28) + ); + _cluster_state_machine.push_back(first_indices.at(item.next_state)); + } + } + } } NamedVector MLMClustering::build_function_impl( diff --git a/madspace/src/python/instruction_set.hpp b/madspace/src/python/instruction_set.hpp index 2a7623c193..b1b52dc5e0 100644 --- a/madspace/src/python/instruction_set.hpp +++ b/madspace/src/python/instruction_set.hpp @@ -117,7 +117,7 @@ void add_instructions(py::classh& fb) { fb.def("scale_transverse_mass", &FunctionBuilder::scale_transverse_mass, py::arg("momenta")); fb.def("scale_half_transverse_mass", &FunctionBuilder::scale_half_transverse_mass, py::arg("momenta")); fb.def("scale_partonic_energy", &FunctionBuilder::scale_partonic_energy, py::arg("momenta")); - fb.def("mlm_clustering_hadronic", &FunctionBuilder::mlm_clustering_hadronic, py::arg("momenta"), py::arg("state_machine")); + fb.def("mlm_clustering_hadronic", &FunctionBuilder::mlm_clustering_hadronic, py::arg("momenta"), py::arg("random"), py::arg("state_machine"), py::arg("masses"), py::arg("widths")); fb.def("chili_forward", &FunctionBuilder::chili_forward, py::arg("r"), py::arg("e_cm"), py::arg("m_out"), py::arg("pt_min"), py::arg("y_max")); fb.def("chili_inverse", &FunctionBuilder::chili_inverse, py::arg("p_ext"), py::arg("e_cm"), py::arg("m_out"), py::arg("pt_min"), py::arg("y_max")); fb.def("matrix_element", &FunctionBuilder::matrix_element, py::arg("args")); From 04f43eb99d8ead9c7530629bf5d02c7ff7655f22 Mon Sep 17 00:00:00 2001 From: Theo Heimel Date: Thu, 25 Jun 2026 13:42:38 +0200 Subject: [PATCH 03/81] initial slop version of the mlm clustering scale --- madspace/src/kernels/mlm.hpp | 372 ++++++++++++++++++++++++++++------- 1 file changed, 306 insertions(+), 66 deletions(-) diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index f80453a117..62a7cec212 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -1,65 +1,279 @@ #pragma once #include "definitions.hpp" +#include "kinematics.hpp" + +#include namespace madspace { namespace kernels { constexpr int N_EXT_MAX = 12; -KERNELSPEC void compute_scale( - double* momentum1, - double* momentum2, +// --- Low-level kinematic helpers (mirror boost and rotate_inverse from +// kinematics.hpp +// but operating on plain double[4] so they can be used with the local scratch +// arrays) --- + +// Lorentz boost of k by the frame defined by p_boost (sign=+1 forward, -1 inverse). +// Mirrors boost from kinematics.hpp. +KERNELSPEC void +boost4(const double k[4], const double p_boost[4], double sign, double out[4]) { + double p2 = p_boost[0] * p_boost[0] - p_boost[1] * p_boost[1] - + p_boost[2] * p_boost[2] - p_boost[3] * p_boost[3]; + double rsq = sqrt(p2 > EPS2 ? p2 : EPS2); + double k_dot_p = k[1] * p_boost[1] + k[2] * p_boost[2] + k[3] * p_boost[3]; + double e = (k[0] * p_boost[0] + sign * k_dot_p) / rsq; + double c1 = sign * (k[0] + e) / (rsq + p_boost[0]); + out[0] = e; + out[1] = k[1] + c1 * p_boost[1]; + out[2] = k[2] + c1 * p_boost[2]; + out[3] = k[3] + c1 * p_boost[3]; +} + +// Rotate p into the canonical frame where q's spatial direction is the z-axis. +// Mirrors rotate_inverse from kinematics.hpp. +KERNELSPEC void rotate_inv4(const double p[4], const double q[4], double out[4]) { + double qt2 = q[1] * q[1] + q[2] * q[2]; + double qq2 = qt2 + q[3] * q[3]; + double qt = sqrt(qt2 > EPS2 ? qt2 : EPS2); + double qq = sqrt(qq2 > EPS2 ? qq2 : EPS2); + out[0] = p[0]; + if (qt2 < EPS2) { + double s = (q[3] < 0.0) ? -1.0 : 1.0; + out[1] = s * p[1]; + out[2] = p[2]; + out[3] = s * p[3]; + return; + } + out[1] = q[1] * q[3] / (qq * qt) * p[1] + q[2] * q[3] / (qq * qt) * p[2] - + p[3] * qt / qq; + out[2] = -q[2] / qt * p[1] + q[1] / qt * p[2]; + out[3] = q[1] / qq * p[1] + q[2] / qq * p[2] + q[3] / qq * p[3]; +} + +// --- Mass tracking --- + +// Update momenta and separately-tracked masses after one clustering step. +// +// i_remove : index of the final-state particle absorbed into the combination (always >= +// 2) j_keep : index of the particle kept with the combined momentum (0/1 = initial +// state, else final) is_bw : Breit-Wigner resonance — combined mass = invariant +// mass; else max of daughters alive : alive[i] is true while particle i has not yet +// been removed n_part : total number of particles (bound for the alive[] loop) +// +// Mass update rules from Fortran update_momenta: +// Final-state non-BW : mass[j_keep] = max(mass[j_keep], mass[i_remove]) +// Final-state BW : mass[j_keep] = sqrt((p_i + p_j)^2) +// Initial-state (XOR) : mass[j_keep] = max(mass[j_keep], mass[i_remove]) (exactly +// one massive) Initial-state (same): mass[j_keep] = 0 (both massive or both massless) +// +// Momentum update rules: +// Final-state : momenta[j_keep] += momenta[i_remove] +// Initial-state: momenta[j_keep] -= momenta[i_remove]; then if CM s > 100 GeV^2, +// boost all alive particles to the new CM frame and rotate so that +// the new j_keep momentum is along z. +KERNELSPEC void update_momenta( + int n_part, + double momenta[N_EXT_MAX][4], + double masses[N_EXT_MAX], + bool alive[N_EXT_MAX], + int i_remove, + int j_keep, + bool is_bw +) { + alive[i_remove] = false; + + if (j_keep < 2) { + // initial-state clustering + int j_other = 1 - j_keep; // the other beam particle (0-indexed) + for (int k = 0; k < 4; ++k) { + momenta[j_keep][k] -= momenta[i_remove][k]; + } + + // mass: max if exactly one daughter is massive, else 0 + bool m0 = (masses[j_keep] > 0.0); + bool m1 = (masses[i_remove] > 0.0); + masses[j_keep] = (m0 != m1) ? fmax(masses[j_keep], masses[i_remove]) : 0.0; + + // CM boost vector: (E_tot, -px_tot, -py_tot, -pz_tot) of the two beam particles + double pcmsp[4]; + pcmsp[0] = momenta[j_keep][0] + momenta[j_other][0]; + pcmsp[1] = -(momenta[j_keep][1] + momenta[j_other][1]); + pcmsp[2] = -(momenta[j_keep][2] + momenta[j_other][2]); + pcmsp[3] = -(momenta[j_keep][3] + momenta[j_other][3]); + + double inv_sq = pcmsp[0] * pcmsp[0] - pcmsp[1] * pcmsp[1] - + pcmsp[2] * pcmsp[2] - pcmsp[3] * pcmsp[3]; + if (inv_sq > 100.0) { + // boost j_keep to CM frame to define the rotation axis + double jkeep_cm[4]; + boost4(momenta[j_keep], pcmsp, 1.0, jkeep_cm); + + // boost all alive particles and rotate so that j_keep is along z + for (int j = 0; j < n_part; ++j) { + if (!alive[j]) { + continue; + } + double p_cm[4], p_rot[4]; + boost4(momenta[j], pcmsp, 1.0, p_cm); + rotate_inv4(p_cm, jkeep_cm, p_rot); + for (int k = 0; k < 4; ++k) { + momenta[j][k] = p_rot[k]; + } + } + } + // if inv_sq <= 100: no boost needed, momenta already correct after subtraction + + } else { + // final-state clustering: sum momenta, update mass + for (int k = 0; k < 4; ++k) { + momenta[j_keep][k] += momenta[i_remove][k]; + } + + if (is_bw) { + const double* p = momenta[j_keep]; + double m2 = p[0] * p[0] - p[1] * p[1] - p[2] * p[2] - p[3] * p[3]; + masses[j_keep] = sqrt(m2 > 0.0 ? m2 : 0.0); + } else { + masses[j_keep] = fmax(masses[j_keep], masses[i_remove]); + } + } +} + +// --- Clustering scale helpers --- + +KERNELSPEC double minkowski_dot(const double* p1, const double* p2) { + return p1[0] * p2[0] - p1[1] * p2[1] - p1[2] * p2[2] - p1[3] * p2[3]; +} + +// mT^2 = E^2 - pz^2 for one parton (hadronic), or E^2 (lepton collider). +// Replaces Fortran DJB_clus from cluster.f:2201. +KERNELSPEC double djb_clus(const double* p, bool hadronic) { + double r = hadronic ? (p[0] - p[3]) * (p[0] + p[3]) : p[0] * p[0]; + return r < 0.0 ? 0.0 : r; +} + +// kt/Durham clustering measure for two partons. +// mass1/mass2 are the tracked clustering masses from masses[] (set by update_momenta), +// NOT the Lorentz-invariant masses computed from the 4-momentum. +// D is the jet-radius parameter from Fortran common /to_dj/D. +// Replaces Fortran dj_clus from cluster.f:2144. +KERNELSPEC double dj_clus( + const double* p1, + const double* p2, + double mass1, + double mass2, + bool hadronic, + double D +) { + constexpr double one_plus_tiny = 1.0 + 1e-6; + if (!hadronic) { + // Durham e+e- measure: 2*min(E1^2,E2^2)*(1-cos_theta) + double p1a = sqrt(p1[1] * p1[1] + p1[2] * p1[2] + p1[3] * p1[3]); + double p2a = sqrt(p2[1] * p2[1] + p2[2] * p2[2] + p2[3] * p2[3]); + if (p1a * p2a == 0.0) { + return 0.0; + } + double costh = (p1[1] * p2[1] + p1[2] * p2[2] + p1[3] * p2[3]) / (p1a * p2a); + return 2.0 * fmin(p1[0] * p1[0], p2[0] * p2[0]) * fmax(1.0 - costh, 0.0); + } + // hadronic: massless+massive pair clusters to the lighter parton's mT^2 + bool massive1 = (mass1 > 0.0); + bool massive2 = (mass2 > 0.0); + if (!massive1 && massive2) { + return djb_clus(p1, true) * one_plus_tiny; + } + if (massive1 && !massive2) { + return djb_clus(p2, true) * one_plus_tiny; + } + // both massless or both massive: generalised kt measure in (eta, phi) + double pt1_sq = p1[1] * p1[1] + p1[2] * p1[2]; + double pt2_sq = p2[1] * p2[1] + p2[2] * p2[2]; + if (pt1_sq == 0.0 || pt2_sq == 0.0) { + return 0.0; + } + double p1a = sqrt(pt1_sq + p1[3] * p1[3]); + double p2a = sqrt(pt2_sq + p2[3] * p2[3]); + double eta1 = 0.5 * log((p1a + p1[3]) / (p1a - p1[3])); + double eta2 = 0.5 * log((p2a + p2[3]) / (p2a - p2[3])); + double m_max_sq = fmax(mass1 * mass1, mass2 * mass2); + double dphi_cos = (p1[1] * p2[1] + p1[2] * p2[2]) / sqrt(pt1_sq * pt2_sq); + double r = m_max_sq + + fmin(pt1_sq, pt2_sq) * 2.0 * (cosh(eta1 - eta2) - dphi_cos) / (D * D); + return r < 0.0 ? 0.0 : r; +} + +// Clustering scale for the pair (momentum1=pi, momentum2=pj). +// mass1/mass2 are the tracked clustering masses for momentum1/momentum2. +// +// Parameters replacing Fortran globals: +// is_initial : momentum2 is a beam particle (Fortran: j<=2 in cluster_one_step) +// hadronic : hadronic collider (Fortran: lpp[] from run.inc) +// D : jet-radius parameter (Fortran: common /to_dj/D) +// +// massive_in/out1/out2 replace the Fortran get_clustering_type cl[0:2] bit-array: +// massive_in = cl[0] has bit 2 or 4 (intermediate/mother) +// massive_out1 = cl[1] has bit 2 or 4 (momentum1 = final-state particle pi) +// massive_out2 = cl[2] has bit 2 or 4 (momentum2 = pj) +// +// resonant replaces the iBWlist lookup in Fortran cluster_scale. +// mass_in/width_in are carried for future use; not consumed in the scale formula. +// +// Replaces Fortran cluster_scale from cluster.f:1159. +KERNELSPEC double compute_scale( + const double* momentum1, + const double* momentum2, + double mass1, + double mass2, + double mass_in, + double width_in, bool resonant, + bool is_initial, bool massive_in, bool massive_out1, - bool massive_out2 + bool massive_out2, + bool hadronic, + double D ) { - /* - initial state: - cluster_scale=sqrt(djb_clus(pi)) - ! prefer clustering when outgoing is in the direction of incoming - if(sign(1d0,pi(3)).ne.sign(1d0,pj(3))) - $ cluster_scale=cluster_scale*one_plus_tiny - - final state: - resonant: - cluster_scale=sqrt(max(sumdot(pi,pj,1d0),0d0)) - - 2 : 1 0 1 - dj_clus = DJB_clus(p1)*(1d0+1d-6) - - 3 : 1 1 0 - dj_clus = DJB_clus(p2)*(1d0+1d-6) - - 1 : 0 0 0 - 6 : 0 1 1 - 7 : 1 1 1 - pt1 = p1(1)**2+p1(2)**2 - pt2 = p2(1)**2+p2(2)**2 - if (pt1.eq.0d0 .or. pt2.eq.0d0) then - dj_clus=0d0 - return - endif - p1a = dsqrt(pt1+p1(3)**2) - p2a = dsqrt(pt2+p2(3)**2) - eta1 = 0.5d0*log((p1a+p1(3))/(p1a-p1(3))) - eta2 = 0.5d0*log((p2a+p2(3))/(p2a-p2(3))) - dj_clus = max(m1,m2)**2+min(pt1,pt2)*2d0*(cosh(eta1-eta2)- - & (p1(1)*p2(1)+p1(2)*p2(2))/dsqrt(pt1*pt2))/D**2 - if (dj_clus.lt.0d0 .or. dj_clus.ne.dj_clus) - & dj_clus=0d0 ! prevent numerical inaccuracies - - 4 : 0 1 0 - cluster_scale=sqrt(abs(dot(pj,(pi+pj))))/2d0 - - 5 : 0 0 1 - cluster_scale=sqrt(abs(dot(pi,(pi+pj))))/2d0 - - 8 : 1 0 0 - resonant: - cluster_scale=sqrt(max(sumdot(pi,pj,1d0),0d0)) - */ + constexpr double one_plus_tiny = 1.000001; + + if (is_initial) { + // scale = mT of the final-state parton; small penalty when it goes against the + // beam + double scale = sqrt(djb_clus(momentum1, hadronic)); + if ((momentum1[3] < 0.0) != (momentum2[3] < 0.0)) { + scale *= one_plus_tiny; + } + return scale; + } + + double sum[4] = { + momentum1[0] + momentum2[0], + momentum1[1] + momentum2[1], + momentum1[2] + momentum2[2], + momentum1[3] + momentum2[3] + }; + + if (resonant) { + return sqrt(fmax(minkowski_dot(sum, sum), 0.0)); + } + + // Map massive_in/out1/out2 booleans to Fortran get_clustering_type itypes: + // type 4: massless mother -> massive out1 + massless out2 + if (!massive_in && massive_out1 && !massive_out2) { + return sqrt(fabs(minkowski_dot(momentum2, sum))) / 2.0; + } + // type 5: massless mother -> massless out1 + massive out2 + if (!massive_in && !massive_out1 && massive_out2) { + return sqrt(fabs(minkowski_dot(momentum1, sum))) / 2.0; + } + // type 8: massive mother -> massless out1 + massless out2 + if (massive_in && !massive_out1 && !massive_out2) { + return sqrt(fmax(minkowski_dot(sum, sum), 0.0)); + } + // types 1,2,3,6,7: all-massless, massive-emitting-massless, all-massive + return sqrt(dj_clus(momentum1, momentum2, mass1, mass2, hadronic, D)); } template @@ -69,6 +283,7 @@ KERNELSPEC void mlm_clustering_hadronic( IIn state_machine, FIn masses, FIn widths, + FIn D, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -76,21 +291,24 @@ KERNELSPEC void mlm_clustering_hadronic( IOut diagram_index ) { static_assert(std::is_same_v, int>); + static_assert(std::is_same_v, double>); int state = 0, cluster_count = 0; int cluster_max = momenta.size() - 3; + int n_part = momenta.size(); double momenta_tmp[N_EXT_MAX][4]; + double masses_tmp[N_EXT_MAX]; + bool alive[N_EXT_MAX]; int cluster_history[N_EXT_MAX - 3]; - int cluster_scales[N_EXT_MAX - 3]; + double cluster_scales[N_EXT_MAX - 3]; - for (int i = 0; i < momenta.size(); ++i) { + for (int i = 0; i < n_part; ++i) { for (int j = 0; j < 4; ++j) { momenta_tmp[i][j] = momenta[i][j]; } + masses_tmp[i] = masses[i]; + alive[i] = true; } - // Iterate over the possible clustering states. This loop iterates over the possible - // clusterings at a given points as well as the full sequence of clusterings. - // Compared to two nested loops, this means that no thread has to wait on the GPU int win_next_state = -1, win_data = 0; double win_scale = 1e308; while (cluster_count < cluster_max) { @@ -103,13 +321,24 @@ KERNELSPEC void mlm_clustering_hadronic( bool massive_out1 = (data >> 25) & 1; bool massive_out2 = (data >> 26) & 1; bool is_last = (data >> 28) & 1; + bool is_initial = (particle2 < 2); + bool resonant = + (mass_index != 0) && (static_cast(widths[mass_index]) > 0.0); - double scale = compute_scale_hadronic( + double scale = compute_scale( momenta_tmp[particle1], momenta_tmp[particle2], + masses_tmp[particle1], + masses_tmp[particle2], + static_cast(masses[mass_index]), + static_cast(widths[mass_index]), + resonant, + is_initial, massive_in, massive_out1, - massive_out2 + massive_out2, + true, // hadronic + static_cast(D) ); if (scale < win_scale) { @@ -118,20 +347,27 @@ KERNELSPEC void mlm_clustering_hadronic( win_data = data; } if (is_last) { + int p1_win = win_data & 0xFF; + int p2_win = (win_data >> 8) & 0xFF; + bool win_resonant = ((win_data >> 16) & 0xFF) != 0 && + (static_cast(widths[(win_data >> 16) & 0xFF]) > 0.0); + update_momenta( + n_part, momenta_tmp, masses_tmp, alive, p1_win, p2_win, win_resonant + ); state = win_next_state; cluster_history[cluster_count] = win_data; cluster_scales[cluster_count] = win_scale; ++cluster_count; + win_scale = 1e308; } else { ++state; } } - // now determine the scales: - // renormalization scale: geometric mean of clustering scales, replacing those - // with is_qcd==false with the maximal scale - // factorization scale: minimal scale with is_qcd==true - double fac_scale = 1e308, max_scale = 0; + // Renormalization scale: geometric mean of QCD clustering scales + // (non-QCD entries replaced by the max scale). + // Factorization scale: smallest QCD clustering scale. + double fac_scale = 1e308, max_scale = 0.0; for (int i = 0; i < cluster_max; ++i) { double scale = cluster_scales[i]; bool is_qcd = (cluster_history[i] >> 27) & 1; @@ -143,17 +379,21 @@ KERNELSPEC void mlm_clustering_hadronic( } } - double ren_scale = 1.; + double ren_scale_val = 1.0; for (int i = 0; i < cluster_max; ++i) { double scale = cluster_scales[i]; bool is_qcd = (cluster_history[i] >> 27) & 1; - ren_scale *= is_qcd ? scale : max_scale; + ren_scale_val *= is_qcd ? scale : max_scale; } - ren_scale = pow(ren_scale, 1. / cluster_max); - if (fac_scale > ren_scale) { - fac_scale = ren_scale; + ren_scale_val = pow(ren_scale_val, 1.0 / cluster_max); + if (fac_scale > ren_scale_val) { + fac_scale = ren_scale_val; } + ren_scale = ren_scale_val; + fact_scale1 = fac_scale; + fact_scale2 = fac_scale; + // if multiple diagrams exist, pick one randomly int diag_count = state_machine[state]; int rand_index = static_cast(static_cast(random) * diag_count); From 85c81d49d210176982033d5dd4432562e34ed663 Mon Sep 17 00:00:00 2001 From: Theo Heimel Date: Thu, 25 Jun 2026 14:22:50 +0200 Subject: [PATCH 04/81] begin desloppification --- madspace/src/kernels/kinematics.hpp | 10 + madspace/src/kernels/mlm.hpp | 452 ++++++++++++++-------------- 2 files changed, 230 insertions(+), 232 deletions(-) diff --git a/madspace/src/kernels/kinematics.hpp b/madspace/src/kernels/kinematics.hpp index cacfdcac40..26705daf64 100644 --- a/madspace/src/kernels/kinematics.hpp +++ b/madspace/src/kernels/kinematics.hpp @@ -139,6 +139,16 @@ KERNELSPEC FVal esquare(FourMom p) { return p[1] * p[1] + p[2] * p[2] + p[3] * p[3]; } +template +KERNELSPEC FVal ldot(FourMom p1, FourMom p2) { + return p1[0] * p2[0] - p1[1] * p2[1] - p1[2] * p2[2] - p1[3] * p2[3]; +} + +template +KERNELSPEC FVal edot(FourMom p1, FourMom p2) { + return p1[1] * p2[1] + p1[2] * p2[2] + p1[3] * p2[3]; +} + template KERNELSPEC FourMom rotate(FourMom p, FourMom q) { auto qt2 = q[1] * q[1] + q[2] * q[2]; diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 62a7cec212..f6ea2b3360 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -3,252 +3,112 @@ #include "definitions.hpp" #include "kinematics.hpp" -#include - namespace madspace { namespace kernels { constexpr int N_EXT_MAX = 12; -// --- Low-level kinematic helpers (mirror boost and rotate_inverse from -// kinematics.hpp -// but operating on plain double[4] so they can be used with the local scratch -// arrays) --- - -// Lorentz boost of k by the frame defined by p_boost (sign=+1 forward, -1 inverse). -// Mirrors boost from kinematics.hpp. -KERNELSPEC void -boost4(const double k[4], const double p_boost[4], double sign, double out[4]) { - double p2 = p_boost[0] * p_boost[0] - p_boost[1] * p_boost[1] - - p_boost[2] * p_boost[2] - p_boost[3] * p_boost[3]; - double rsq = sqrt(p2 > EPS2 ? p2 : EPS2); - double k_dot_p = k[1] * p_boost[1] + k[2] * p_boost[2] + k[3] * p_boost[3]; - double e = (k[0] * p_boost[0] + sign * k_dot_p) / rsq; - double c1 = sign * (k[0] + e) / (rsq + p_boost[0]); - out[0] = e; - out[1] = k[1] + c1 * p_boost[1]; - out[2] = k[2] + c1 * p_boost[2]; - out[3] = k[3] + c1 * p_boost[3]; -} - -// Rotate p into the canonical frame where q's spatial direction is the z-axis. -// Mirrors rotate_inverse from kinematics.hpp. -KERNELSPEC void rotate_inv4(const double p[4], const double q[4], double out[4]) { - double qt2 = q[1] * q[1] + q[2] * q[2]; - double qq2 = qt2 + q[3] * q[3]; - double qt = sqrt(qt2 > EPS2 ? qt2 : EPS2); - double qq = sqrt(qq2 > EPS2 ? qq2 : EPS2); - out[0] = p[0]; - if (qt2 < EPS2) { - double s = (q[3] < 0.0) ? -1.0 : 1.0; - out[1] = s * p[1]; - out[2] = p[2]; - out[3] = s * p[3]; - return; - } - out[1] = q[1] * q[3] / (qq * qt) * p[1] + q[2] * q[3] / (qq * qt) * p[2] - - p[3] * qt / qq; - out[2] = -q[2] / qt * p[1] + q[1] / qt * p[2]; - out[3] = q[1] / qq * p[1] + q[2] / qq * p[2] + q[3] / qq * p[3]; -} - -// --- Mass tracking --- - -// Update momenta and separately-tracked masses after one clustering step. -// -// i_remove : index of the final-state particle absorbed into the combination (always >= -// 2) j_keep : index of the particle kept with the combined momentum (0/1 = initial -// state, else final) is_bw : Breit-Wigner resonance — combined mass = invariant -// mass; else max of daughters alive : alive[i] is true while particle i has not yet -// been removed n_part : total number of particles (bound for the alive[] loop) -// -// Mass update rules from Fortran update_momenta: -// Final-state non-BW : mass[j_keep] = max(mass[j_keep], mass[i_remove]) -// Final-state BW : mass[j_keep] = sqrt((p_i + p_j)^2) -// Initial-state (XOR) : mass[j_keep] = max(mass[j_keep], mass[i_remove]) (exactly -// one massive) Initial-state (same): mass[j_keep] = 0 (both massive or both massless) -// -// Momentum update rules: -// Final-state : momenta[j_keep] += momenta[i_remove] -// Initial-state: momenta[j_keep] -= momenta[i_remove]; then if CM s > 100 GeV^2, -// boost all alive particles to the new CM frame and rotate so that -// the new j_keep momentum is along z. -KERNELSPEC void update_momenta( - int n_part, - double momenta[N_EXT_MAX][4], - double masses[N_EXT_MAX], - bool alive[N_EXT_MAX], - int i_remove, - int j_keep, - bool is_bw -) { - alive[i_remove] = false; - - if (j_keep < 2) { - // initial-state clustering - int j_other = 1 - j_keep; // the other beam particle (0-indexed) - for (int k = 0; k < 4; ++k) { - momenta[j_keep][k] -= momenta[i_remove][k]; - } - - // mass: max if exactly one daughter is massive, else 0 - bool m0 = (masses[j_keep] > 0.0); - bool m1 = (masses[i_remove] > 0.0); - masses[j_keep] = (m0 != m1) ? fmax(masses[j_keep], masses[i_remove]) : 0.0; - - // CM boost vector: (E_tot, -px_tot, -py_tot, -pz_tot) of the two beam particles - double pcmsp[4]; - pcmsp[0] = momenta[j_keep][0] + momenta[j_other][0]; - pcmsp[1] = -(momenta[j_keep][1] + momenta[j_other][1]); - pcmsp[2] = -(momenta[j_keep][2] + momenta[j_other][2]); - pcmsp[3] = -(momenta[j_keep][3] + momenta[j_other][3]); - - double inv_sq = pcmsp[0] * pcmsp[0] - pcmsp[1] * pcmsp[1] - - pcmsp[2] * pcmsp[2] - pcmsp[3] * pcmsp[3]; - if (inv_sq > 100.0) { - // boost j_keep to CM frame to define the rotation axis - double jkeep_cm[4]; - boost4(momenta[j_keep], pcmsp, 1.0, jkeep_cm); - - // boost all alive particles and rotate so that j_keep is along z - for (int j = 0; j < n_part; ++j) { - if (!alive[j]) { - continue; - } - double p_cm[4], p_rot[4]; - boost4(momenta[j], pcmsp, 1.0, p_cm); - rotate_inv4(p_cm, jkeep_cm, p_rot); - for (int k = 0; k < 4; ++k) { - momenta[j][k] = p_rot[k]; - } - } - } - // if inv_sq <= 100: no boost needed, momenta already correct after subtraction - - } else { - // final-state clustering: sum momenta, update mass - for (int k = 0; k < 4; ++k) { - momenta[j_keep][k] += momenta[i_remove][k]; - } - - if (is_bw) { - const double* p = momenta[j_keep]; - double m2 = p[0] * p[0] - p[1] * p[1] - p[2] * p[2] - p[3] * p[3]; - masses[j_keep] = sqrt(m2 > 0.0 ? m2 : 0.0); - } else { - masses[j_keep] = fmax(masses[j_keep], masses[i_remove]); - } - } -} - -// --- Clustering scale helpers --- - -KERNELSPEC double minkowski_dot(const double* p1, const double* p2) { - return p1[0] * p2[0] - p1[1] * p2[1] - p1[2] * p2[2] - p1[3] * p2[3]; -} - -// mT^2 = E^2 - pz^2 for one parton (hadronic), or E^2 (lepton collider). -// Replaces Fortran DJB_clus from cluster.f:2201. -KERNELSPEC double djb_clus(const double* p, bool hadronic) { - double r = hadronic ? (p[0] - p[3]) * (p[0] + p[3]) : p[0] * p[0]; - return r < 0.0 ? 0.0 : r; +// mT^2 = E^2 - pz^2 (hadronic) or E^2 (lepton collider). +// based on djb_clus from Template/NLO/SubProcesses/cluster.f +template +KERNELSPEC FVal djb_clus(FourMom p, bool hadronic) { + auto r = hadronic ? (p[0] - p[3]) * (p[0] + p[3]) : p[0] * p[0]; + return max(r, 0.0); } // kt/Durham clustering measure for two partons. -// mass1/mass2 are the tracked clustering masses from masses[] (set by update_momenta), -// NOT the Lorentz-invariant masses computed from the 4-momentum. -// D is the jet-radius parameter from Fortran common /to_dj/D. -// Replaces Fortran dj_clus from cluster.f:2144. -KERNELSPEC double dj_clus( - const double* p1, - const double* p2, - double mass1, - double mass2, +// mass1/mass2 are the tracked clustering masses kept in a separate array and updated by +// update_momenta — NOT the Lorentz-invariant masses computed from the 4-momentum. +// jet_radius is the jet-radius parameter (Fortran common /to_dj/D). +// based on dj_clus from Template/NLO/SubProcesses/cluster.f +template +KERNELSPEC FVal dj_clus( + FourMom p1, + FourMom p2, + FVal mass1, + FVal mass2, bool hadronic, - double D + FVal jet_radius ) { constexpr double one_plus_tiny = 1.0 + 1e-6; if (!hadronic) { - // Durham e+e- measure: 2*min(E1^2,E2^2)*(1-cos_theta) - double p1a = sqrt(p1[1] * p1[1] + p1[2] * p1[2] + p1[3] * p1[3]); - double p2a = sqrt(p2[1] * p2[1] + p2[2] * p2[2] + p2[3] * p2[3]); + // Durham e+e- measure: 2*min(E1^2, E2^2)*(1 - cos_theta) + auto p1a = sqrt(esquare(p1)); + auto p2a = sqrt(esquare(p2)); if (p1a * p2a == 0.0) { return 0.0; } - double costh = (p1[1] * p2[1] + p1[2] * p2[2] + p1[3] * p2[3]) / (p1a * p2a); - return 2.0 * fmin(p1[0] * p1[0], p2[0] * p2[0]) * fmax(1.0 - costh, 0.0); + auto costh = edot(p1, p2) / (p1a * p2a); + return 2.0 * min(p1[0] * p1[0], p2[0] * p2[0]) * max(1.0 - costh, 0.0); } // hadronic: massless+massive pair clusters to the lighter parton's mT^2 bool massive1 = (mass1 > 0.0); bool massive2 = (mass2 > 0.0); if (!massive1 && massive2) { - return djb_clus(p1, true) * one_plus_tiny; + return djb_clus(p1, true) * one_plus_tiny; } if (massive1 && !massive2) { - return djb_clus(p2, true) * one_plus_tiny; + return djb_clus(p2, true) * one_plus_tiny; } // both massless or both massive: generalised kt measure in (eta, phi) - double pt1_sq = p1[1] * p1[1] + p1[2] * p1[2]; - double pt2_sq = p2[1] * p2[1] + p2[2] * p2[2]; + auto pt1_sq = p1[1] * p1[1] + p1[2] * p1[2]; + auto pt2_sq = p2[1] * p2[1] + p2[2] * p2[2]; if (pt1_sq == 0.0 || pt2_sq == 0.0) { return 0.0; } - double p1a = sqrt(pt1_sq + p1[3] * p1[3]); - double p2a = sqrt(pt2_sq + p2[3] * p2[3]); - double eta1 = 0.5 * log((p1a + p1[3]) / (p1a - p1[3])); - double eta2 = 0.5 * log((p2a + p2[3]) / (p2a - p2[3])); - double m_max_sq = fmax(mass1 * mass1, mass2 * mass2); - double dphi_cos = (p1[1] * p2[1] + p1[2] * p2[2]) / sqrt(pt1_sq * pt2_sq); - double r = m_max_sq + - fmin(pt1_sq, pt2_sq) * 2.0 * (cosh(eta1 - eta2) - dphi_cos) / (D * D); - return r < 0.0 ? 0.0 : r; + auto p1a = sqrt(pt1_sq + p1[3] * p1[3]); + auto p2a = sqrt(pt2_sq + p2[3] * p2[3]); + auto eta1 = 0.5 * log((p1a + p1[3]) / (p1a - p1[3])); + auto eta2 = 0.5 * log((p2a + p2[3]) / (p2a - p2[3])); + auto m_max_sq = max(mass1 * mass1, mass2 * mass2); + auto dphi_cos = (p1[1] * p2[1] + p1[2] * p2[2]) / sqrt(pt1_sq * pt2_sq); + return max( + m_max_sq + + min(pt1_sq, pt2_sq) * 2.0 * (cosh(eta1 - eta2) - dphi_cos) / + (jet_radius * jet_radius), + 0.0 + ); } // Clustering scale for the pair (momentum1=pi, momentum2=pj). // mass1/mass2 are the tracked clustering masses for momentum1/momentum2. // -// Parameters replacing Fortran globals: -// is_initial : momentum2 is a beam particle (Fortran: j<=2 in cluster_one_step) -// hadronic : hadronic collider (Fortran: lpp[] from run.inc) -// D : jet-radius parameter (Fortran: common /to_dj/D) -// -// massive_in/out1/out2 replace the Fortran get_clustering_type cl[0:2] bit-array: -// massive_in = cl[0] has bit 2 or 4 (intermediate/mother) -// massive_out1 = cl[1] has bit 2 or 4 (momentum1 = final-state particle pi) -// massive_out2 = cl[2] has bit 2 or 4 (momentum2 = pj) +// Parameters replacing Fortran globals / derived quantities: +// is_initial : momentum2 is a beam particle (Fortran: j<=2 in cluster_one_step) +// hadronic : hadronic collider (Fortran: lpp[] from run.inc) +// jet_radius : jet-radius parameter (Fortran: common /to_dj/D) +// massive_in/out1/out2 replace the Fortran get_clustering_type cl[0:2] bit-array +// resonant : replaces the iBWlist lookup in Fortran cluster_scale +// mass_in/width_in: carried for future use; not consumed in the scale formula // -// resonant replaces the iBWlist lookup in Fortran cluster_scale. -// mass_in/width_in are carried for future use; not consumed in the scale formula. -// -// Replaces Fortran cluster_scale from cluster.f:1159. -KERNELSPEC double compute_scale( - const double* momentum1, - const double* momentum2, - double mass1, - double mass2, - double mass_in, - double width_in, +// based on cluster_scale from Template/NLO/SubProcesses/cluster.f +template +KERNELSPEC FVal compute_scale( + FourMom momentum1, + FourMom momentum2, + FVal mass1, + FVal mass2, bool resonant, bool is_initial, bool massive_in, bool massive_out1, bool massive_out2, bool hadronic, - double D + FVal jet_radius ) { constexpr double one_plus_tiny = 1.000001; if (is_initial) { - // scale = mT of the final-state parton; small penalty when it goes against the - // beam - double scale = sqrt(djb_clus(momentum1, hadronic)); + // scale = mT of the final-state parton + // small penalty when it goes against the beam + auto scale = sqrt(djb_clus(momentum1, hadronic)); if ((momentum1[3] < 0.0) != (momentum2[3] < 0.0)) { - scale *= one_plus_tiny; + scale = scale * one_plus_tiny; } return scale; } - double sum[4] = { + FourMom sum = { momentum1[0] + momentum2[0], momentum1[1] + momentum2[1], momentum1[2] + momentum2[2], @@ -256,50 +116,120 @@ KERNELSPEC double compute_scale( }; if (resonant) { - return sqrt(fmax(minkowski_dot(sum, sum), 0.0)); + return sqrt(max(lsquare(sum), 0.0)); } - // Map massive_in/out1/out2 booleans to Fortran get_clustering_type itypes: - // type 4: massless mother -> massive out1 + massless out2 + // Map massive_in/out1/out2 to Fortran get_clustering_type itypes: if (!massive_in && massive_out1 && !massive_out2) { - return sqrt(fabs(minkowski_dot(momentum2, sum))) / 2.0; + return sqrt(fabs(ldot(momentum2, sum))) / 2.0; } - // type 5: massless mother -> massless out1 + massive out2 if (!massive_in && !massive_out1 && massive_out2) { - return sqrt(fabs(minkowski_dot(momentum1, sum))) / 2.0; + return sqrt(fabs(ldot(momentum1, sum))) / 2.0; } - // type 8: massive mother -> massless out1 + massless out2 if (massive_in && !massive_out1 && !massive_out2) { - return sqrt(fmax(minkowski_dot(sum, sum), 0.0)); + return sqrt(max(lsquare(sum), 0.0)); } - // types 1,2,3,6,7: all-massless, massive-emitting-massless, all-massive - return sqrt(dj_clus(momentum1, momentum2, mass1, mass2, hadronic, D)); + return sqrt(dj_clus(momentum1, momentum2, mass1, mass2, hadronic, jet_radius)); } +// Update momenta and the separately-tracked masses after one clustering step. +// Uses boost and rotate_inverse from kinematics.hpp. +// +// i_remove : final-state particle absorbed into the combination (always >= 2, +// 0-indexed) j_keep : particle kept with the combined momentum (0/1 = initial state, +// else final) is_bw : BW resonance — combined mass = invariant mass of sum; else max +// of daughters +// +// Mass update rules mirror Fortran update_momenta (cluster.f:871): +// Final non-BW : mass[j_keep] = max(mass[j_keep], mass[i_remove]) +// Final BW : mass[j_keep] = sqrt((pi + pj)^2) +// Initial, one massive: mass[j_keep] = max(mass[j_keep], mass[i_remove]) +// Initial, same : mass[j_keep] = 0 template -KERNELSPEC void mlm_clustering_hadronic( +KERNELSPEC void update_momenta( + int n_part, + FourMom momenta[N_EXT_MAX], + FVal masses[N_EXT_MAX], + bool alive[N_EXT_MAX], + int i_remove, + int j_keep, + bool is_bw +) { + alive[i_remove] = false; + + if (j_keep < 2) { + // initial-state clustering + int j_other = 1 - j_keep; + for (int k = 0; k < 4; ++k) { + momenta[j_keep][k] -= momenta[i_remove][k]; + } + + // mass: take max if exactly one daughter is massive, else 0 + bool m0 = (masses[j_keep] > 0.0); + bool m1 = (masses[i_remove] > 0.0); + masses[j_keep] = + (m0 != m1) ? max(masses[j_keep], masses[i_remove]) : FVal(0.0); + + // CM boost vector: (E_tot, -px_tot, -py_tot, -pz_tot) of the two beam particles + FourMom pcmsp = { + momenta[j_keep][0] + momenta[j_other][0], + -(momenta[j_keep][1] + momenta[j_other][1]), + -(momenta[j_keep][2] + momenta[j_other][2]), + -(momenta[j_keep][3] + momenta[j_other][3]) + }; + + if (lsquare(pcmsp) > 100.0) { + // boost j_keep to CM to define the rotation axis, then apply to all alive + // particles + auto jkeep_cm = boost(momenta[j_keep], pcmsp, 1.0); + for (int j = 0; j < n_part; ++j) { + if (!alive[j]) { + continue; + } + momenta[j] = + rotate_inverse(boost(momenta[j], pcmsp, 1.0), jkeep_cm); + } + } + + } else { + // final-state clustering + for (int k = 0; k < 4; ++k) { + momenta[j_keep][k] += momenta[i_remove][k]; + } + + if (is_bw) { + masses[j_keep] = sqrt(max(lsquare(momenta[j_keep]), 0.0)); + } else { + masses[j_keep] = max(masses[j_keep], masses[i_remove]); + } + } +} + +template +KERNELSPEC void mlm_clustering( FIn momenta, FIn random, IIn state_machine, FIn masses, FIn widths, - FIn D, + FIn jet_radius, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, FOut outgoing_scales, - IOut diagram_index + IOut diagram_index, + bool hadronic ) { static_assert(std::is_same_v, int>); static_assert(std::is_same_v, double>); int state = 0, cluster_count = 0; int cluster_max = momenta.size() - 3; int n_part = momenta.size(); - double momenta_tmp[N_EXT_MAX][4]; - double masses_tmp[N_EXT_MAX]; + FourMom momenta_tmp[N_EXT_MAX]; + FVal masses_tmp[N_EXT_MAX]; bool alive[N_EXT_MAX]; int cluster_history[N_EXT_MAX - 3]; - double cluster_scales[N_EXT_MAX - 3]; + FVal cluster_scales[N_EXT_MAX - 3]; for (int i = 0; i < n_part; ++i) { for (int j = 0; j < 4; ++j) { @@ -310,7 +240,7 @@ KERNELSPEC void mlm_clustering_hadronic( } int win_next_state = -1, win_data = 0; - double win_scale = 1e308; + FVal win_scale = 1e308; while (cluster_count < cluster_max) { int data = state_machine[state]; int next_state = state_machine[state + 1]; @@ -322,23 +252,22 @@ KERNELSPEC void mlm_clustering_hadronic( bool massive_out2 = (data >> 26) & 1; bool is_last = (data >> 28) & 1; bool is_initial = (particle2 < 2); - bool resonant = - (mass_index != 0) && (static_cast(widths[mass_index]) > 0.0); + bool resonant = (mass_index != 0) && (widths[mass_index] > 0.0); - double scale = compute_scale( + FVal scale = compute_scale( momenta_tmp[particle1], momenta_tmp[particle2], masses_tmp[particle1], masses_tmp[particle2], - static_cast(masses[mass_index]), - static_cast(widths[mass_index]), + masses[mass_index], + widths[mass_index], resonant, is_initial, massive_in, massive_out1, massive_out2, - true, // hadronic - static_cast(D) + true, + jet_radius ); if (scale < win_scale) { @@ -349,9 +278,9 @@ KERNELSPEC void mlm_clustering_hadronic( if (is_last) { int p1_win = win_data & 0xFF; int p2_win = (win_data >> 8) & 0xFF; - bool win_resonant = ((win_data >> 16) & 0xFF) != 0 && - (static_cast(widths[(win_data >> 16) & 0xFF]) > 0.0); - update_momenta( + int mi_win = (win_data >> 16) & 0xFF; + bool win_resonant = (mi_win != 0) && (widths[mi_win] > 0.0); + update_momenta( n_part, momenta_tmp, masses_tmp, alive, p1_win, p2_win, win_resonant ); state = win_next_state; @@ -365,11 +294,11 @@ KERNELSPEC void mlm_clustering_hadronic( } // Renormalization scale: geometric mean of QCD clustering scales - // (non-QCD entries replaced by the max scale). + // (non-QCD entries replaced by the maximum scale). // Factorization scale: smallest QCD clustering scale. - double fac_scale = 1e308, max_scale = 0.0; + FVal fac_scale = 1e308, max_scale = 0.0; for (int i = 0; i < cluster_max; ++i) { - double scale = cluster_scales[i]; + FVal scale = cluster_scales[i]; bool is_qcd = (cluster_history[i] >> 27) & 1; if (is_qcd && scale < fac_scale) { fac_scale = scale; @@ -379,9 +308,9 @@ KERNELSPEC void mlm_clustering_hadronic( } } - double ren_scale_val = 1.0; + FVal ren_scale_val = 1.0; for (int i = 0; i < cluster_max; ++i) { - double scale = cluster_scales[i]; + FVal scale = cluster_scales[i]; bool is_qcd = (cluster_history[i] >> 27) & 1; ren_scale_val *= is_qcd ? scale : max_scale; } @@ -394,14 +323,73 @@ KERNELSPEC void mlm_clustering_hadronic( fact_scale1 = fac_scale; fact_scale2 = fac_scale; - // if multiple diagrams exist, pick one randomly int diag_count = state_machine[state]; - int rand_index = static_cast(static_cast(random) * diag_count); + int rand_index = static_cast(FVal(random) * diag_count); if (rand_index >= diag_count) { rand_index = diag_count - 1; } diagram_index = state_machine[state + rand_index + 1]; } +template +KERNELSPEC void mlm_clustering_hadronic( + FIn momenta, + FIn random, + IIn state_machine, + FIn masses, + FIn widths, + FIn jet_radius, + FOut ren_scale, + FOut fact_scale1, + FOut fact_scale2, + FOut outgoing_scales, + IOut diagram_index +) { + mlm_clustering( + momenta, + random, + state_machine, + masses, + widths, + jet_radius, + ren_scale, + fact_scale1, + fact_scale2, + outgoing_scales, + diagram_index, + true + ); +} + +template +KERNELSPEC void mlm_clustering_leptonic( + FIn momenta, + FIn random, + IIn state_machine, + FIn masses, + FIn widths, + FIn jet_radius, + FOut ren_scale, + FOut fact_scale1, + FOut fact_scale2, + FOut outgoing_scales, + IOut diagram_index +) { + mlm_clustering( + momenta, + random, + state_machine, + masses, + widths, + jet_radius, + ren_scale, + fact_scale1, + fact_scale2, + outgoing_scales, + diagram_index, + false + ); +} + } // namespace kernels } // namespace madspace From 306950c5ffff3b4947e7f4e0c58ed93325303d22 Mon Sep 17 00:00:00 2001 From: Theo Heimel Date: Thu, 25 Jun 2026 20:28:31 +0200 Subject: [PATCH 05/81] more progress on mlm --- madspace/CMakeLists.txt | 1 + .../compgraphs/function_builder_mixin.inc | 9 +- .../madspace/compgraphs/opcode_mixin.inc | 97 ++++++++-------- madspace/instruction_set.yaml | 61 +++++++++- .../src/compgraphs/instruction_set_mixin.inc | 99 ++++++++-------- madspace/src/cpu/runtime_backward_mixin.inc | 42 +++---- madspace/src/cpu/runtime_mixin.inc | 99 ++++++++-------- madspace/src/gpu/runtime_backward_mixin.inc | 42 +++---- madspace/src/gpu/runtime_mixin.inc | 99 ++++++++-------- madspace/src/kernels/kernels.hpp | 1 + madspace/src/kernels/kinematics.hpp | 2 +- madspace/src/kernels/mlm.hpp | 108 +++++++++++------- madspace/src/python/instruction_set.hpp | 3 +- 13 files changed, 379 insertions(+), 284 deletions(-) diff --git a/madspace/CMakeLists.txt b/madspace/CMakeLists.txt index b54850aa0a..df2e82c484 100644 --- a/madspace/CMakeLists.txt +++ b/madspace/CMakeLists.txt @@ -302,6 +302,7 @@ set( src/kernels/rambo.hpp src/kernels/cuts.hpp src/kernels/chili.hpp + src/kernels/mlm.hpp src/kernels/nn.hpp src/kernels/observables.hpp src/kernels/discrete.hpp diff --git a/madspace/include/madspace/compgraphs/function_builder_mixin.inc b/madspace/include/madspace/compgraphs/function_builder_mixin.inc index ea10552d04..ad31f0fb0c 100644 --- a/madspace/include/madspace/compgraphs/function_builder_mixin.inc +++ b/madspace/include/madspace/compgraphs/function_builder_mixin.inc @@ -461,8 +461,13 @@ Value scale_partonic_energy(Value momenta) { return instruction("scale_partonic_energy", {momenta})[0]; } -std::array mlm_clustering_hadronic(Value momenta, Value random, Value state_machine, Value masses, Value widths) { - auto output_vector = instruction("mlm_clustering_hadronic", {momenta, random, state_machine, masses, widths}); +std::array mlm_clustering_hadronic(Value momenta, Value random, Value state_machine, Value external_masses, Value bw_masses, Value bw_widths, Value bw_cutoffs, Value jet_radius) { + auto output_vector = instruction("mlm_clustering_hadronic", {momenta, random, state_machine, external_masses, bw_masses, bw_widths, bw_cutoffs, jet_radius}); + return {output_vector[0], output_vector[1], output_vector[2], output_vector[3], output_vector[4]}; +} + +std::array mlm_clustering_leptonic(Value momenta, Value random, Value state_machine, Value external_masses, Value bw_masses, Value bw_widths, Value bw_cutoffs, Value jet_radius) { + auto output_vector = instruction("mlm_clustering_leptonic", {momenta, random, state_machine, external_masses, bw_masses, bw_widths, bw_cutoffs, jet_radius}); return {output_vector[0], output_vector[1], output_vector[2], output_vector[3], output_vector[4]}; } diff --git a/madspace/include/madspace/compgraphs/opcode_mixin.inc b/madspace/include/madspace/compgraphs/opcode_mixin.inc index 4d5bc962a4..ba363e13a1 100644 --- a/madspace/include/madspace/compgraphs/opcode_mixin.inc +++ b/madspace/include/madspace/compgraphs/opcode_mixin.inc @@ -103,51 +103,52 @@ scale_transverse_mass = 101, scale_half_transverse_mass = 102, scale_partonic_energy = 103, mlm_clustering_hadronic = 104, -chili_forward = 105, -chili_inverse = 106, -matrix_element = 107, -collect_channel_weights = 108, -interpolate_pdf = 109, -interpolate_alpha_s = 110, -matmul = 111, -relu = 112, -leaky_relu = 113, -elu = 114, -gelu = 115, -sigmoid = 116, -softplus = 117, -rqs_reshape = 118, -rqs_find_bin = 119, -rqs_forward = 120, -rqs_inverse = 121, -softmax = 122, -softmax_prior = 123, -sample_discrete = 124, -sample_discrete_inverse = 125, -sample_discrete_probs = 126, -sample_discrete_probs_inverse = 127, -discrete_histogram = 128, -permute_momenta = 129, -gather = 130, -gather_int = 131, -select_int = 132, -select = 133, -select_vector = 134, -argsort = 135, -quantile = 136, -one_hot = 137, -madnis_abs_weight = 138, -madnis_softclip = 139, -madnis_variance = 140, -madnis_single_channel_variance = 141, -madnis_multi_channel_variance = 142, -nonzero = 143, -batch_gather = 144, -batch_scatter = 145, -random = 146, -random_int = 147, -unweight = 148, -vegas_forward = 149, -vegas_inverse = 150, -vegas_histogram = 151, -histogram = 152 +mlm_clustering_leptonic = 105, +chili_forward = 106, +chili_inverse = 107, +matrix_element = 108, +collect_channel_weights = 109, +interpolate_pdf = 110, +interpolate_alpha_s = 111, +matmul = 112, +relu = 113, +leaky_relu = 114, +elu = 115, +gelu = 116, +sigmoid = 117, +softplus = 118, +rqs_reshape = 119, +rqs_find_bin = 120, +rqs_forward = 121, +rqs_inverse = 122, +softmax = 123, +softmax_prior = 124, +sample_discrete = 125, +sample_discrete_inverse = 126, +sample_discrete_probs = 127, +sample_discrete_probs_inverse = 128, +discrete_histogram = 129, +permute_momenta = 130, +gather = 131, +gather_int = 132, +select_int = 133, +select = 134, +select_vector = 135, +argsort = 136, +quantile = 137, +one_hot = 138, +madnis_abs_weight = 139, +madnis_softclip = 140, +madnis_variance = 141, +madnis_single_channel_variance = 142, +madnis_multi_channel_variance = 143, +nonzero = 144, +batch_gather = 145, +batch_scatter = 146, +random = 147, +random_int = 148, +unweight = 149, +vegas_forward = 150, +vegas_inverse = 151, +vegas_histogram = 152, +histogram = 153 diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index 0a4d9413c9..9ff993a92a 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -1958,11 +1958,64 @@ mlm_clustering_hadronic: - name: state_machine type: [int, single, m] desc: - - name: masses - type: [double, single, k] + - name: external_masses + type: [float, n] desc: - - name: widths - type: [double, single, k] + - name: bw_masses + type: [float, single, k] + desc: + - name: bw_widths + type: [float, single, k] + desc: + - name: bw_cutoffs + type: [float, single] + desc: + - name: jet_radius + type: [float, single] + desc: + outputs: + - name: ren_scale + type: [float] + desc: + - name: fact_scale1 + type: [float] + desc: + - name: fact_scale2 + type: [float] + desc: + - name: outgoing_scales + type: [float, n-2] + desc: + - name: diagram_index + type: [int] + desc: + vectorized: False + +mlm_clustering_leptonic: + inputs: + - name: momenta + type: [float, n, 4] + desc: + - name: random + type: [float] + desc: + - name: state_machine + type: [int, single, m] + desc: + - name: external_masses + type: [float, n] + desc: + - name: bw_masses + type: [float, single, k] + desc: + - name: bw_widths + type: [float, single, k] + desc: + - name: bw_cutoffs + type: [float, single] + desc: + - name: jet_radius + type: [float, single] desc: outputs: - name: ren_scale diff --git a/madspace/src/compgraphs/instruction_set_mixin.inc b/madspace/src/compgraphs/instruction_set_mixin.inc index e71e59dc25..be0daf3231 100644 --- a/madspace/src/compgraphs/instruction_set_mixin.inc +++ b/madspace/src/compgraphs/instruction_set_mixin.inc @@ -119,53 +119,54 @@ InstructionOwner instructions[] { mi("scale_transverse_mass", 101, true, {{DataType::dt_float, false, {"n", 4}, false}}, {{DataType::dt_float, false, {}, false}}), mi("scale_half_transverse_mass", 102, true, {{DataType::dt_float, false, {"n", 4}, false}}, {{DataType::dt_float, false, {}, false}}), mi("scale_partonic_energy", 103, true, {{DataType::dt_float, false, {"n", 4}, false}}, {{DataType::dt_float, false, {}, false}}), - mi("mlm_clustering_hadronic", 104, true, {{DataType::dt_float, false, {"n", 4}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {"m"}, false}, {DataType::dt_double, true, {"k"}, false}, {DataType::dt_double, true, {"k"}, false}}, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {"n-2"}, false}, {DataType::dt_int, false, {}, false}}), - mi("chili_forward", 105, true, {{DataType::dt_float, false, {"3n-2"}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {"n+2", 4}, false}, {DataType::dt_float, false, {}, false}}), - mi("chili_inverse", 106, true, {{DataType::dt_float, false, {"n+2", 4}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {"3n-2"}, false}, {DataType::dt_float, false, {}, false}}), - InstructionOwner(new MatrixElementInstruction(107, true)), - mi("collect_channel_weights", 108, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_int, true, {"n"}, false}, {DataType::dt_int, true, {"c"}, true}}, {{DataType::dt_float, false, {"c"}, false}}), - mi("interpolate_pdf", 109, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, false, {"n"}, false}, {DataType::dt_float, true, {"a"}, false}, {DataType::dt_float, true, {"b"}, false}, {DataType::dt_float, true, {16, "c", "d"}, false}}, {{DataType::dt_float, false, {"n"}, false}}), - mi("interpolate_alpha_s", 110, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, true, {"b+1"}, false}, {DataType::dt_float, true, {4, "b"}, false}}, {{DataType::dt_float, false, {}, false}}), - mi("matmul", 111, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, true, {"m", "n"}, false}, {DataType::dt_float, true, {"m"}, false}}, {{DataType::dt_float, false, {"m"}, false}}), - mi("relu", 112, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), - mi("leaky_relu", 113, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), - mi("elu", 114, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), - mi("gelu", 115, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), - mi("sigmoid", 116, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), - mi("softplus", 117, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), - InstructionOwner(new RqsReshapeInstruction(118, true)), - mi("rqs_find_bin", 119, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n", "b"}, false}, {DataType::dt_float, false, {"n", "b"}, false}, {DataType::dt_float, false, {"n", "b+1"}, false}}, {{DataType::dt_float, false, {"n", 6}, false}}), - mi("rqs_forward", 120, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n", 6}, false}}, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}), - mi("rqs_inverse", 121, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n", 6}, false}}, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}), - mi("softmax", 122, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), - mi("softmax_prior", 123, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {"n"}, false}}), - mi("sample_discrete", 124, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {}, false}}, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {}, false}}), - mi("sample_discrete_inverse", 125, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_int, true, {}, false}}, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}), - mi("sample_discrete_probs", 126, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {}, false}}), - mi("sample_discrete_probs_inverse", 127, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}), - mi("discrete_histogram", 128, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {"n"}, true}}, {{DataType::dt_float, true, {"n"}, false}, {DataType::dt_int, true, {"n"}, false}}), - mi("permute_momenta", 129, true, {{DataType::dt_float, false, {"n", 4}, false}, {DataType::dt_int, true, {"m", "n"}, false}, {DataType::dt_int, false, {}, false}}, {{DataType::dt_float, false, {"n", 4}, false}}), - mi("gather", 130, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {}, false}}), - mi("gather_int", 131, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_int, false, {"n"}, false}}, {{DataType::dt_int, false, {}, false}}), - mi("select_int", 132, true, {{DataType::dt_int, false, {"n"}, false}, {DataType::dt_int, false, {"m"}, false}}, {{DataType::dt_int, false, {"m"}, false}}), - mi("select", 133, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_int, false, {"m"}, false}}, {{DataType::dt_float, false, {"m"}, false}}), - mi("select_vector", 134, true, {{DataType::dt_float, false, {"n", "k"}, false}, {DataType::dt_int, false, {"m"}, false}}, {{DataType::dt_float, false, {"m", "k"}, false}}), - mi("argsort", 135, true, {{DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_int, false, {"n"}, false}}), - mi("quantile", 136, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, true, {}, false}}, {{DataType::dt_float, true, {}, false}}), - mi("one_hot", 137, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_int, true, {"n"}, true}}, {{DataType::dt_float, false, {"n"}, false}}), - mi("madnis_abs_weight", 138, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}, {{DataType::dt_float, false, {}, false}}), - mi("madnis_softclip", 139, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}, {{DataType::dt_float, false, {}, false}}), - mi("madnis_variance", 140, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}, {{DataType::dt_float, false, {}, false}}), - mi("madnis_single_channel_variance", 141, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}, {{DataType::dt_float, false, {}, false}}), - mi("madnis_multi_channel_variance", 142, true, {{DataType::dt_float, false, {"c"}, false}, {DataType::dt_float, false, {"c"}, false}}, {{DataType::dt_float, false, {}, false}}), - InstructionOwner(new NonzeroInstruction(143, true)), - InstructionOwner(new BatchGatherInstruction(144, true)), - InstructionOwner(new BatchScatterInstruction(145, true)), - InstructionOwner(new RandomInstruction(146, true)), - InstructionOwner(new RandomIntInstruction(147, true)), - InstructionOwner(new UnweightInstruction(148, true)), - mi("vegas_forward", 149, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, true, {"n", "b"}, false}}, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}), - mi("vegas_inverse", 150, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, true, {"n", "b"}, false}}, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}), - mi("vegas_histogram", 151, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {"b"}, true}}, {{DataType::dt_float, true, {"n", "b"}, false}, {DataType::dt_int, true, {"n", "b"}, false}}), - mi("histogram", 152, true, {{DataType::dt_float, false, {std::monostate{}}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {"b"}, true}}, {{DataType::dt_float, true, {"b+2"}, false}, {DataType::dt_float, true, {"b+2"}, false}}), + mi("mlm_clustering_hadronic", 104, true, {{DataType::dt_float, false, {"n", 4}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {"m"}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, true, {"k"}, false}, {DataType::dt_float, true, {"k"}, false}, {DataType::dt_float, true, {}, false}, {DataType::dt_float, true, {}, false}}, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {"n-2"}, false}, {DataType::dt_int, false, {}, false}}), + mi("mlm_clustering_leptonic", 105, true, {{DataType::dt_float, false, {"n", 4}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {"m"}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, true, {"k"}, false}, {DataType::dt_float, true, {"k"}, false}, {DataType::dt_float, true, {}, false}, {DataType::dt_float, true, {}, false}}, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {"n-2"}, false}, {DataType::dt_int, false, {}, false}}), + mi("chili_forward", 106, true, {{DataType::dt_float, false, {"3n-2"}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {"n+2", 4}, false}, {DataType::dt_float, false, {}, false}}), + mi("chili_inverse", 107, true, {{DataType::dt_float, false, {"n+2", 4}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {"3n-2"}, false}, {DataType::dt_float, false, {}, false}}), + InstructionOwner(new MatrixElementInstruction(108, true)), + mi("collect_channel_weights", 109, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_int, true, {"n"}, false}, {DataType::dt_int, true, {"c"}, true}}, {{DataType::dt_float, false, {"c"}, false}}), + mi("interpolate_pdf", 110, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, false, {"n"}, false}, {DataType::dt_float, true, {"a"}, false}, {DataType::dt_float, true, {"b"}, false}, {DataType::dt_float, true, {16, "c", "d"}, false}}, {{DataType::dt_float, false, {"n"}, false}}), + mi("interpolate_alpha_s", 111, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, true, {"b+1"}, false}, {DataType::dt_float, true, {4, "b"}, false}}, {{DataType::dt_float, false, {}, false}}), + mi("matmul", 112, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, true, {"m", "n"}, false}, {DataType::dt_float, true, {"m"}, false}}, {{DataType::dt_float, false, {"m"}, false}}), + mi("relu", 113, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), + mi("leaky_relu", 114, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), + mi("elu", 115, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), + mi("gelu", 116, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), + mi("sigmoid", 117, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), + mi("softplus", 118, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), + InstructionOwner(new RqsReshapeInstruction(119, true)), + mi("rqs_find_bin", 120, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n", "b"}, false}, {DataType::dt_float, false, {"n", "b"}, false}, {DataType::dt_float, false, {"n", "b+1"}, false}}, {{DataType::dt_float, false, {"n", 6}, false}}), + mi("rqs_forward", 121, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n", 6}, false}}, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}), + mi("rqs_inverse", 122, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n", 6}, false}}, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}), + mi("softmax", 123, true, {{DataType::dt_float, false, {std::monostate{}}, false}}, {{DataType::dt_float, false, {std::monostate{}}, false}}), + mi("softmax_prior", 124, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {"n"}, false}}), + mi("sample_discrete", 125, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {}, false}}, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {}, false}}), + mi("sample_discrete_inverse", 126, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_int, true, {}, false}}, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}), + mi("sample_discrete_probs", 127, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {}, false}}), + mi("sample_discrete_probs_inverse", 128, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}), + mi("discrete_histogram", 129, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {"n"}, true}}, {{DataType::dt_float, true, {"n"}, false}, {DataType::dt_int, true, {"n"}, false}}), + mi("permute_momenta", 130, true, {{DataType::dt_float, false, {"n", 4}, false}, {DataType::dt_int, true, {"m", "n"}, false}, {DataType::dt_int, false, {}, false}}, {{DataType::dt_float, false, {"n", 4}, false}}), + mi("gather", 131, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_float, false, {}, false}}), + mi("gather_int", 132, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_int, false, {"n"}, false}}, {{DataType::dt_int, false, {}, false}}), + mi("select_int", 133, true, {{DataType::dt_int, false, {"n"}, false}, {DataType::dt_int, false, {"m"}, false}}, {{DataType::dt_int, false, {"m"}, false}}), + mi("select", 134, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_int, false, {"m"}, false}}, {{DataType::dt_float, false, {"m"}, false}}), + mi("select_vector", 135, true, {{DataType::dt_float, false, {"n", "k"}, false}, {DataType::dt_int, false, {"m"}, false}}, {{DataType::dt_float, false, {"m", "k"}, false}}), + mi("argsort", 136, true, {{DataType::dt_float, false, {"n"}, false}}, {{DataType::dt_int, false, {"n"}, false}}), + mi("quantile", 137, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, true, {}, false}}, {{DataType::dt_float, true, {}, false}}), + mi("one_hot", 138, true, {{DataType::dt_int, false, {}, false}, {DataType::dt_int, true, {"n"}, true}}, {{DataType::dt_float, false, {"n"}, false}}), + mi("madnis_abs_weight", 139, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}, {{DataType::dt_float, false, {}, false}}), + mi("madnis_softclip", 140, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}, {{DataType::dt_float, false, {}, false}}), + mi("madnis_variance", 141, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}, {{DataType::dt_float, false, {}, false}}), + mi("madnis_single_channel_variance", 142, true, {{DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}}, {{DataType::dt_float, false, {}, false}}), + mi("madnis_multi_channel_variance", 143, true, {{DataType::dt_float, false, {"c"}, false}, {DataType::dt_float, false, {"c"}, false}}, {{DataType::dt_float, false, {}, false}}), + InstructionOwner(new NonzeroInstruction(144, true)), + InstructionOwner(new BatchGatherInstruction(145, true)), + InstructionOwner(new BatchScatterInstruction(146, true)), + InstructionOwner(new RandomInstruction(147, true)), + InstructionOwner(new RandomIntInstruction(148, true)), + InstructionOwner(new UnweightInstruction(149, true)), + mi("vegas_forward", 150, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, true, {"n", "b"}, false}}, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}), + mi("vegas_inverse", 151, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, true, {"n", "b"}, false}}, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {"n"}, false}}), + mi("vegas_histogram", 152, true, {{DataType::dt_float, false, {"n"}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {"b"}, true}}, {{DataType::dt_float, true, {"n", "b"}, false}, {DataType::dt_int, true, {"n", "b"}, false}}), + mi("histogram", 153, true, {{DataType::dt_float, false, {std::monostate{}}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_float, false, {}, false}, {DataType::dt_int, true, {"b"}, true}}, {{DataType::dt_float, true, {"b+2"}, false}, {DataType::dt_float, true, {"b+2"}, false}}), }; diff --git a/madspace/src/cpu/runtime_backward_mixin.inc b/madspace/src/cpu/runtime_backward_mixin.inc index 01f0b0e0a9..1a92043e6d 100644 --- a/madspace/src/cpu/runtime_backward_mixin.inc +++ b/madspace/src/cpu/runtime_backward_mixin.inc @@ -52,66 +52,66 @@ case 24: case 25: backward_batch_foreach, backward_kernel_square, 2, 1, DeviceType>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 111: +case 112: backward_op_matmul(instr, locals, local_grads, device); break; -case 112: +case 113: backward_batch_foreach, backward_kernel_relu, 2, 1, DeviceType>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 113: +case 114: backward_batch_foreach, backward_kernel_leaky_relu, 2, 1, DeviceType>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 114: +case 115: backward_batch_foreach, backward_kernel_elu, 2, 1, DeviceType>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 115: +case 116: backward_batch_foreach, backward_kernel_gelu, 2, 1, DeviceType>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 116: +case 117: backward_batch_foreach, backward_kernel_sigmoid, 2, 1, DeviceType>, 1, 1, 0, 1>(instr, locals, local_grads, {}, {0}, device); break; -case 117: +case 118: backward_batch_foreach, backward_kernel_softplus, 2, 1, DeviceType>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 118: +case 119: backward_op_rqs_reshape(instr, locals, local_grads, device); break; -case 119: +case 120: backward_batch_foreach, backward_kernel_rqs_find_bin, 5, 4, 2, DeviceType>, 4, 1, 4, 0>(instr, locals, local_grads, {0,1,2,3}, {}, device); break; -case 120: +case 121: backward_batch_foreach, backward_kernel_rqs_forward, 4, 2, 2, DeviceType>, 2, 2, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; -case 121: +case 122: backward_batch_foreach, backward_kernel_rqs_inverse, 4, 2, 2, DeviceType>, 2, 2, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; -case 122: +case 123: backward_batch_foreach, backward_kernel_softmax, 2, 1, DeviceType>, 1, 1, 0, 1>(instr, locals, local_grads, {}, {0}, device); break; -case 123: +case 124: backward_batch_foreach, backward_kernel_softmax_prior, 2, 2, 1, DeviceType>, 2, 1, 0, 1>(instr, locals, local_grads, {}, {0}, device); break; -case 127: +case 128: backward_batch_foreach, backward_kernel_sample_discrete_probs_inverse, 4, 2, 1, DeviceType>, 2, 2, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; -case 130: +case 131: backward_batch_foreach, backward_kernel_gather, 2, 2, 1, DeviceType>, 2, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 133: +case 134: backward_batch_foreach, backward_kernel_select, 2, 2, 1, DeviceType>, 2, 1, 1, 0>(instr, locals, local_grads, {1}, {}, device); break; -case 138: +case 139: backward_batch_foreach, backward_kernel_madnis_abs_weight, 3, 2, 1, DeviceType>, 2, 1, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; -case 139: +case 140: backward_batch_foreach, backward_kernel_madnis_softclip, 5, 4, 1, DeviceType>, 4, 1, 4, 0>(instr, locals, local_grads, {0,1,2,3}, {}, device); break; -case 140: +case 141: backward_batch_foreach, backward_kernel_madnis_variance, 5, 4, 1, DeviceType>, 4, 1, 4, 0>(instr, locals, local_grads, {0,1,2,3}, {}, device); break; -case 141: +case 142: backward_batch_foreach, backward_kernel_madnis_single_channel_variance, 2, 2, 1, DeviceType>, 2, 1, 1, 0>(instr, locals, local_grads, {1}, {}, device); break; -case 142: +case 143: backward_batch_foreach, backward_kernel_madnis_multi_channel_variance, 3, 2, 1, DeviceType>, 2, 1, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; diff --git a/madspace/src/cpu/runtime_mixin.inc b/madspace/src/cpu/runtime_mixin.inc index 9ff7dee352..68e688f743 100644 --- a/madspace/src/cpu/runtime_mixin.inc +++ b/madspace/src/cpu/runtime_mixin.inc @@ -314,149 +314,152 @@ case 103: batch_foreach, kernel_scale_partonic_energy, 1, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 104: - batch_foreach, kernel_mlm_clustering_hadronic, 5, 5, 1, DeviceType>, 5, 5>(instr, locals, device); + batch_foreach, kernel_mlm_clustering_hadronic, 8, 5, 1, DeviceType>, 8, 5>(instr, locals, device); break; case 105: - batch_foreach, kernel_chili_forward, 5, 2, 1, DeviceType>, 5, 2>(instr, locals, device); + batch_foreach, kernel_mlm_clustering_leptonic, 8, 5, 1, DeviceType>, 8, 5>(instr, locals, device); break; case 106: - batch_foreach, kernel_chili_inverse, 5, 2, 1, DeviceType>, 5, 2>(instr, locals, device); + batch_foreach, kernel_chili_forward, 5, 2, 1, DeviceType>, 5, 2>(instr, locals, device); break; case 107: - op_matrix_element(instr, locals, device); + batch_foreach, kernel_chili_inverse, 5, 2, 1, DeviceType>, 5, 2>(instr, locals, device); break; case 108: - batch_foreach, kernel_collect_channel_weights, 3, 1, 1, DeviceType>, 3, 1>(instr, locals, device); + op_matrix_element(instr, locals, device); break; case 109: - batch_foreach, kernel_interpolate_pdf, 6, 1, 1, DeviceType>, 6, 1>(instr, locals, device); + batch_foreach, kernel_collect_channel_weights, 3, 1, 1, DeviceType>, 3, 1>(instr, locals, device); break; case 110: - batch_foreach, kernel_interpolate_alpha_s, 3, 1, 1, DeviceType>, 3, 1>(instr, locals, device); + batch_foreach, kernel_interpolate_pdf, 6, 1, 1, DeviceType>, 6, 1>(instr, locals, device); break; case 111: - op_matmul(instr, locals, device); + batch_foreach, kernel_interpolate_alpha_s, 3, 1, 1, DeviceType>, 3, 1>(instr, locals, device); break; case 112: - batch_foreach, kernel_relu, 1, 1, DeviceType>, 1, 1>(instr, locals, device); + op_matmul(instr, locals, device); break; case 113: - batch_foreach, kernel_leaky_relu, 1, 1, DeviceType>, 1, 1>(instr, locals, device); + batch_foreach, kernel_relu, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 114: - batch_foreach, kernel_elu, 1, 1, DeviceType>, 1, 1>(instr, locals, device); + batch_foreach, kernel_leaky_relu, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 115: - batch_foreach, kernel_gelu, 1, 1, DeviceType>, 1, 1>(instr, locals, device); + batch_foreach, kernel_elu, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 116: - batch_foreach, kernel_sigmoid, 1, 1, DeviceType>, 1, 1>(instr, locals, device); + batch_foreach, kernel_gelu, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 117: - batch_foreach, kernel_softplus, 1, 1, DeviceType>, 1, 1>(instr, locals, device); + batch_foreach, kernel_sigmoid, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 118: - op_rqs_reshape(instr, locals, device); + batch_foreach, kernel_softplus, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 119: - batch_foreach, kernel_rqs_find_bin, 4, 1, 2, DeviceType>, 4, 1>(instr, locals, device); + op_rqs_reshape(instr, locals, device); break; case 120: - batch_foreach, kernel_rqs_forward, 2, 2, 2, DeviceType>, 2, 2>(instr, locals, device); + batch_foreach, kernel_rqs_find_bin, 4, 1, 2, DeviceType>, 4, 1>(instr, locals, device); break; case 121: - batch_foreach, kernel_rqs_inverse, 2, 2, 2, DeviceType>, 2, 2>(instr, locals, device); + batch_foreach, kernel_rqs_forward, 2, 2, 2, DeviceType>, 2, 2>(instr, locals, device); break; case 122: - batch_foreach, kernel_softmax, 1, 1, DeviceType>, 1, 1>(instr, locals, device); + batch_foreach, kernel_rqs_inverse, 2, 2, 2, DeviceType>, 2, 2>(instr, locals, device); break; case 123: - batch_foreach, kernel_softmax_prior, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + batch_foreach, kernel_softmax, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 124: - batch_foreach, kernel_sample_discrete, 2, 2, 1, DeviceType>, 2, 2>(instr, locals, device); + batch_foreach, kernel_softmax_prior, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 125: - batch_foreach, kernel_sample_discrete_inverse, 2, 2, 1, DeviceType>, 2, 2>(instr, locals, device); + batch_foreach, kernel_sample_discrete, 2, 2, 1, DeviceType>, 2, 2>(instr, locals, device); break; case 126: - batch_foreach, kernel_sample_discrete_probs, 2, 2, 1, DeviceType>, 2, 2>(instr, locals, device); + batch_foreach, kernel_sample_discrete_inverse, 2, 2, 1, DeviceType>, 2, 2>(instr, locals, device); break; case 127: - batch_foreach, kernel_sample_discrete_probs_inverse, 2, 2, 1, DeviceType>, 2, 2>(instr, locals, device); + batch_foreach, kernel_sample_discrete_probs, 2, 2, 1, DeviceType>, 2, 2>(instr, locals, device); break; case 128: - op_discrete_histogram(instr, locals, device); + batch_foreach, kernel_sample_discrete_probs_inverse, 2, 2, 1, DeviceType>, 2, 2>(instr, locals, device); break; case 129: - batch_foreach, kernel_permute_momenta, 3, 1, 1, DeviceType>, 3, 1>(instr, locals, device); + op_discrete_histogram(instr, locals, device); break; case 130: - batch_foreach, kernel_gather, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + batch_foreach, kernel_permute_momenta, 3, 1, 1, DeviceType>, 3, 1>(instr, locals, device); break; case 131: - batch_foreach, kernel_gather_int, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + batch_foreach, kernel_gather, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 132: - batch_foreach, kernel_select_int, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + batch_foreach, kernel_gather_int, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 133: - batch_foreach, kernel_select, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + batch_foreach, kernel_select_int, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 134: - batch_foreach, kernel_select_vector, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + batch_foreach, kernel_select, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 135: - batch_foreach, kernel_argsort, 1, 1, 1, DeviceType>, 1, 1>(instr, locals, device); + batch_foreach, kernel_select_vector, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 136: - op_quantile(instr, locals, device); + batch_foreach, kernel_argsort, 1, 1, 1, DeviceType>, 1, 1>(instr, locals, device); break; case 137: - batch_foreach, kernel_one_hot, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + op_quantile(instr, locals, device); break; case 138: - batch_foreach, kernel_madnis_abs_weight, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + batch_foreach, kernel_one_hot, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 139: - batch_foreach, kernel_madnis_softclip, 4, 1, 1, DeviceType>, 4, 1>(instr, locals, device); + batch_foreach, kernel_madnis_abs_weight, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 140: - batch_foreach, kernel_madnis_variance, 4, 1, 1, DeviceType>, 4, 1>(instr, locals, device); + batch_foreach, kernel_madnis_softclip, 4, 1, 1, DeviceType>, 4, 1>(instr, locals, device); break; case 141: - batch_foreach, kernel_madnis_single_channel_variance, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + batch_foreach, kernel_madnis_variance, 4, 1, 1, DeviceType>, 4, 1>(instr, locals, device); break; case 142: - batch_foreach, kernel_madnis_multi_channel_variance, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); + batch_foreach, kernel_madnis_single_channel_variance, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 143: - op_nonzero(instr, locals, device); + batch_foreach, kernel_madnis_multi_channel_variance, 2, 1, 1, DeviceType>, 2, 1>(instr, locals, device); break; case 144: - op_batch_gather(instr, locals, device); + op_nonzero(instr, locals, device); break; case 145: - op_batch_scatter(instr, locals, device); + op_batch_gather(instr, locals, device); break; case 146: - op_random(instr, locals, device); + op_batch_scatter(instr, locals, device); break; case 147: - op_random_int(instr, locals, device); + op_random(instr, locals, device); break; case 148: - op_unweight(instr, locals, device); + op_random_int(instr, locals, device); break; case 149: - batch_foreach, kernel_vegas_forward, 2, 2, 2, DeviceType>, 2, 2>(instr, locals, device); + op_unweight(instr, locals, device); break; case 150: - batch_foreach, kernel_vegas_inverse, 2, 2, 2, DeviceType>, 2, 2>(instr, locals, device); + batch_foreach, kernel_vegas_forward, 2, 2, 2, DeviceType>, 2, 2>(instr, locals, device); break; case 151: - op_vegas_histogram(instr, locals, device); + batch_foreach, kernel_vegas_inverse, 2, 2, 2, DeviceType>, 2, 2>(instr, locals, device); break; case 152: + op_vegas_histogram(instr, locals, device); + break; +case 153: op_histogram(instr, locals, device); break; diff --git a/madspace/src/gpu/runtime_backward_mixin.inc b/madspace/src/gpu/runtime_backward_mixin.inc index be422c24d5..57bfd1fc73 100644 --- a/madspace/src/gpu/runtime_backward_mixin.inc +++ b/madspace/src/gpu/runtime_backward_mixin.inc @@ -52,66 +52,66 @@ case 24: case 25: backward_batch_foreach, 2, 1>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 111: +case 112: backward_op_matmul(instr, locals, local_grads, device); break; -case 112: +case 113: backward_batch_foreach, 2, 1>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 113: +case 114: backward_batch_foreach, 2, 1>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 114: +case 115: backward_batch_foreach, 2, 1>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 115: +case 116: backward_batch_foreach, 2, 1>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 116: +case 117: backward_batch_foreach, 2, 1>, 1, 1, 0, 1>(instr, locals, local_grads, {}, {0}, device); break; -case 117: +case 118: backward_batch_foreach, 2, 1>, 1, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 118: +case 119: backward_op_rqs_reshape(instr, locals, local_grads, device); break; -case 119: +case 120: backward_batch_foreach, 5, 4, 2>, 4, 1, 4, 0>(instr, locals, local_grads, {0,1,2,3}, {}, device); break; -case 120: +case 121: backward_batch_foreach, 4, 2, 2>, 2, 2, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; -case 121: +case 122: backward_batch_foreach, 4, 2, 2>, 2, 2, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; -case 122: +case 123: backward_batch_foreach, 2, 1>, 1, 1, 0, 1>(instr, locals, local_grads, {}, {0}, device); break; -case 123: +case 124: backward_batch_foreach, 2, 2, 1>, 2, 1, 0, 1>(instr, locals, local_grads, {}, {0}, device); break; -case 127: +case 128: backward_batch_foreach, 4, 2, 1>, 2, 2, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; -case 130: +case 131: backward_batch_foreach, 2, 2, 1>, 2, 1, 1, 0>(instr, locals, local_grads, {0}, {}, device); break; -case 133: +case 134: backward_batch_foreach, 2, 2, 1>, 2, 1, 1, 0>(instr, locals, local_grads, {1}, {}, device); break; -case 138: +case 139: backward_batch_foreach, 3, 2, 1>, 2, 1, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; -case 139: +case 140: backward_batch_foreach, 5, 4, 1>, 4, 1, 4, 0>(instr, locals, local_grads, {0,1,2,3}, {}, device); break; -case 140: +case 141: backward_batch_foreach, 5, 4, 1>, 4, 1, 4, 0>(instr, locals, local_grads, {0,1,2,3}, {}, device); break; -case 141: +case 142: backward_batch_foreach, 2, 2, 1>, 2, 1, 1, 0>(instr, locals, local_grads, {1}, {}, device); break; -case 142: +case 143: backward_batch_foreach, 3, 2, 1>, 2, 1, 2, 0>(instr, locals, local_grads, {0,1}, {}, device); break; diff --git a/madspace/src/gpu/runtime_mixin.inc b/madspace/src/gpu/runtime_mixin.inc index dbc3488e69..f975f0adc8 100644 --- a/madspace/src/gpu/runtime_mixin.inc +++ b/madspace/src/gpu/runtime_mixin.inc @@ -314,149 +314,152 @@ case 103: batch_foreach, 1, 1, 1>, 1, 1>(instr, locals, device); break; case 104: - batch_foreach, 5, 5, 1>, 5, 5>(instr, locals, device); + batch_foreach, 8, 5, 1>, 8, 5>(instr, locals, device); break; case 105: - batch_foreach, 5, 2, 1>, 5, 2>(instr, locals, device); + batch_foreach, 8, 5, 1>, 8, 5>(instr, locals, device); break; case 106: - batch_foreach, 5, 2, 1>, 5, 2>(instr, locals, device); + batch_foreach, 5, 2, 1>, 5, 2>(instr, locals, device); break; case 107: - op_matrix_element(instr, locals, device); + batch_foreach, 5, 2, 1>, 5, 2>(instr, locals, device); break; case 108: - batch_foreach, 3, 1, 1>, 3, 1>(instr, locals, device); + op_matrix_element(instr, locals, device); break; case 109: - batch_foreach, 6, 1, 1>, 6, 1>(instr, locals, device); + batch_foreach, 3, 1, 1>, 3, 1>(instr, locals, device); break; case 110: - batch_foreach, 3, 1, 1>, 3, 1>(instr, locals, device); + batch_foreach, 6, 1, 1>, 6, 1>(instr, locals, device); break; case 111: - op_matmul(instr, locals, device); + batch_foreach, 3, 1, 1>, 3, 1>(instr, locals, device); break; case 112: - batch_foreach, 1, 1>, 1, 1>(instr, locals, device); + op_matmul(instr, locals, device); break; case 113: - batch_foreach, 1, 1>, 1, 1>(instr, locals, device); + batch_foreach, 1, 1>, 1, 1>(instr, locals, device); break; case 114: - batch_foreach, 1, 1>, 1, 1>(instr, locals, device); + batch_foreach, 1, 1>, 1, 1>(instr, locals, device); break; case 115: - batch_foreach, 1, 1>, 1, 1>(instr, locals, device); + batch_foreach, 1, 1>, 1, 1>(instr, locals, device); break; case 116: - batch_foreach, 1, 1>, 1, 1>(instr, locals, device); + batch_foreach, 1, 1>, 1, 1>(instr, locals, device); break; case 117: - batch_foreach, 1, 1>, 1, 1>(instr, locals, device); + batch_foreach, 1, 1>, 1, 1>(instr, locals, device); break; case 118: - op_rqs_reshape(instr, locals, device); + batch_foreach, 1, 1>, 1, 1>(instr, locals, device); break; case 119: - batch_foreach, 4, 1, 2>, 4, 1>(instr, locals, device); + op_rqs_reshape(instr, locals, device); break; case 120: - batch_foreach, 2, 2, 2>, 2, 2>(instr, locals, device); + batch_foreach, 4, 1, 2>, 4, 1>(instr, locals, device); break; case 121: - batch_foreach, 2, 2, 2>, 2, 2>(instr, locals, device); + batch_foreach, 2, 2, 2>, 2, 2>(instr, locals, device); break; case 122: - batch_foreach, 1, 1>, 1, 1>(instr, locals, device); + batch_foreach, 2, 2, 2>, 2, 2>(instr, locals, device); break; case 123: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + batch_foreach, 1, 1>, 1, 1>(instr, locals, device); break; case 124: - batch_foreach, 2, 2, 1>, 2, 2>(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 125: - batch_foreach, 2, 2, 1>, 2, 2>(instr, locals, device); + batch_foreach, 2, 2, 1>, 2, 2>(instr, locals, device); break; case 126: - batch_foreach, 2, 2, 1>, 2, 2>(instr, locals, device); + batch_foreach, 2, 2, 1>, 2, 2>(instr, locals, device); break; case 127: - batch_foreach, 2, 2, 1>, 2, 2>(instr, locals, device); + batch_foreach, 2, 2, 1>, 2, 2>(instr, locals, device); break; case 128: - op_discrete_histogram(instr, locals, device); + batch_foreach, 2, 2, 1>, 2, 2>(instr, locals, device); break; case 129: - batch_foreach, 3, 1, 1>, 3, 1>(instr, locals, device); + op_discrete_histogram(instr, locals, device); break; case 130: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + batch_foreach, 3, 1, 1>, 3, 1>(instr, locals, device); break; case 131: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 132: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 133: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 134: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 135: - batch_foreach, 1, 1, 1>, 1, 1>(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 136: - op_quantile(instr, locals, device); + batch_foreach, 1, 1, 1>, 1, 1>(instr, locals, device); break; case 137: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + op_quantile(instr, locals, device); break; case 138: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 139: - batch_foreach, 4, 1, 1>, 4, 1>(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 140: - batch_foreach, 4, 1, 1>, 4, 1>(instr, locals, device); + batch_foreach, 4, 1, 1>, 4, 1>(instr, locals, device); break; case 141: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + batch_foreach, 4, 1, 1>, 4, 1>(instr, locals, device); break; case 142: - batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 143: - op_nonzero(instr, locals, device); + batch_foreach, 2, 1, 1>, 2, 1>(instr, locals, device); break; case 144: - op_batch_gather(instr, locals, device); + op_nonzero(instr, locals, device); break; case 145: - op_batch_scatter(instr, locals, device); + op_batch_gather(instr, locals, device); break; case 146: - op_random(instr, locals, device); + op_batch_scatter(instr, locals, device); break; case 147: - op_random_int(instr, locals, device); + op_random(instr, locals, device); break; case 148: - op_unweight(instr, locals, device); + op_random_int(instr, locals, device); break; case 149: - batch_foreach, 2, 2, 2>, 2, 2>(instr, locals, device); + op_unweight(instr, locals, device); break; case 150: - batch_foreach, 2, 2, 2>, 2, 2>(instr, locals, device); + batch_foreach, 2, 2, 2>, 2, 2>(instr, locals, device); break; case 151: - op_vegas_histogram(instr, locals, device); + batch_foreach, 2, 2, 2>, 2, 2>(instr, locals, device); break; case 152: + op_vegas_histogram(instr, locals, device); + break; +case 153: op_histogram(instr, locals, device); break; diff --git a/madspace/src/kernels/kernels.hpp b/madspace/src/kernels/kernels.hpp index 7c2d828efc..307397aef0 100644 --- a/madspace/src/kernels/kernels.hpp +++ b/madspace/src/kernels/kernels.hpp @@ -7,6 +7,7 @@ #include "kinematics.hpp" #include "lup_det.hpp" #include "math.hpp" +#include "mlm.hpp" #include "multichannel.hpp" #include "nn.hpp" #include "observables.hpp" diff --git a/madspace/src/kernels/kinematics.hpp b/madspace/src/kernels/kinematics.hpp index 26705daf64..04dd0fd322 100644 --- a/madspace/src/kernels/kinematics.hpp +++ b/madspace/src/kernels/kinematics.hpp @@ -12,7 +12,7 @@ constexpr double INV_GEV2_TO_PB = 0.38937937217186e9; template struct FourMom { KERNELSPEC FVal& operator[](int i) { return p[i]; } - KERNELSPEC FVal& operator[](int i) const { return p[i]; } + KERNELSPEC const FVal& operator[](int i) const { return p[i]; } FVal p[4]; }; diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index f6ea2b3360..a68979f816 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -18,7 +18,7 @@ KERNELSPEC FVal djb_clus(FourMom p, bool hadronic) { // kt/Durham clustering measure for two partons. // mass1/mass2 are the tracked clustering masses kept in a separate array and updated by -// update_momenta — NOT the Lorentz-invariant masses computed from the 4-momentum. +// update_momenta - NOT the Lorentz-invariant masses computed from the 4-momentum. // jet_radius is the jet-radius parameter (Fortran common /to_dj/D). // based on dj_clus from Template/NLO/SubProcesses/cluster.f template @@ -84,8 +84,8 @@ KERNELSPEC FVal dj_clus( // based on cluster_scale from Template/NLO/SubProcesses/cluster.f template KERNELSPEC FVal compute_scale( - FourMom momentum1, - FourMom momentum2, + const FourMom& momentum1, + const FourMom& momentum2, FVal mass1, FVal mass2, bool resonant, @@ -148,14 +148,14 @@ KERNELSPEC FVal compute_scale( template KERNELSPEC void update_momenta( int n_part, - FourMom momenta[N_EXT_MAX], - FVal masses[N_EXT_MAX], - bool alive[N_EXT_MAX], + FourMom* momenta, + FVal* masses, + int& alive, int i_remove, int j_keep, - bool is_bw + bool resonant ) { - alive[i_remove] = false; + alive &= ~(1 << i_remove); if (j_keep < 2) { // initial-state clustering @@ -165,10 +165,9 @@ KERNELSPEC void update_momenta( } // mass: take max if exactly one daughter is massive, else 0 - bool m0 = (masses[j_keep] > 0.0); - bool m1 = (masses[i_remove] > 0.0); - masses[j_keep] = - (m0 != m1) ? max(masses[j_keep], masses[i_remove]) : FVal(0.0); + masses[j_keep] = (masses[j_keep] > 0.0) != (masses[i_remove] > 0.0) + ? max(masses[j_keep], masses[i_remove]) + : FVal(0.0); // CM boost vector: (E_tot, -px_tot, -py_tot, -pz_tot) of the two beam particles FourMom pcmsp = { @@ -183,11 +182,10 @@ KERNELSPEC void update_momenta( // particles auto jkeep_cm = boost(momenta[j_keep], pcmsp, 1.0); for (int j = 0; j < n_part; ++j) { - if (!alive[j]) { - continue; + if (alive & (1 << j)) { + momenta[j] = + rotate_inverse(boost(momenta[j], pcmsp, 1.0), jkeep_cm); } - momenta[j] = - rotate_inverse(boost(momenta[j], pcmsp, 1.0), jkeep_cm); } } @@ -197,7 +195,7 @@ KERNELSPEC void update_momenta( momenta[j_keep][k] += momenta[i_remove][k]; } - if (is_bw) { + if (resonant) { masses[j_keep] = sqrt(max(lsquare(momenta[j_keep]), 0.0)); } else { masses[j_keep] = max(masses[j_keep], masses[i_remove]); @@ -210,8 +208,10 @@ KERNELSPEC void mlm_clustering( FIn momenta, FIn random, IIn state_machine, - FIn masses, - FIn widths, + FIn external_masses, + FIn bw_masses, + FIn bw_widths, + FIn bw_cutoff, FIn jet_radius, FOut ren_scale, FOut fact_scale1, @@ -220,14 +220,16 @@ KERNELSPEC void mlm_clustering( IOut diagram_index, bool hadronic ) { + // we do not support SIMD for now, so we can assume simple types static_assert(std::is_same_v, int>); static_assert(std::is_same_v, double>); + int state = 0, cluster_count = 0; int cluster_max = momenta.size() - 3; int n_part = momenta.size(); FourMom momenta_tmp[N_EXT_MAX]; FVal masses_tmp[N_EXT_MAX]; - bool alive[N_EXT_MAX]; + int alive = 0xFFFFFF; int cluster_history[N_EXT_MAX - 3]; FVal cluster_scales[N_EXT_MAX - 3]; @@ -235,11 +237,11 @@ KERNELSPEC void mlm_clustering( for (int j = 0; j < 4; ++j) { momenta_tmp[i][j] = momenta[i][j]; } - masses_tmp[i] = masses[i]; - alive[i] = true; + masses_tmp[i] = external_masses[i]; } int win_next_state = -1, win_data = 0; + bool win_resonant = false; FVal win_scale = 1e308; while (cluster_count < cluster_max) { int data = state_machine[state]; @@ -252,34 +254,50 @@ KERNELSPEC void mlm_clustering( bool massive_out2 = (data >> 26) & 1; bool is_last = (data >> 28) & 1; bool is_initial = (particle2 < 2); - bool resonant = (mass_index != 0) && (widths[mass_index] > 0.0); + + bool resonant = false; + if (mass_index != 0) { + FourMom mom_sum{ + momenta_tmp[particle1][0] + momenta_tmp[particle2][0], + momenta_tmp[particle1][1] + momenta_tmp[particle2][1], + momenta_tmp[particle1][2] + momenta_tmp[particle2][2], + momenta_tmp[particle1][3] + momenta_tmp[particle2][3], + }; + FVal prop_m2 = lsquare(mom_sum); + FVal mass = bw_masses[mass_index - 1]; + FVal width = bw_widths[mass_index - 1]; + FVal m_min = mass - width; + FVal m_max = mass + width; + resonant = (prop_m2 >= m_min * m_min) && (prop_m2 <= m_max * m_max); + } FVal scale = compute_scale( momenta_tmp[particle1], momenta_tmp[particle2], masses_tmp[particle1], masses_tmp[particle2], - masses[mass_index], - widths[mass_index], resonant, is_initial, massive_in, massive_out1, massive_out2, - true, + hadronic, jet_radius ); - if (scale < win_scale) { + // The MG5 fortran code extracted the resonance structure from the integration + // channel. This is not always possible in MG7, so prefer resonant configs + // over non-resonant ones + if ((!win_resonant && resonant) || + (win_resonant == resonant && scale < win_scale)) { win_next_state = next_state; win_scale = scale; win_data = data; + win_resonant = resonant; } if (is_last) { int p1_win = win_data & 0xFF; int p2_win = (win_data >> 8) & 0xFF; - int mi_win = (win_data >> 16) & 0xFF; - bool win_resonant = (mi_win != 0) && (widths[mi_win] > 0.0); update_momenta( n_part, momenta_tmp, masses_tmp, alive, p1_win, p2_win, win_resonant ); @@ -332,12 +350,14 @@ KERNELSPEC void mlm_clustering( } template -KERNELSPEC void mlm_clustering_hadronic( +KERNELSPEC void kernel_mlm_clustering_hadronic( FIn momenta, FIn random, IIn state_machine, - FIn masses, - FIn widths, + FIn external_masses, + FIn bw_masses, + FIn bw_widths, + FIn bw_cutoff, FIn jet_radius, FOut ren_scale, FOut fact_scale1, @@ -345,12 +365,14 @@ KERNELSPEC void mlm_clustering_hadronic( FOut outgoing_scales, IOut diagram_index ) { - mlm_clustering( + mlm_clustering( momenta, random, state_machine, - masses, - widths, + external_masses, + bw_masses, + bw_widths, + bw_cutoff, jet_radius, ren_scale, fact_scale1, @@ -362,12 +384,14 @@ KERNELSPEC void mlm_clustering_hadronic( } template -KERNELSPEC void mlm_clustering_leptonic( +KERNELSPEC void kernel_mlm_clustering_leptonic( FIn momenta, FIn random, IIn state_machine, - FIn masses, - FIn widths, + FIn external_masses, + FIn bw_masses, + FIn bw_widths, + FIn bw_cutoff, FIn jet_radius, FOut ren_scale, FOut fact_scale1, @@ -375,12 +399,14 @@ KERNELSPEC void mlm_clustering_leptonic( FOut outgoing_scales, IOut diagram_index ) { - mlm_clustering( + mlm_clustering( momenta, random, state_machine, - masses, - widths, + external_masses, + bw_masses, + bw_widths, + bw_cutoff, jet_radius, ren_scale, fact_scale1, diff --git a/madspace/src/python/instruction_set.hpp b/madspace/src/python/instruction_set.hpp index fc952bdcdc..9dc330b23f 100644 --- a/madspace/src/python/instruction_set.hpp +++ b/madspace/src/python/instruction_set.hpp @@ -117,7 +117,8 @@ void add_instructions(py::classh& fb) { fb.def("scale_transverse_mass", &FunctionBuilder::scale_transverse_mass, py::arg("momenta")); fb.def("scale_half_transverse_mass", &FunctionBuilder::scale_half_transverse_mass, py::arg("momenta")); fb.def("scale_partonic_energy", &FunctionBuilder::scale_partonic_energy, py::arg("momenta")); - fb.def("mlm_clustering_hadronic", &FunctionBuilder::mlm_clustering_hadronic, py::arg("momenta"), py::arg("random"), py::arg("state_machine"), py::arg("masses"), py::arg("widths")); + fb.def("mlm_clustering_hadronic", &FunctionBuilder::mlm_clustering_hadronic, py::arg("momenta"), py::arg("random"), py::arg("state_machine"), py::arg("external_masses"), py::arg("bw_masses"), py::arg("bw_widths"), py::arg("bw_cutoffs"), py::arg("jet_radius")); + fb.def("mlm_clustering_leptonic", &FunctionBuilder::mlm_clustering_leptonic, py::arg("momenta"), py::arg("random"), py::arg("state_machine"), py::arg("external_masses"), py::arg("bw_masses"), py::arg("bw_widths"), py::arg("bw_cutoffs"), py::arg("jet_radius")); fb.def("chili_forward", &FunctionBuilder::chili_forward, py::arg("r"), py::arg("e_cm"), py::arg("m_out"), py::arg("pt_min"), py::arg("y_max")); fb.def("chili_inverse", &FunctionBuilder::chili_inverse, py::arg("p_ext"), py::arg("e_cm"), py::arg("m_out"), py::arg("pt_min"), py::arg("y_max")); fb.def("matrix_element", &FunctionBuilder::matrix_element, py::arg("args")); From 350bde867fafa901817de9bce4dc99cb4af2d8fc Mon Sep 17 00:00:00 2001 From: Theo Heimel Date: Sun, 28 Jun 2026 21:58:12 +0200 Subject: [PATCH 06/81] some more cleaning up in mlm --- madspace/src/kernels/mlm.hpp | 109 ++++++++++++++--------------------- 1 file changed, 44 insertions(+), 65 deletions(-) diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index a68979f816..5f0bb4f262 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -7,6 +7,7 @@ namespace madspace { namespace kernels { constexpr int N_EXT_MAX = 12; +constexpr double ONE_PLUS_TINY = 1.000001; // mT^2 = E^2 - pz^2 (hadronic) or E^2 (lepton collider). // based on djb_clus from Template/NLO/SubProcesses/cluster.f @@ -30,7 +31,6 @@ KERNELSPEC FVal dj_clus( bool hadronic, FVal jet_radius ) { - constexpr double one_plus_tiny = 1.0 + 1e-6; if (!hadronic) { // Durham e+e- measure: 2*min(E1^2, E2^2)*(1 - cos_theta) auto p1a = sqrt(esquare(p1)); @@ -41,15 +41,17 @@ KERNELSPEC FVal dj_clus( auto costh = edot(p1, p2) / (p1a * p2a); return 2.0 * min(p1[0] * p1[0], p2[0] * p2[0]) * max(1.0 - costh, 0.0); } + // hadronic: massless+massive pair clusters to the lighter parton's mT^2 bool massive1 = (mass1 > 0.0); bool massive2 = (mass2 > 0.0); if (!massive1 && massive2) { - return djb_clus(p1, true) * one_plus_tiny; + return djb_clus(p1, true) * ONE_PLUS_TINY; } if (massive1 && !massive2) { - return djb_clus(p2, true) * one_plus_tiny; + return djb_clus(p2, true) * ONE_PLUS_TINY; } + // both massless or both massive: generalised kt measure in (eta, phi) auto pt1_sq = p1[1] * p1[1] + p1[2] * p1[2]; auto pt2_sq = p2[1] * p2[1] + p2[2] * p2[2]; @@ -86,6 +88,7 @@ template KERNELSPEC FVal compute_scale( const FourMom& momentum1, const FourMom& momentum2, + const FourMom& momentum_sum, FVal mass1, FVal mass2, bool resonant, @@ -96,55 +99,33 @@ KERNELSPEC FVal compute_scale( bool hadronic, FVal jet_radius ) { - constexpr double one_plus_tiny = 1.000001; if (is_initial) { // scale = mT of the final-state parton // small penalty when it goes against the beam auto scale = sqrt(djb_clus(momentum1, hadronic)); if ((momentum1[3] < 0.0) != (momentum2[3] < 0.0)) { - scale = scale * one_plus_tiny; + scale = scale * ONE_PLUS_TINY; } return scale; } - - FourMom sum = { - momentum1[0] + momentum2[0], - momentum1[1] + momentum2[1], - momentum1[2] + momentum2[2], - momentum1[3] + momentum2[3] - }; - if (resonant) { - return sqrt(max(lsquare(sum), 0.0)); + return sqrt(max(lsquare(momentum_sum), 0.0)); } - - // Map massive_in/out1/out2 to Fortran get_clustering_type itypes: if (!massive_in && massive_out1 && !massive_out2) { - return sqrt(fabs(ldot(momentum2, sum))) / 2.0; + return sqrt(fabs(ldot(momentum2, momentum_sum))) / 2.0; } if (!massive_in && !massive_out1 && massive_out2) { - return sqrt(fabs(ldot(momentum1, sum))) / 2.0; + return sqrt(fabs(ldot(momentum1, momentum_sum))) / 2.0; } if (massive_in && !massive_out1 && !massive_out2) { - return sqrt(max(lsquare(sum), 0.0)); + return sqrt(max(lsquare(momentum_sum), 0.0)); } return sqrt(dj_clus(momentum1, momentum2, mass1, mass2, hadronic, jet_radius)); } // Update momenta and the separately-tracked masses after one clustering step. -// Uses boost and rotate_inverse from kinematics.hpp. -// -// i_remove : final-state particle absorbed into the combination (always >= 2, -// 0-indexed) j_keep : particle kept with the combined momentum (0/1 = initial state, -// else final) is_bw : BW resonance — combined mass = invariant mass of sum; else max -// of daughters -// -// Mass update rules mirror Fortran update_momenta (cluster.f:871): -// Final non-BW : mass[j_keep] = max(mass[j_keep], mass[i_remove]) -// Final BW : mass[j_keep] = sqrt((pi + pj)^2) -// Initial, one massive: mass[j_keep] = max(mass[j_keep], mass[i_remove]) -// Initial, same : mass[j_keep] = 0 +// Based on update_momenta from Template/NLO/SubProcesses/cluster.f template KERNELSPEC void update_momenta( int n_part, @@ -152,53 +133,49 @@ KERNELSPEC void update_momenta( FVal* masses, int& alive, int i_remove, - int j_keep, + int i_keep, bool resonant ) { alive &= ~(1 << i_remove); - if (j_keep < 2) { - // initial-state clustering - int j_other = 1 - j_keep; + if (i_keep < 2) { // initial-state clustering + int j_other = 1 - i_keep; for (int k = 0; k < 4; ++k) { - momenta[j_keep][k] -= momenta[i_remove][k]; + momenta[i_keep][k] -= momenta[i_remove][k]; } // mass: take max if exactly one daughter is massive, else 0 - masses[j_keep] = (masses[j_keep] > 0.0) != (masses[i_remove] > 0.0) - ? max(masses[j_keep], masses[i_remove]) - : FVal(0.0); + masses[i_keep] = (masses[i_keep] > 0.0) != (masses[i_remove] > 0.0) + ? max(masses[i_keep], masses[i_remove]) + : 0.0; - // CM boost vector: (E_tot, -px_tot, -py_tot, -pz_tot) of the two beam particles - FourMom pcmsp = { - momenta[j_keep][0] + momenta[j_other][0], - -(momenta[j_keep][1] + momenta[j_other][1]), - -(momenta[j_keep][2] + momenta[j_other][2]), - -(momenta[j_keep][3] + momenta[j_other][3]) + FourMom com_boost_vector = { + momenta[i_keep][0] + momenta[j_other][0], + -(momenta[i_keep][1] + momenta[j_other][1]), + -(momenta[i_keep][2] + momenta[j_other][2]), + -(momenta[i_keep][3] + momenta[j_other][3]), }; - - if (lsquare(pcmsp) > 100.0) { - // boost j_keep to CM to define the rotation axis, then apply to all alive - // particles - auto jkeep_cm = boost(momenta[j_keep], pcmsp, 1.0); + if (lsquare(com_boost_vector) > 100.0) { + // boost j_keep to COM frame to define the rotation axis, then apply to all + // alive particles + auto jkeep_cm = boost(momenta[i_keep], com_boost_vector, 1.0); for (int j = 0; j < n_part; ++j) { if (alive & (1 << j)) { - momenta[j] = - rotate_inverse(boost(momenta[j], pcmsp, 1.0), jkeep_cm); + momenta[j] = rotate_inverse( + boost(momenta[j], com_boost_vector, 1.0), jkeep_cm + ); } } } - - } else { - // final-state clustering + } else { // final-state clustering for (int k = 0; k < 4; ++k) { - momenta[j_keep][k] += momenta[i_remove][k]; + momenta[i_keep][k] += momenta[i_remove][k]; } if (resonant) { - masses[j_keep] = sqrt(max(lsquare(momenta[j_keep]), 0.0)); + masses[i_keep] = sqrt(max(lsquare(momenta[i_keep]), 0.0)); } else { - masses[j_keep] = max(masses[j_keep], masses[i_remove]); + masses[i_keep] = max(masses[i_keep], masses[i_remove]); } } } @@ -255,15 +232,16 @@ KERNELSPEC void mlm_clustering( bool is_last = (data >> 28) & 1; bool is_initial = (particle2 < 2); + FourMom momentum_sum{ + momenta_tmp[particle1][0] + momenta_tmp[particle2][0], + momenta_tmp[particle1][1] + momenta_tmp[particle2][1], + momenta_tmp[particle1][2] + momenta_tmp[particle2][2], + momenta_tmp[particle1][3] + momenta_tmp[particle2][3], + }; + bool resonant = false; if (mass_index != 0) { - FourMom mom_sum{ - momenta_tmp[particle1][0] + momenta_tmp[particle2][0], - momenta_tmp[particle1][1] + momenta_tmp[particle2][1], - momenta_tmp[particle1][2] + momenta_tmp[particle2][2], - momenta_tmp[particle1][3] + momenta_tmp[particle2][3], - }; - FVal prop_m2 = lsquare(mom_sum); + FVal prop_m2 = lsquare(momentum_sum); FVal mass = bw_masses[mass_index - 1]; FVal width = bw_widths[mass_index - 1]; FVal m_min = mass - width; @@ -274,6 +252,7 @@ KERNELSPEC void mlm_clustering( FVal scale = compute_scale( momenta_tmp[particle1], momenta_tmp[particle2], + momentum_sum, masses_tmp[particle1], masses_tmp[particle2], resonant, From 3661af9f8e5e668c1b2bcb3ddbdfa13c5b834dfb Mon Sep 17 00:00:00 2001 From: Theo Heimel Date: Mon, 29 Jun 2026 17:03:29 +0200 Subject: [PATCH 07/81] output clustering scale for external particles --- madspace/src/kernels/mlm.hpp | 27 ++++++++++++++++++---- madspace/src/phasespace/mlm_clustering.cpp | 5 +++- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 5f0bb4f262..290efa04cb 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -144,7 +144,6 @@ KERNELSPEC void update_momenta( momenta[i_keep][k] -= momenta[i_remove][k]; } - // mass: take max if exactly one daughter is massive, else 0 masses[i_keep] = (masses[i_keep] > 0.0) != (masses[i_remove] > 0.0) ? max(masses[i_keep], masses[i_remove]) : 0.0; @@ -229,7 +228,7 @@ KERNELSPEC void mlm_clustering( bool massive_in = (data >> 24) & 1; bool massive_out1 = (data >> 25) & 1; bool massive_out2 = (data >> 26) & 1; - bool is_last = (data >> 28) & 1; + bool is_last = (data >> 30) & 1; bool is_initial = (particle2 < 2); FourMom momentum_sum{ @@ -305,11 +304,31 @@ KERNELSPEC void mlm_clustering( } } + for (int i = 0; i < n_part - 2; ++i) { + outgoing_scales[i] = 0.0; + } FVal ren_scale_val = 1.0; + int is_last_cluster = 0b11111111'11111111'11111100; for (int i = 0; i < cluster_max; ++i) { FVal scale = cluster_scales[i]; - bool is_qcd = (cluster_history[i] >> 27) & 1; - ren_scale_val *= is_qcd ? scale : max_scale; + int data = cluster_history[i]; + int particle1 = data & 0xFF; + int particle2 = (data >> 8) & 0xFF; + bool is_qcd = (data >> 27) & 1; + bool is_jet1 = (data >> 28) & 1; + bool is_jet2 = (data >> 29) & 1; + if (is_qcd) { + if (is_jet1 && is_last_cluster & (1 << particle1)) { + outgoing_scales[particle1 - 2] = scale; + } + if (is_jet2 && is_last_cluster & (1 << particle2)) { + outgoing_scales[particle2 - 2] = scale; + } + ren_scale_val *= scale; + } else { + ren_scale_val *= max_scale; + } + is_last_cluster &= ~((1 << particle1) | (1 << particle2)); } ren_scale_val = pow(ren_scale_val, 1.0 / cluster_max); if (fac_scale > ren_scale_val) { diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index ec5d2b78d6..9c7753e094 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -17,6 +17,8 @@ struct StateItem { bool massive_out1; bool massive_out2; bool is_qcd; + bool is_jet1; + bool is_jet2; }; void find_clusterings( @@ -209,7 +211,8 @@ MLMClustering::MLMClustering( (item.particle1 << 0) + (item.particle2 << 8) + (item.mass_index << 16) + (item.massive_in << 24) + (item.massive_out1 << 25) + (item.massive_out2 << 26) + - (item.is_qcd << 27) + ((&item == &state.back()) << 28) + (item.is_qcd << 27) + (item.is_jet1 << 28) + (item.is_jet2 << 29) + + ((&item == &state.back()) << 30) ); _cluster_state_machine.push_back(first_indices.at(item.next_state)); } From b075f6d475e1bfec27155dccbcc71cb83537f45b Mon Sep 17 00:00:00 2001 From: Theo Heimel Date: Fri, 3 Jul 2026 15:23:08 +0200 Subject: [PATCH 08/81] mlm compiling and running, no reasonable output yet --- madspace/include/madspace/phasespace.hpp | 1 + .../madspace/phasespace/mlm_clustering.hpp | 11 +++- madspace/src/phasespace/mlm_clustering.cpp | 64 +++++++++++++++---- madspace/src/python/madspace.cpp | 17 +++++ madspace/tests/test_mlm.py | 60 +++++++++++++++++ 5 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 madspace/tests/test_mlm.py diff --git a/madspace/include/madspace/phasespace.hpp b/madspace/include/madspace/phasespace.hpp index ca6cb3cf8d..ef927428a9 100644 --- a/madspace/include/madspace/phasespace.hpp +++ b/madspace/include/madspace/phasespace.hpp @@ -16,6 +16,7 @@ #include "phasespace/luminosity.hpp" #include "phasespace/madnis.hpp" #include "phasespace/matrix_element.hpp" +#include "phasespace/mlm_clustering.hpp" #include "phasespace/mlp.hpp" #include "phasespace/multichannel.hpp" #include "phasespace/observable.hpp" diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp index a21377a387..aa7ce71632 100644 --- a/madspace/include/madspace/phasespace/mlm_clustering.hpp +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -10,7 +10,10 @@ class MLMClustering : public FunctionGenerator { MLMClustering( std::vector topologies, nested_vector3 permutations, - nested_vector2 diagram_indices + nested_vector2 diagram_indices, + double bw_cutoff = 15, + double jet_radius = 0.4, + bool hadronic = true ); private: @@ -19,6 +22,12 @@ class MLMClustering : public FunctionGenerator { ) const override; std::vector _cluster_state_machine; + std::vector _external_masses; + std::vector _bw_masses; + std::vector _bw_widths; + double _bw_cutoff; + double _jet_radius; + bool _hadronic; }; } // namespace madspace diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index 9c7753e094..60a4f2fede 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -64,7 +64,7 @@ void find_clusterings( key = {new_masks, new_diags}; } int index; - if (auto search = state_map.find(key); search == state_map.end()) { + if (auto search = state_map.find(key); search != state_map.end()) { index = search->second; } else { index = states.size(); @@ -80,7 +80,9 @@ void find_clusterings( .massive_in = false, .massive_out1 = false, .massive_out2 = false, - .is_qcd = false, + .is_qcd = true, + .is_jet1 = true, + .is_jet2 = true, }); if (new_masks.size() == 3) { @@ -95,6 +97,8 @@ void find_clusterings( .massive_out1 = false, .massive_out2 = false, .is_qcd = false, + .is_jet1 = false, + .is_jet2 = false, }); } } else { @@ -111,7 +115,10 @@ void find_clusterings( MLMClustering::MLMClustering( std::vector topologies, nested_vector3 permutations, - nested_vector2 diagram_indices + nested_vector2 diagram_indices, + double bw_cutoff, + double jet_radius, + bool hadronic ) : FunctionGenerator( "MLMClustering", @@ -120,16 +127,27 @@ MLMClustering::MLMClustering( {{"ren_scale", batch_float}, {"fact_scale1", batch_float}, {"fact_scale2", batch_float}, - {"cluster_history", - batch_int_array(topologies.at(0).outgoing_masses().size() - 1)}, - {"cluster_scales", - batch_float_array(topologies.at(0).outgoing_masses().size() - 1)}} - ) { - std::size_t n_ext = topologies.at(0).outgoing_masses().size() + 2; + {"outgoing_scales", + batch_float_array(topologies.at(0).outgoing_masses().size())}, + {"diagram_index", batch_int}} + ), + _bw_cutoff(bw_cutoff), + _jet_radius(jet_radius), + _hadronic(hadronic) { + auto& incoming_masses = topologies.at(0).incoming_masses(); + auto& outgoing_masses = topologies.at(0).outgoing_masses(); + std::size_t n_ext = outgoing_masses.size() + 2; nested_vector2 valid_diags(1 << n_ext); std::vector particle_masks; std::vector all_diags; + _external_masses.insert( + _external_masses.end(), incoming_masses.begin(), incoming_masses.end() + ); + _external_masses.insert( + _external_masses.end(), outgoing_masses.begin(), outgoing_masses.end() + ); + // create a list of all diagram indices that are possible for a given clustering, // where a binary encoding of the clustering is used for (auto [topo, permutations, diag_indices] : @@ -138,7 +156,7 @@ MLMClustering::MLMClustering( all_diags.push_back(diag_index); particle_masks.assign(topo.decays().size(), 0); for (std::size_t i = 2; i < permutation.size(); ++i) { - particle_masks.at(topo.outgoing_indices().at(permutation.at(i))) = 1 + particle_masks.at(topo.outgoing_indices().at(permutation.at(i) - 2)) = 1 << i; } @@ -223,6 +241,30 @@ MLMClustering::MLMClustering( NamedVector MLMClustering::build_function_impl( FunctionBuilder& fb, const NamedVector& args ) const { - auto mlm_out = fb.mlm_clustering_hadronic(args.at(0), _cluster_state_machine); + std::array mlm_out; + Value random = fb.squeeze(fb.random(fb.batch_size(args.values()), 1)); + if (_hadronic) { + mlm_out = fb.mlm_clustering_hadronic( + args.at(0), + random, + _cluster_state_machine, + _external_masses, + _bw_masses, + _bw_widths, + _bw_cutoff, + _jet_radius + ); + } else { + mlm_out = fb.mlm_clustering_leptonic( + args.at(0), + random, + _cluster_state_machine, + _external_masses, + _bw_masses, + _bw_widths, + _bw_cutoff, + _jet_radius + ); + } return {return_types().keys(), {mlm_out.begin(), mlm_out.end()}}; } diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index 3d3313c8d6..181a51520b 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -952,6 +952,23 @@ PYBIND11_MODULE(_madspace_py, m) { "initialize_globals", &DiscreteFlow::initialize_globals, py::arg("context") ); + py::classh(m, "MLMClustering") + .def( + py::init< + std::vector, + nested_vector3, + nested_vector2, + double, + double, + bool>(), + py::arg("topologies"), + py::arg("permutations"), + py::arg("diagram_indices"), + py::arg("bw_cutoff") = 15., + py::arg("jet_radius") = 0.4, + py::arg("hadronic") = true + ); + py::classh(m, "VegasGridOptimizer") .def( "add_data", diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py new file mode 100644 index 0000000000..8a8bc969e1 --- /dev/null +++ b/madspace/tests/test_mlm.py @@ -0,0 +1,60 @@ +import json +import os + +import numpy as np +import torch + +import madspace as ms + +BATCH_SIZE = 100 +CM_ENERGY = 13000.0 +rng = np.random.default_rng(5678) + +TTGG_FILE = os.path.join( + os.path.dirname(os.path.realpath(__file__)), "processes", "ttgg.json" +) + + +def test_mlm_clustering(): + with open(TTGG_FILE) as f: + diagrams = json.load(f) + + topologies = [ + ms.Topology( + ms.Diagram( + d["incoming_masses"], + d["outgoing_masses"], + [ms.Propagator(*p) for p in d["propagators"]], + d["vertices"], + ) + ) + for d in diagrams + ] + permutations = [d["permutations"] for d in diagrams] + offset = 0 + diagram_indices = [] + for d in diagrams: + n_perms = len(d["permutations"]) + diagram_indices.append(list(range(offset, offset + n_perms))) + offset += n_perms + + clustering = ms.MLMClustering(topologies, permutations, diagram_indices) + + first = diagrams[0] + mapping = ms.PhaseSpaceMapping( + topologies[0], CM_ENERGY, permutations=first["permutations"] + ) + r = rng.random((BATCH_SIZE, mapping.random_dim())) + perm_count = len(first["permutations"]) + condition = ( + [] + if perm_count <= 1 + else [rng.integers(0, perm_count, BATCH_SIZE, dtype=np.int32)] + ) + p_ext, _, _, _ = mapping.map_forward([r], condition) + ren_scale, fact_scale1, fact_scale2, outgoing_scales, diagram_index = clustering( + p_ext + ) + from icecream import ic + + print(ren_scale, fact_scale1, fact_scale2, outgoing_scales, diagram_index) From bed30160262699966d15ffa056737d9fc9233ee6 Mon Sep 17 00:00:00 2001 From: Theo Heimel Date: Fri, 3 Jul 2026 16:25:34 +0200 Subject: [PATCH 09/81] mlm scale computation bugfixes --- madspace/src/kernels/mlm.hpp | 13 +++++----- madspace/src/phasespace/mlm_clustering.cpp | 28 ++++++++++++---------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 290efa04cb..1e30b63e1b 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -12,7 +12,7 @@ constexpr double ONE_PLUS_TINY = 1.000001; // mT^2 = E^2 - pz^2 (hadronic) or E^2 (lepton collider). // based on djb_clus from Template/NLO/SubProcesses/cluster.f template -KERNELSPEC FVal djb_clus(FourMom p, bool hadronic) { +KERNELSPEC FVal djb_clus(const FourMom& p, bool hadronic) { auto r = hadronic ? (p[0] - p[3]) * (p[0] + p[3]) : p[0] * p[0]; return max(r, 0.0); } @@ -24,8 +24,8 @@ KERNELSPEC FVal djb_clus(FourMom p, bool hadronic) { // based on dj_clus from Template/NLO/SubProcesses/cluster.f template KERNELSPEC FVal dj_clus( - FourMom p1, - FourMom p2, + const FourMom& p1, + const FourMom& p2, FVal mass1, FVal mass2, bool hadronic, @@ -99,11 +99,10 @@ KERNELSPEC FVal compute_scale( bool hadronic, FVal jet_radius ) { - if (is_initial) { // scale = mT of the final-state parton // small penalty when it goes against the beam - auto scale = sqrt(djb_clus(momentum1, hadronic)); + auto scale = sqrt(djb_clus(momentum2, hadronic)); if ((momentum1[3] < 0.0) != (momentum2[3] < 0.0)) { scale = scale * ONE_PLUS_TINY; } @@ -229,7 +228,7 @@ KERNELSPEC void mlm_clustering( bool massive_out1 = (data >> 25) & 1; bool massive_out2 = (data >> 26) & 1; bool is_last = (data >> 30) & 1; - bool is_initial = (particle2 < 2); + bool is_initial = (particle1 < 2); FourMom momentum_sum{ momenta_tmp[particle1][0] + momenta_tmp[particle2][0], @@ -285,7 +284,7 @@ KERNELSPEC void mlm_clustering( ++cluster_count; win_scale = 1e308; } else { - ++state; + state += 2; } } diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index 60a4f2fede..42e96f63e0 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -87,19 +87,21 @@ void find_clusterings( if (new_masks.size() == 3) { auto& current_states = states.at(index); - for (int diag_index : new_diags) { - current_states.push_back({ - .next_state = diag_index, - .particle1 = 0, - .particle2 = 0, - .mass_index = 0, - .massive_in = false, - .massive_out1 = false, - .massive_out2 = false, - .is_qcd = false, - .is_jet1 = false, - .is_jet2 = false, - }); + if (current_states.size() == 0) { + for (int diag_index : new_diags) { + current_states.push_back({ + .next_state = diag_index, + .particle1 = 0, + .particle2 = 0, + .mass_index = 0, + .massive_in = false, + .massive_out1 = false, + .massive_out2 = false, + .is_qcd = false, + .is_jet1 = false, + .is_jet2 = false, + }); + } } } else { find_clusterings( From f5060f1934010016f31e35f0321e3fc73bc679ca Mon Sep 17 00:00:00 2001 From: Theo Heimel Date: Mon, 6 Jul 2026 15:06:59 +0200 Subject: [PATCH 10/81] add mlm as option for dynamical scale --- .../iolibs/template_files/mg7/madevent.py | 39 +++++++++++++------ .../iolibs/template_files/mg7/run_card.toml | 3 +- .../include/madspace/phasespace/scale.hpp | 3 ++ madspace/src/phasespace/scale.cpp | 7 ++++ madspace/src/python/madspace.cpp | 3 +- 5 files changed, 41 insertions(+), 14 deletions(-) diff --git a/madgraph/iolibs/template_files/mg7/madevent.py b/madgraph/iolibs/template_files/mg7/madevent.py index 57c6f73c91..33039f36d2 100644 --- a/madgraph/iolibs/template_files/mg7/madevent.py +++ b/madgraph/iolibs/template_files/mg7/madevent.py @@ -275,18 +275,22 @@ def init_beam(self) -> None: "half_transverse_mass": ms.EnergyScale.half_transverse_mass, "partonic_energy": ms.EnergyScale.partonic_energy, } - if beam_args["dynamical_scale_choice"] in dynamical_scales: + if beam_args["dynamical_scale_choice"] == "mlm": + self.mlm_clustering = True + self.scale_kwargs = {} + elif beam_args["dynamical_scale_choice"] in dynamical_scales: dynamical_scale_type = dynamical_scales[beam_args["dynamical_scale_choice"]] + self.mlm_clustering = False + self.scale_kwargs = dict( + dynamical_scale_type=dynamical_scale_type, + ren_scale_fixed=beam_args["fixed_ren_scale"], + fact_scale_fixed=beam_args["fixed_fact_scale"], + ren_scale=beam_args["ren_scale"], + fact_scale1=beam_args["fact_scale1"], + fact_scale2=beam_args["fact_scale2"], + ) else: raise ValueError("Unknown dynamical scale choice") - self.scale_kwargs = dict( - dynamical_scale_type=dynamical_scale_type, - ren_scale_fixed=beam_args["fixed_ren_scale"], - fact_scale_fixed=beam_args["fixed_fact_scale"], - ren_scale=beam_args["ren_scale"], - fact_scale1=beam_args["fact_scale1"], - fact_scale2=beam_args["fact_scale2"], - ) pdf_set = beam_args["pdf"] if PDF_PATH is None: @@ -851,9 +855,20 @@ def __init__(self, process: MadgraphProcess, meta: dict, subproc_id: int): else None ) - self.scale = ms.EnergyScale( - particle_count=self.particle_count, **self.process.scale_kwargs - ) + if self.process.mlm_clustering: + mc_data = self.build_multi_channel_data() + self.scale = ms.EnergyScale(ms.MLMClustering( + topologies = [topo[0] for topo in mc_data.topologies], + permutations = mc_data.permutations, + diagram_indices = mc_data.diagram_indices, + bw_cutoff = self.process.run_card["phasespace"]["bw_cutoff"], + jet_radius = self.process.run_card["beam"]["jet_radius"], + hadronic = not self.process.leptonic, + )) + else: + self.scale = ms.EnergyScale( + particle_count=self.particle_count, **self.process.scale_kwargs + ) if self.process.run_card["run"]["dummy_matrix_element"]: self.matrix_element = None diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index 83af645394..8a94ca1e57 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -26,8 +26,9 @@ fixed_fact_scale = true ren_scale = 91.188 fact_scale1 = 91.188 fact_scale2 = 91.188 -# options: transverse_energy, transverse_mass, half_transverse_mass, partonic_energy +# options: transverse_energy, transverse_mass, half_transverse_mass, partonic_energy, mlm dynamical_scale_choice = "half_transverse_mass" +jet_radius = 0.4 [generation] events = 100000 diff --git a/madspace/include/madspace/phasespace/scale.hpp b/madspace/include/madspace/phasespace/scale.hpp index b53235821b..d55bc8efaa 100644 --- a/madspace/include/madspace/phasespace/scale.hpp +++ b/madspace/include/madspace/phasespace/scale.hpp @@ -1,6 +1,7 @@ #pragma once #include "madspace/phasespace/base.hpp" +#include "madspace/phasespace/mlm_clustering.hpp" namespace madspace { @@ -36,6 +37,7 @@ class EnergyScale : public FunctionGenerator { double fact_scale1, double fact_scale2 ); + EnergyScale(const MLMClustering& clustering); private: NamedVector build_function_impl( @@ -48,6 +50,7 @@ class EnergyScale : public FunctionGenerator { double _ren_scale; double _fact_scale1; double _fact_scale2; + std::optional _clustering; }; } // namespace madspace diff --git a/madspace/src/phasespace/scale.cpp b/madspace/src/phasespace/scale.cpp index e6106314d5..d39d71bcd7 100644 --- a/madspace/src/phasespace/scale.cpp +++ b/madspace/src/phasespace/scale.cpp @@ -25,10 +25,17 @@ EnergyScale::EnergyScale( _fact_scale1(fact_scale1), _fact_scale2(fact_scale2) {} +EnergyScale::EnergyScale(const MLMClustering& clustering) : + FunctionGenerator("EnergyScale", clustering.arg_types(), clustering.return_types()), + _clustering(clustering) {} + NamedVector EnergyScale::build_function_impl( FunctionBuilder& fb, const NamedVector& args ) const { auto momenta = args.at(0); + if (_clustering) { + return _clustering.value().build_function(fb, args); + } if (_ren_scale_fixed && _fact_scale_fixed) { auto batch_size = fb.batch_size({momenta}); return { diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index 64ec9baf05..07ddde36b9 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -1185,7 +1185,8 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("ren_scale"), py::arg("fact_scale1"), py::arg("fact_scale2") - ); + ) + .def(py::init(), py::arg("clustering")); py::classh(m, "CachedPdf").def(py::init<>()); py::classh(m, "CachedScale") From bde606ddb8f3f6e4ed2f7c5923d9b78eefcdc50d Mon Sep 17 00:00:00 2001 From: Theo Heimel Date: Mon, 6 Jul 2026 15:51:49 +0200 Subject: [PATCH 11/81] cluster scales in lhe output --- madgraph/iolibs/template_files/mg7/run_card.toml | 2 +- madspace/include/madspace/driver/lhe_output.hpp | 2 ++ madspace/include/madspace/phasespace/scale.hpp | 2 ++ madspace/src/driver/lhe_output.cpp | 12 ++++++++++++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index 8a94ca1e57..47418315b8 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -11,7 +11,7 @@ simd_vector_size = -1 cpu_thread_pool_size = -1 gpu_thread_pool_size = 1 combine_thread_pool_size = -1 -output_format = "compact_npy" # options: compact_npy, lhe_npy, lhe +output_format = "lhe" # options: compact_npy, lhe_npy, lhe verbosity = "pretty" # options: silent, pretty, log dummy_matrix_element = false save_gridpack = false diff --git a/madspace/include/madspace/driver/lhe_output.hpp b/madspace/include/madspace/driver/lhe_output.hpp index 5451d6f257..f29c612f70 100644 --- a/madspace/include/madspace/driver/lhe_output.hpp +++ b/madspace/include/madspace/driver/lhe_output.hpp @@ -50,6 +50,7 @@ struct LHEParticle { double px, py, pz, energy, mass; double lifetime; double spin; + double cluster_scale; }; struct LHEEvent { @@ -60,6 +61,7 @@ struct LHEEvent { double alpha_qed; double alpha_qcd; std::vector particles; + bool has_cluster_scales; void format_to(std::string& buffer) const; }; diff --git a/madspace/include/madspace/phasespace/scale.hpp b/madspace/include/madspace/phasespace/scale.hpp index d55bc8efaa..f95cfbed38 100644 --- a/madspace/include/madspace/phasespace/scale.hpp +++ b/madspace/include/madspace/phasespace/scale.hpp @@ -39,6 +39,8 @@ class EnergyScale : public FunctionGenerator { ); EnergyScale(const MLMClustering& clustering); + bool is_mlm() const { return _clustering.has_value(); } + private: NamedVector build_function_impl( FunctionBuilder& fb, const NamedVector& args diff --git a/madspace/src/driver/lhe_output.cpp b/madspace/src/driver/lhe_output.cpp index edea68b065..33ee1752a3 100644 --- a/madspace/src/driver/lhe_output.cpp +++ b/madspace/src/driver/lhe_output.cpp @@ -53,6 +53,18 @@ void LHEEvent::format_to(std::string& buffer) const { particle.spin ); } + if (has_cluster_scales) { + std::format_to(insert_iter, ""); + } buffer += "\n"; } From a0a006985fd8ecf4efe3af297602a3a4266e418b Mon Sep 17 00:00:00 2001 From: Theo Heimel Date: Mon, 6 Jul 2026 17:07:55 +0200 Subject: [PATCH 12/81] pass mlm output to event file and matrix element --- .../iolibs/template_files/mg7/madevent.py | 13 +++++++-- .../madspace/driver/channel_generator.hpp | 1 + madspace/include/madspace/driver/io.hpp | 5 +++- madspace/src/driver/channel_generator.cpp | 17 +++++++++++ madspace/src/driver/event_generator.cpp | 22 ++++++++++++-- madspace/src/driver/lhe_output.cpp | 16 +++++----- madspace/src/phasespace/integrand.cpp | 29 +++++++++++++++++++ madspace/src/python/madspace.cpp | 3 +- 8 files changed, 92 insertions(+), 14 deletions(-) diff --git a/madgraph/iolibs/template_files/mg7/madevent.py b/madgraph/iolibs/template_files/mg7/madevent.py index 33039f36d2..e52957daad 100644 --- a/madgraph/iolibs/template_files/mg7/madevent.py +++ b/madgraph/iolibs/template_files/mg7/madevent.py @@ -1306,10 +1306,19 @@ def build_integrands( flavor_remap.append(flav["index"]) flavor_factors.append(len(flav["options"])) flavor_mirror.append(flav["mirror"]) + if self.scale.is_mlm(): + me_inputs = [ + ms.MatrixElement.diagram_in + if inp == ms.MatrixElement.random_diagram_in + else inp + for inp in ms.Integrand.matrix_element_inputs + ] + else: + me_inputs = ms.Integrand.matrix_element_inputs if self.matrix_element: matrix_element = ms.MatrixElement( self.matrix_element, - ms.Integrand.matrix_element_inputs, + me_inputs, ms.Integrand.matrix_element_outputs, True, ) @@ -1317,7 +1326,7 @@ def build_integrands( matrix_element = ms.MatrixElement( 0xBADCAFE, self.particle_count, - ms.Integrand.matrix_element_inputs, + me_inputs, ms.Integrand.matrix_element_outputs, self.meta["diagram_count"], True, diff --git a/madspace/include/madspace/driver/channel_generator.hpp b/madspace/include/madspace/driver/channel_generator.hpp index 957037d158..dae774f2dd 100644 --- a/madspace/include/madspace/driver/channel_generator.hpp +++ b/madspace/include/madspace/driver/channel_generator.hpp @@ -104,6 +104,7 @@ class ChannelEventGenerator { int color_index, helicity_index, diagram_index, flavor_index; int ren_scale, alpha_qcd; int x1, fact_scale1, x2, fact_scale2, partial_weight_product; + int cluster_scales; int random, rest; }; diff --git a/madspace/include/madspace/driver/io.hpp b/madspace/include/madspace/driver/io.hpp index 783de70da5..1ab7f211d7 100644 --- a/madspace/include/madspace/driver/io.hpp +++ b/madspace/include/madspace/driver/io.hpp @@ -115,7 +115,7 @@ class ParticleRecord { // clustering data UnalignedRef cluster_scale() { return &_data[_offsets[0] + 0]; } - void from_lhe_particle(const LHEParticle& particle) { + void from_lhe_particle(const LHEParticle& particle, bool has_clustering = false) { lhe_pdg_id() = particle.pdg_id; lhe_status_code() = particle.status_code; lhe_mother1() = particle.mother1; @@ -129,6 +129,9 @@ class ParticleRecord { lhe_mass() = particle.mass; lhe_lifetime() = particle.lifetime; lhe_spin() = particle.spin; + if (has_clustering) { + cluster_scale() = particle.cluster_scale; + } } private: diff --git a/madspace/src/driver/channel_generator.cpp b/madspace/src/driver/channel_generator.cpp index 823fb5e860..d83797b9b0 100644 --- a/madspace/src/driver/channel_generator.cpp +++ b/madspace/src/driver/channel_generator.cpp @@ -271,6 +271,11 @@ void ChannelEventGenerator::init_field_indices() { } else { _field_indices.partial_weight_product = -1; } + if (index_map.contains("cluster_scales")) { + _field_indices.cluster_scales = index_map.at("cluster_scales"); + } else { + _field_indices.cluster_scales = -1; + } _field_indices.random = index_map.at("random"); _field_indices.rest = _field_indices.random + 1; } @@ -644,6 +649,18 @@ void ChannelEventGenerator::write_events( } } + if (_field_indices.cluster_scales != -1) { + auto cluster_view = + unweighted_events.at(_field_indices.cluster_scales).view(); + for (std::size_t i = 0; i < w_view.size(); ++i) { + auto scales = cluster_view[i]; + for (std::size_t j = 0; j < _particle_count; ++j) { + auto particle = event_buffer.particle(i, j); + particle.cluster_scale() = j >= 2 ? scales[j - 2] : 0.; + } + } + } + _event_file.write(event_buffer); _weight_file.write(weight_buffer); _status.count_unweighted += diff --git a/madspace/src/driver/event_generator.cpp b/madspace/src/driver/event_generator.cpp index af8e3a59ba..3a078d3ea0 100644 --- a/madspace/src/driver/event_generator.cpp +++ b/madspace/src/driver/event_generator.cpp @@ -399,10 +399,14 @@ void EventGenerator::combine_to_lhe_npy( buffer_out.event(i).from_lhe_event(lhe_event); std::size_t j = 0; for (; j < lhe_event.particles.size(); ++j) { - buffer_out.particle(i, j).from_lhe_particle(lhe_event.particles[j]); + buffer_out.particle(i, j).from_lhe_particle( + lhe_event.particles[j], lhe_event.has_cluster_scales + ); } for (; j < lhe_completer.max_particle_count(); ++j) { - buffer_out.particle(i, j).from_lhe_particle(LHEParticle{}); + buffer_out.particle(i, j).from_lhe_particle( + LHEParticle{}, lhe_event.has_cluster_scales + ); } } event_file.write(buffer_out); @@ -606,6 +610,8 @@ void EventGenerator::read_and_combine( bool has_beam2 = _channels.at(0)->event_layout_extra_flags() & EventRecord::f_beam2; bool has_partial = _channels.at(0)->event_layout_extra_flags() & EventRecord::f_partial_weights; + bool has_clustering = + _channels.at(0)->particle_layout_extra_flags() & ParticleRecord::f_clustering; std::random_device rand_device; std::mt19937 rand_gen(rand_device()); @@ -671,6 +677,9 @@ void EventGenerator::read_and_combine( particle_out.px() = particle_in.px(); particle_out.py() = particle_in.py(); particle_out.pz() = particle_in.pz(); + if (has_clustering) { + particle_out.cluster_scale() = particle_in.cluster_scale(); + } } for (; i < buffer.particle_count(); ++i) { auto particle_out = buffer.particle(event_index, i); @@ -711,6 +720,15 @@ void EventGenerator::fill_lhe_event( } ); } + bool has_clustering = + _channels.at(0)->particle_layout_extra_flags() & ParticleRecord::f_clustering; + lhe_event.has_cluster_scales = has_clustering; + if (has_clustering) { + for (std::size_t i = 0; i < lhe_event.particles.size(); ++i) { + lhe_event.particles.at(i).cluster_scale = + buffer.particle(event_index, i).cluster_scale(); + } + } lhe_completer.complete_event_data( lhe_event, event_in.subprocess_index(), diff --git a/madspace/src/driver/lhe_output.cpp b/madspace/src/driver/lhe_output.cpp index 33ee1752a3..03ada99aac 100644 --- a/madspace/src/driver/lhe_output.cpp +++ b/madspace/src/driver/lhe_output.cpp @@ -55,15 +55,15 @@ void LHEEvent::format_to(std::string& buffer) const { } if (has_cluster_scales) { std::format_to(insert_iter, ""); + std::format_to(insert_iter, ">\n"); } buffer += "\n"; } diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index 2d808ee34a..0ab7c670a6 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -94,6 +94,12 @@ Integrand::Integrand( ret_types.push_back("partial_weight_product", batch_float); } } + if (energy_scale && energy_scale->is_mlm()) { + ret_types.push_back( + "cluster_scales", + batch_float_array(mapping.particle_count() - 2) + ); + } ret_types.push_back("random", batch_float_array(mapping.random_dim())); if (mapping.channel_count() > 1 && !std::holds_alternative(discrete_before)) { @@ -261,6 +267,11 @@ NamedVector Integrand::compute_channel_part_ret_types() const { } } + if (_energy_scale && _energy_scale->is_mlm()) { + ret.push_back("cluster_scales_acc", acc_float_array(particle_count - 2)); + ret.push_back("scale_diagram_index_acc", acc_int); + } + return ret; } @@ -547,6 +558,11 @@ NamedVector Integrand::build_channel_part( out.push_back("fact_scale2", scales.at(2)); } + if (_energy_scale && _energy_scale->is_mlm()) { + out.push_back("cluster_scales_acc", scales.at("outgoing_scales")); + out.push_back("scale_diagram_index_acc", scales.at("diagram_index")); + } + return out; } @@ -597,6 +613,9 @@ NamedVector Integrand::build_common_part( momenta_acc, _flavor_remap.size() > 0 ? fb.gather_int(flavor_id, _flavor_remap) : flavor_id, }; + if (_energy_scale && _energy_scale->is_mlm()) { + xs_args.push_back(args.at("scale_diagram_index_acc")); + } xs_args.push_back(x1_acc); xs_args.push_back(x2_acc); xs_args.push_back(flavor_id); @@ -777,6 +796,16 @@ NamedVector Integrand::build_common_part( ); } } + if (_energy_scale && _energy_scale->is_mlm()) { + auto outgoing_count = static_cast(_mapping.particle_count() - 2); + outputs.push_back( + "cluster_scales", + scatter_or_drop( + fb.full({0., batch_size_val, outgoing_count}), + args.at("cluster_scales_acc") + ) + ); + } outputs.push_back("random", optional_cut(args.at("r"))); if (has_permutations && !std::holds_alternative(_discrete_before)) { diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index 07ddde36b9..4266923a58 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -1186,7 +1186,8 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("fact_scale1"), py::arg("fact_scale2") ) - .def(py::init(), py::arg("clustering")); + .def(py::init(), py::arg("clustering")) + .def("is_mlm", &EnergyScale::is_mlm); py::classh(m, "CachedPdf").def(py::init<>()); py::classh(m, "CachedScale") From 42b0eddda9a1e462faca30b1c5fd8eb55b1ba65e Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sat, 5 Sep 2026 10:34:27 +0200 Subject: [PATCH 13/81] Fix the MLM clustering, populate its vertex metadata, and test it The clustering compiled and ran but the histories it produced were not usable. Fixes, in rough order of impact: * update_momenta was called with its i_remove/i_keep arguments swapped. The state machine names the merged pseudo-particle by the lowest bit of the combined mask, i.e. by particle1, so particle1 is the slot that has to survive; keeping particle2 left the merged momentum where no later state looks, and made the "i_keep < 2" initial-state branch dead and inconsistent with the caller's "is_initial = particle1 < 2". The Fortran settles the convention: in cluster_one_step the removed index runs from 3 and the kept one runs 1..i-1, so the kept index is the lower one and "jwin <= 2" means the kept particle is a beam. * The winner selection was only partly reset between clustering steps. win_resonant and win_next_state survived, so after a resonant step no non-resonant candidate could win the next one, and the walk would then jump to the previous step's target. * Nothing guaranteed a winner. A NaN measure never compares less than anything, so a step whose candidates were all NaN left win_next_state at -1 and the kernel read outside the state machine - an actual crash, reproducible at a few events per thousand once the initial-state branch of update_momenta became reachable, since its boost and rotation can leave a pair exactly collinear. dj_clus now zeroes a NaN result the way the Fortran does, and the selection takes the first candidate unconditionally. * find_clusterings expanded a state once per path reaching it, appending a duplicate copy of its transitions each time. * bw_cutoff was threaded into the kernel and never read: the resonance window was mass +- width instead of mass +- bw_cutoff * width. * external_masses is indexed by external leg but was built in topology slot order. The permutation is now applied, and diagrams that disagree on a leg's mass throw instead of silently picking one. * alive was hardcoded to 0xFFFFFF regardless of the particle count, and a dead-end state would have been read out of bounds. The vertex metadata (is_qcd, is_jet1/2, mass_index, massive_in/out) was hardcoded, so massless QCD was assumed everywhere and the resonance branch was unreachable. Masses and widths now come from Topology::Decay and from the t-channel propagator lists; Topology gained t_propagator_pdg_ids() alongside them, since the flavour of a t-channel line changes along the chain. The external pdg ids are not in Topology at all, so MLMClustering takes them as a new optional argument, which launch.py fills from the pids it already computes. Omitting it keeps the old "everything is a QCD jet" behaviour. tests/test_mlm.py replaces the smoke test with 50 tests covering the structure of the compiled state machine, the kernel outputs, the symmetries of the clustering, the resonance window, and the clustering measures against transcriptions of the Fortran. Where a property does not hold exactly, the test pins how far off it is and says why: * the jet-scale assignment flips for a few percent of events under a symmetry transformation, because degenerate candidates make the argmin unstable; * the choice of clustering is not longitudinally boost invariant, because the 1.000001 "against the beam" penalty tests sign(pz); * scales are not exactly homogeneous in the momenta, because update_momenta keeps the Fortran's absolute 100 GeV^2 guard against boosting into a degenerate frame. docs/mlm-merging-status.md records all of this along with the known differences from madevent and what is still open. Co-Authored-By: Claude Opus 5 --- docs/mlm-merging-status.md | 166 ++++ madgraph/iolibs/template_files/mg7/launch.py | 2 + .../iolibs/template_files/mg7/run_card.toml | 2 + madgraph/various/banner.py | 3 + .../madspace/phasespace/mlm_clustering.hpp | 15 +- .../include/madspace/phasespace/topology.hpp | 7 + madspace/src/kernels/mlm.hpp | 55 +- madspace/src/phasespace/mlm_clustering.cpp | 317 ++++++- madspace/src/phasespace/topology.cpp | 4 + madspace/src/python/madspace.cpp | 16 +- madspace/tests/test_mlm.py | 859 +++++++++++++++++- 11 files changed, 1350 insertions(+), 96 deletions(-) create mode 100644 docs/mlm-merging-status.md diff --git a/docs/mlm-merging-status.md b/docs/mlm-merging-status.md new file mode 100644 index 0000000000..9f7a9e0ba4 --- /dev/null +++ b/docs/mlm-merging-status.md @@ -0,0 +1,166 @@ +# MLM merging in madspace — status, findings and open items + +Working notes for the `feat-mlm` branch after merging `main` into it. + +## What the feature is + +`madspace/src/phasespace/mlm_clustering.cpp` compiles the diagram topologies of a +subprocess into a state machine of possible clustering histories, encoded as a +flat integer array. `madspace/src/kernels/mlm.hpp` walks that state machine for +one phase-space point, picks a clustering history by minimising a clustering +measure at each step, and reads off: + +* the renormalisation scale, +* the factorisation scales, +* a clustering scale per outgoing jet (written to the LHE as `pt_clust_i`), +* the index of the diagram the history ends on (for colour flows and resonances + in the LHE output). + +`djb_clus`, `dj_clus`, `compute_scale` and `update_momenta` are ports of the +routines of the same name in `Template/NLO/SubProcesses/cluster.f`. + +## Merge with main + +The branch was 699 commits behind. Most of the conflict surface was not real: + +* The generated instruction-set headers (`compgraphs/*.inc`, the cpu/gpu runtime + mixins, `python/instruction_set.hpp`) are no longer checked in — main + generates them from `instruction_set.yaml` via `generate_headers.py`. Only the + two new `mlm_clustering_{hadronic,leptonic}` YAML entries were kept. +* `madevent.py` was split into `launch.py` / `run_interface.py`; the MLM hunks + were re-applied to `launch.py`. The matrix element is now built in a loop over + backends, so `me_inputs` is computed once before that loop. +* `run_card.toml` is now a template filled from `banner.py`, so `"mlm"`, + `beam.jet_radius` and `beam.max_jet_flavor` are declared there. +* `LHECompleter` was refactored on main; main's version was taken. The branch's + `TODO` about the permutation direction is gone because main fixed exactly + that, by inverting the permutation before using it. + +## Bugs found and fixed + +1. **`update_momenta` arguments were swapped.** The kernel called it as + `(..., p1_win, p2_win, ...)` against the signature + `(..., i_remove, i_keep, ...)`, so it kept `particle2` and removed + `particle1`. The state machine names the merged pseudo-particle by the lowest + bit of the combined mask, i.e. by `particle1`, so every later state referred + to a slot whose momentum had not been updated. It also made the + `i_keep < 2` initial-state branch unreachable, and inconsistent with the + caller's `is_initial = particle1 < 2`. + + The Fortran settles it: in `cluster_one_step`, `i` (removed) runs from 3 and + `j` (kept) runs `1..i-1`, so the kept index is the lower one and `jwin <= 2` + means "the kept particle is a beam". + +2. **The winner selection was not reset between clustering steps.** Only + `win_scale` was reset; `win_resonant` and `win_next_state` kept their values. + After a resonant step, no non-resonant candidate could ever win the next + step, and the walk would then jump to the previous step's target state. + +3. **Nothing guaranteed a winner at all.** `win_next_state` starts at `-1` and is + only assigned when a candidate beats the current best. A NaN measure never + compares less than anything, so a step in which every candidate was NaN left + `state = -1` and the kernel read outside the state machine — an actual crash, + reproducible at a few events per thousand for strongly asymmetric beam + energies. The boost and rotation `update_momenta` applies after an + initial-state clustering can leave a pair exactly collinear, which is where + the NaNs come from. + + Two fixes: `dj_clus` now zeroes a NaN result the way the Fortran does + ("prevent numerical inaccuracies"), and the selection takes the first + candidate unconditionally so a clustering is always chosen. + +4. **State machine states were expanded more than once.** In `find_clusterings` + the terminal branch guarded re-expansion with `if (current_states.size() == 0)` + but the recursive branch did not, so a state reached along several paths had + its whole transition list appended once per path. Harmless for the result, + but the table grows exponentially with multiplicity. + +5. **`bw_cutoff` was dead.** It was threaded from the run card all the way into + the kernel signature and never read: the resonance window was `mass ± width` + rather than `mass ± bw_cutoff * width`. + +6. **External masses were indexed by topology slot, not by external leg.** The + kernel indexes `external_masses` by leg, but the array was built as + `incoming_masses ++ outgoing_masses`, which is in slot order. Invisible for + the test processes, whose permutations only exchange identical gluons, but + wrong for any permutation that moves a mass between legs. The permutation is + now applied, and a mismatch between diagrams throws instead of being silently + ignored. + +7. **`alive` was hardcoded to `0xFFFFFF`** regardless of the particle count, and + an empty (dead-end) state would have been read out of bounds. Both now + handled. + +## Vertex metadata, previously not populated + +`is_qcd`, `is_jet1`, `is_jet2`, `mass_index` and the `massive_*` flags were all +hardcoded, so massless QCD was assumed everywhere and the resonance branch was +unreachable. They are now filled from the topology: + +* masses and widths come from `Topology::Decay`, and from + `t_propagator_masses()` for the t-channel chain, +* `Topology` gained `t_propagator_pdg_ids()`, filled alongside the existing + t-channel masses and widths, because the flavour of a t-channel line changes + along the chain and is needed to tell a QCD splitting from a QED one, +* the **external** pdg ids are not in `Topology` at all, so `MLMClustering` + takes them as a new constructor argument (`external_pdg_ids`, plus + `max_jet_flavor`). `launch.py` passes the `all_pids` it already computes. When + the argument is omitted the old behaviour — everything is a QCD jet — is kept, + so existing callers still work. + +`is_jet` mirrors `isjet()` in `Template/LO/SubProcesses/reweight.f` +(`|pdg| <= max_jet_flavor`, gluons, and the merged-flavour placeholder 81). A +vertex counts as QCD when all three of its lines carry colour, which is the same +rule `launch.py` already uses in `build_multi_channel_data`. A propagator pdg of +0 means "not supplied" and falls back to deciding from the two children. + +## Known differences from madevent, by design + +These are the open physics questions, not defects. They are what makes a direct +comparison against the validated madevent MLM disagree. + +* **Resonances.** MG5 reads the resonance structure off the SDE integration + channel, which madspace/madnis does not have. This implementation instead + prefers a resonant clustering over any non-resonant one, using the + Breit-Wigner window. That is a hard switch, so a phase-space distribution can + be discontinuous across the window boundary. + +* **The factorisation scale.** The kernel uses a single scale for both beams: + the smallest QCD clustering scale in the history, capped at mu_R. madevent + tracks the two QCD lines connected to each beam separately and sets + `mu_F(beam) = sqrt(pt2[jlast(beam)] * pt2[jcentral(beam)])`, so its two + factorisation scales differ from each other and neither is the softest + clustering. There is no equivalent of `jfirst`/`jlast`/`jcentral` here: the + kernel does not trace parton lines through the event the way `ipartupdate` + does. + +* **The renormalisation scale.** The kernel takes the geometric mean of all + clustering scales, replacing non-QCD ones by the largest scale. madevent takes + `(pt2[jlast1] * pt2[jcentral1] * pt2[jlast2] * pt2[jcentral2])^(1/8)`, i.e. + the geometric mean of four specific scales. + +* **No Sudakov reweighting**, which is the other half of what the NLO code does. + +## Open items + +* **The trigger for MLM is `dynamical_scale_choice = "mlm"`, which is probably + the wrong switch and needs revisiting.** In madevent, merging is turned on by + `ickkw` (with `xqcut`), while `dynamical_scale_choice = -1` selects the CKKW + clustering scale. Conflating the two means MLM merging cannot be combined with + another scale choice, and the clustering scale cannot be used without merging. + A separate `[matching]`-style switch is the likely shape. + +* Pass the selected diagram index through to the matrix element. The plumbing is + in place (`MatrixElement::diagram_in` replaces `random_diagram_in` when MLM is + active) but the madmatrix side needs per-event diagram input support. Affects + the LHE output only, not the scales. + +* The `dj_clus` massless/massive test is `mass > 0` here, where the Fortran uses + thresholds tied to `maxjetflavor` (`m >= 3` if `maxjetflavor > 4`, else + `m >= 1`). Equivalent for realistic masses; only differs for a tracked mass + between 0 and 1 GeV. + +* Different diagrams can put different propagators on the same partition of + external legs (a photon and a Z, say). The compiler keeps whichever has a + width, since that is the one the resonance test is about, but this is an + arbitrary choice where the diagrams genuinely disagree. diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index 7ab40dfb4f..2bedb0d23f 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -1361,6 +1361,8 @@ def __init__( bw_cutoff=self.process.run_card["phasespace"]["bw_cutoff"], jet_radius=self.process.run_card["beam"]["jet_radius"], hadronic=not self.process.leptonic, + external_pdg_ids=all_pids, + max_jet_flavor=self.process.run_card["beam"]["max_jet_flavor"], ) ) else: diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index f282f7c59f..01b62f22bb 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -37,6 +37,8 @@ fact_scale2 = %(beam.fact_scale2)s dynamical_scale_choice = %(beam.dynamical_scale_choice)s # jet radius used by the MLM clustering measure (only for dynamical_scale_choice = "mlm") jet_radius = %(beam.jet_radius)s +# heaviest quark flavour treated as a jet by the MLM back-clustering +max_jet_flavor = %(beam.max_jet_flavor)s [generation] events = %(generation.events)s diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index 0ca83d870e..d85c072019 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6521,6 +6521,9 @@ def default_setup(self): # Jet radius entering the kt clustering measure of the MLM back-clustering # (only used when dynamical_scale_choice = "mlm"). self.add_toml_param('beam', 'jet_radius', 0.4) + # Heaviest quark flavour treated as a jet by the MLM back-clustering + # (the counterpart of maxjetflavor in the legacy run_card). + self.add_toml_param('beam', 'max_jet_flavor', 4) # -------------------------- [generation] ---------------------- self.add_toml_param('generation', 'events', 100000, gridpack=True) diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp index aa7ce71632..e63bc60fdb 100644 --- a/madspace/include/madspace/phasespace/mlm_clustering.hpp +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -13,9 +13,22 @@ class MLMClustering : public FunctionGenerator { nested_vector2 diagram_indices, double bw_cutoff = 15, double jet_radius = 0.4, - bool hadronic = true + bool hadronic = true, + // pdg ids of the external particles, in leg order. When empty, every + // clustering is assumed to be a QCD splitting between jets. + std::vector external_pdg_ids = {}, + int max_jet_flavor = 4 ); + // The compiled clustering state machine, in the flat encoding the kernel + // walks. Exposed so that its structure can be checked directly. + const std::vector& cluster_state_machine() const { + return _cluster_state_machine; + } + const std::vector& external_masses() const { return _external_masses; } + const std::vector& bw_masses() const { return _bw_masses; } + const std::vector& bw_widths() const { return _bw_widths; } + private: NamedVector build_function_impl( FunctionBuilder& fb, const NamedVector& args diff --git a/madspace/include/madspace/phasespace/topology.hpp b/madspace/include/madspace/phasespace/topology.hpp index 63a1a06c92..0677a4097a 100644 --- a/madspace/include/madspace/phasespace/topology.hpp +++ b/madspace/include/madspace/phasespace/topology.hpp @@ -96,6 +96,12 @@ class Topology { const std::vector& t_propagator_widths() const { return _t_propagator_widths; } + // pdg ids of the t-channel propagators, in the same order as + // t_propagator_masses(). Needed to tell a QCD splitting off the initial + // state from a QED/EW one. + const std::vector& t_propagator_pdg_ids() const { + return _t_propagator_pdg_ids; + } const std::vector& decays() const { return _decays; } const std::vector& decay_integration_order() const { return _decay_integration_order; @@ -115,6 +121,7 @@ class Topology { std::vector _t_integration_order; std::vector _t_propagator_masses; std::vector _t_propagator_widths; + std::vector _t_propagator_pdg_ids; std::vector _decays; std::vector _decay_integration_order; std::vector _outgoing_indices; diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 1e30b63e1b..6ecd33c1fd 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -8,6 +8,9 @@ namespace kernels { constexpr int N_EXT_MAX = 12; constexpr double ONE_PLUS_TINY = 1.000001; +// Stand-in for a clustering measure that came out inf or NaN; still far +// above any physical scale, and small enough that SCALE_MAX * 10 is finite. +constexpr double SCALE_MAX = 1e307; // mT^2 = E^2 - pz^2 (hadronic) or E^2 (lepton collider). // based on djb_clus from Template/NLO/SubProcesses/cluster.f @@ -64,12 +67,17 @@ KERNELSPEC FVal dj_clus( auto eta2 = 0.5 * log((p2a + p2[3]) / (p2a - p2[3])); auto m_max_sq = max(mass1 * mass1, mass2 * mass2); auto dphi_cos = (p1[1] * p2[1] + p1[2] * p2[2]) / sqrt(pt1_sq * pt2_sq); - return max( - m_max_sq + - min(pt1_sq, pt2_sq) * 2.0 * (cosh(eta1 - eta2) - dphi_cos) / - (jet_radius * jet_radius), - 0.0 - ); + auto result = m_max_sq + + min(pt1_sq, pt2_sq) * 2.0 * (cosh(eta1 - eta2) - dphi_cos) / + (jet_radius * jet_radius); + // An exactly collinear pair makes eta1 or eta2 infinite and their + // difference NaN. The Fortran zeroes both negative and NaN results here + // ("prevent numerical inaccuracies"); max() alone would let the NaN + // through. + if (!(result > 0.0)) { + return 0.0; + } + return result; } // Clustering scale for the pair (momentum1=pi, momentum2=pj). @@ -204,7 +212,7 @@ KERNELSPEC void mlm_clustering( int n_part = momenta.size(); FourMom momenta_tmp[N_EXT_MAX]; FVal masses_tmp[N_EXT_MAX]; - int alive = 0xFFFFFF; + int alive = (1 << n_part) - 1; int cluster_history[N_EXT_MAX - 3]; FVal cluster_scales[N_EXT_MAX - 3]; @@ -217,7 +225,7 @@ KERNELSPEC void mlm_clustering( int win_next_state = -1, win_data = 0; bool win_resonant = false; - FVal win_scale = 1e308; + FVal win_scale = SCALE_MAX * 10.; while (cluster_count < cluster_max) { int data = state_machine[state]; int next_state = state_machine[state + 1]; @@ -242,8 +250,9 @@ KERNELSPEC void mlm_clustering( FVal prop_m2 = lsquare(momentum_sum); FVal mass = bw_masses[mass_index - 1]; FVal width = bw_widths[mass_index - 1]; - FVal m_min = mass - width; - FVal m_max = mass + width; + FVal half_window = FVal(bw_cutoff) * width; + FVal m_min = max(mass - half_window, 0.0); + FVal m_max = mass + half_window; resonant = (prop_m2 >= m_min * m_min) && (prop_m2 <= m_max * m_max); } @@ -262,10 +271,19 @@ KERNELSPEC void mlm_clustering( jet_radius ); + // An exactly collinear pair - which the boost and rotation applied after + // an initial-state clustering can produce - makes the measure inf or + // NaN. Map those onto a large finite value: they then lose to any + // well-defined clustering, but a clustering is still always chosen, so + // the walk cannot fall off the end of the state machine. + if (!(scale < SCALE_MAX)) { + scale = SCALE_MAX; + } + // The MG5 fortran code extracted the resonance structure from the integration // channel. This is not always possible in MG7, so prefer resonant configs // over non-resonant ones - if ((!win_resonant && resonant) || + if (win_next_state == -1 || (!win_resonant && resonant) || (win_resonant == resonant && scale < win_scale)) { win_next_state = next_state; win_scale = scale; @@ -276,13 +294,20 @@ KERNELSPEC void mlm_clustering( int p1_win = win_data & 0xFF; int p2_win = (win_data >> 8) & 0xFF; update_momenta( - n_part, momenta_tmp, masses_tmp, alive, p1_win, p2_win, win_resonant + n_part, momenta_tmp, masses_tmp, alive, p2_win, p1_win, win_resonant ); state = win_next_state; cluster_history[cluster_count] = win_data; cluster_scales[cluster_count] = win_scale; ++cluster_count; - win_scale = 1e308; + // Reset the whole selection, not just the scale: leaving + // win_resonant set would stop any non-resonant candidate from ever + // winning the next step, and leaving win_next_state set would send + // the walk to a stale state if that happened. + win_next_state = -1; + win_data = 0; + win_resonant = false; + win_scale = SCALE_MAX * 10.; } else { state += 2; } @@ -317,10 +342,10 @@ KERNELSPEC void mlm_clustering( bool is_jet1 = (data >> 28) & 1; bool is_jet2 = (data >> 29) & 1; if (is_qcd) { - if (is_jet1 && is_last_cluster & (1 << particle1)) { + if (is_jet1 && (is_last_cluster & (1 << particle1))) { outgoing_scales[particle1 - 2] = scale; } - if (is_jet2 && is_last_cluster & (1 << particle2)) { + if (is_jet2 && (is_last_cluster & (1 << particle2))) { outgoing_scales[particle2 - 2] = scale; } ren_scale_val *= scale; diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index 42e96f63e0..4e6d3a1c88 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -1,12 +1,54 @@ #include "madspace/phasespace/mlm_clustering.hpp" #include +#include +#include #include using namespace madspace; namespace { +// Largest number of external particles the kernel can handle (mlm.hpp +// N_EXT_MAX), and the number of bits available for a clustering mask. +constexpr std::size_t n_ext_max = 12; + +// Properties of the line a clustering mask stands for: the external particle +// itself for a single-bit mask, the propagator recombining them otherwise. +struct LineMeta { + double mass = 0.; + double width = 0.; + int pdg_id = 0; + bool known = false; +}; + +// Mirrors isjet() in Template/LO/SubProcesses/reweight.f: a final state +// particle a parton shower would turn into a jet, and hence one that gets its +// own clustering scale in the LHE output. 81 is the merged-flavor placeholder +// used by flavor grouping. +bool is_jet_pdg(int pdg_id, int max_jet_flavor) { + int a = std::abs(pdg_id); + return (a >= 1 && a <= max_jet_flavor) || a == 21 || a == 81; +} + +// Whether a line carries color. Used to decide whether a *vertex* is a QCD +// splitting, which is what selects the clustering scales entering mu_R/mu_F. +// 81-83 are the merged-flavor placeholders. +bool is_colored_pdg(int pdg_id) { + int a = std::abs(pdg_id); + return (a >= 1 && a <= 6) || a == 21 || (a >= 81 && a <= 83); +} + +struct CompileContext { + const nested_vector2& valid_diags; + const std::vector& mask_meta; + std::vector& bw_masses; + std::vector& bw_widths; + std::map, int>& bw_indices; + bool have_pdg_ids; + int max_jet_flavor; +}; + using StateKey = std::pair, std::vector>; struct StateItem { int next_state; @@ -21,10 +63,81 @@ struct StateItem { bool is_jet2; }; +// 1-based index into the Breit-Wigner tables handed to the kernel, or 0 for +// "never treat this clustering as resonant". +int breit_wigner_index(CompileContext& ctx, const LineMeta& meta) { + if (!meta.known || meta.width <= 0. || meta.mass <= 0.) { + return 0; + } + auto key = std::pair{meta.mass, meta.width}; + if (auto search = ctx.bw_indices.find(key); search != ctx.bw_indices.end()) { + return search->second; + } + int index = static_cast(ctx.bw_masses.size()) + 1; + ctx.bw_masses.push_back(meta.mass); + ctx.bw_widths.push_back(meta.width); + ctx.bw_indices[key] = index; + return index; +} + +StateItem make_state_item( + CompileContext& ctx, int next_state, int mask_in, int mask_1, int mask_2 +) { + int particle1 = std::countr_zero(static_cast(mask_1)); + int particle2 = std::countr_zero(static_cast(mask_2)); + auto& meta_in = ctx.mask_meta.at(mask_in); + auto& meta_1 = ctx.mask_meta.at(mask_1); + auto& meta_2 = ctx.mask_meta.at(mask_2); + // The state machine always names the merged pseudo-particle by the lowest + // bit of the combined mask, so particle1 < particle2 and an initial-state + // (t-channel) clustering is exactly the case particle1 < 2. + bool is_initial = particle1 < 2; + + // A t-channel propagator is spacelike and can never go on shell, so the + // resonance test is only meaningful for a final-state clustering. + int mass_index = is_initial ? 0 : breit_wigner_index(ctx, meta_in); + + bool is_qcd, is_jet1, is_jet2; + if (!ctx.have_pdg_ids) { + // No flavor information supplied: fall back to assuming every + // clustering is a massless QCD splitting between jets. + is_qcd = is_jet1 = is_jet2 = true; + } else { + // A vertex is a QCD splitting when all three of its lines carry color. + // Checking the parent matters: q qbar -> Z has two colored children and + // a colorless parent, and is not a QCD vertex. + // + // A pdg id of 0 means the propagator's flavor was not supplied (the + // topology fixtures used by the tests carry masses only). Fall back to + // the two children then: a splitting of two colored lines is QCD unless + // the parent says otherwise, which is also the only test available for + // t-channel lines whose flavor changes along the chain. + is_qcd = is_colored_pdg(meta_1.pdg_id) && is_colored_pdg(meta_2.pdg_id) && + (meta_in.pdg_id == 0 || is_colored_pdg(meta_in.pdg_id)); + // Only final state particles get a clustering scale in the LHE output, + // so a beam leg is never a jet here. + is_jet1 = !is_initial && is_jet_pdg(meta_1.pdg_id, ctx.max_jet_flavor); + is_jet2 = is_jet_pdg(meta_2.pdg_id, ctx.max_jet_flavor); + } + + return { + .next_state = next_state, + .particle1 = particle1, + .particle2 = particle2, + .mass_index = mass_index, + .massive_in = meta_in.mass > 0., + .massive_out1 = meta_1.mass > 0., + .massive_out2 = meta_2.mass > 0., + .is_qcd = is_qcd, + .is_jet1 = is_jet1, + .is_jet2 = is_jet2, + }; +} + void find_clusterings( + CompileContext& ctx, const std::vector& particle_masks, const std::vector& diagrams, - const nested_vector2& valid_diags, nested_vector2& states, std::map& state_map, int prev_index @@ -34,7 +147,7 @@ void find_clusterings( for (int j = i + 1; j < n_masks; ++j) { int mask_i = particle_masks.at(i), mask_j = particle_masks.at(j); int mask = mask_i | mask_j; - auto& valid = valid_diags.at(mask); + auto& valid = ctx.valid_diags.at(mask); if (valid.size() == 0) { continue; } @@ -57,61 +170,69 @@ void find_clusterings( } } + bool is_terminal = new_masks.size() == 3; StateKey key; - if (new_masks.size() == 3) { + if (is_terminal) { key = {{}, new_diags}; } else { key = {new_masks, new_diags}; } int index; + bool is_new_state; if (auto search = state_map.find(key); search != state_map.end()) { index = search->second; + is_new_state = false; } else { index = states.size(); state_map[key] = index; states.push_back({}); + is_new_state = true; } states.at(prev_index) - .push_back({ - .next_state = index, - .particle1 = std::countr_zero(static_cast(mask_i)), - .particle2 = std::countr_zero(static_cast(mask_j)), - .mass_index = 0, - .massive_in = false, - .massive_out1 = false, - .massive_out2 = false, - .is_qcd = true, - .is_jet1 = true, - .is_jet2 = true, - }); + .push_back(make_state_item(ctx, index, mask, mask_i, mask_j)); - if (new_masks.size() == 3) { - auto& current_states = states.at(index); - if (current_states.size() == 0) { - for (int diag_index : new_diags) { - current_states.push_back({ - .next_state = diag_index, - .particle1 = 0, - .particle2 = 0, - .mass_index = 0, - .massive_in = false, - .massive_out1 = false, - .massive_out2 = false, - .is_qcd = false, - .is_jet1 = false, - .is_jet2 = false, - }); - } + // Only expand a state the first time it is reached. Expanding it + // again would append a second copy of the same transitions, once + // per path leading to it. + if (!is_new_state) { + continue; + } + if (is_terminal) { + for (int diag_index : new_diags) { + states.at(index).push_back({ + .next_state = diag_index, + .particle1 = 0, + .particle2 = 0, + .mass_index = 0, + .massive_in = false, + .massive_out1 = false, + .massive_out2 = false, + .is_qcd = false, + .is_jet1 = false, + .is_jet2 = false, + }); } } else { find_clusterings( - new_masks, new_diags, valid_diags, states, state_map, index + ctx, new_masks, new_diags, states, state_map, index ); } } } } +// Record the properties of the line a mask stands for. Different diagrams can +// put different propagators on the same partition of external legs (a photon +// and a Z, say); keep the one with a width, since that is the one the +// resonance test is about. +void set_mask_meta(std::vector& mask_meta, int mask, LineMeta meta) { + auto& current = mask_meta.at(mask); + if (!current.known || (current.width <= 0. && meta.width > 0.)) { + meta.known = true; + current = meta; + } +} + } // namespace MLMClustering::MLMClustering( @@ -120,7 +241,9 @@ MLMClustering::MLMClustering( nested_vector2 diagram_indices, double bw_cutoff, double jet_radius, - bool hadronic + bool hadronic, + std::vector external_pdg_ids, + int max_jet_flavor ) : FunctionGenerator( "MLMClustering", @@ -136,24 +259,38 @@ MLMClustering::MLMClustering( _bw_cutoff(bw_cutoff), _jet_radius(jet_radius), _hadronic(hadronic) { - auto& incoming_masses = topologies.at(0).incoming_masses(); - auto& outgoing_masses = topologies.at(0).outgoing_masses(); - std::size_t n_ext = outgoing_masses.size() + 2; + std::size_t n_ext = topologies.at(0).outgoing_masses().size() + 2; + if (n_ext > n_ext_max) { + throw std::invalid_argument(std::format( + "MLM clustering supports at most {} external particles, got {}", + n_ext_max, + n_ext + )); + } + bool have_pdg_ids = external_pdg_ids.size() > 0; + if (have_pdg_ids && external_pdg_ids.size() != n_ext) { + throw std::invalid_argument(std::format( + "expected {} external pdg ids, got {}", n_ext, external_pdg_ids.size() + )); + } + nested_vector2 valid_diags(1 << n_ext); + std::vector mask_meta(1 << n_ext); std::vector particle_masks; std::vector all_diags; - _external_masses.insert( - _external_masses.end(), incoming_masses.begin(), incoming_masses.end() - ); - _external_masses.insert( - _external_masses.end(), outgoing_masses.begin(), outgoing_masses.end() - ); + // The kernel indexes momenta and external_masses by external leg, while a + // topology's masses are indexed by topology slot, so the permutation has to + // be applied. Start from a sentinel so the first diagram fills the table and + // every later one is checked against it. + _external_masses.assign(n_ext, -1.); // create a list of all diagram indices that are possible for a given clustering, // where a binary encoding of the clustering is used for (auto [topo, permutations, diag_indices] : zip(topologies, permutations, diagram_indices)) { + auto& incoming_masses = topo.incoming_masses(); + auto& outgoing_masses = topo.outgoing_masses(); for (auto [permutation, diag_index] : zip(permutations, diag_indices)) { all_diags.push_back(diag_index); particle_masks.assign(topo.decays().size(), 0); @@ -162,6 +299,36 @@ MLMClustering::MLMClustering( << i; } + // permutation maps external leg -> topology slot, so the mass of leg + // i is the mass of the slot it is mapped to. + for (std::size_t leg = 0; leg < n_ext; ++leg) { + double mass = leg < 2 + ? incoming_masses.at(leg) + : outgoing_masses.at(permutation.at(leg) - 2); + double& stored = _external_masses.at(leg); + if (stored < 0.) { + stored = mass; + } else if (stored != mass) { + // The kernel keeps a single external mass per leg, so a + // permutation that moves a mass between legs would silently + // give the wrong clustering measure. + throw std::invalid_argument(std::format( + "MLM clustering needs the mass of external leg {} to be the " + "same for every diagram and permutation, got {} and {}", + leg, + stored, + mass + )); + } + set_mask_meta( + mask_meta, + 1 << leg, + {.mass = mass, + .width = 0., + .pdg_id = have_pdg_ids ? external_pdg_ids.at(leg) : 0} + ); + } + bool has_t_channel = topo.t_integration_order().size() > 0; for (auto& decay : std::views::reverse(topo.decays())) { if (decay.child_indices.size() == 0) { @@ -178,6 +345,11 @@ MLMClustering::MLMClustering( particle_masks.at(decay.child_indices.at(1))); particle_masks.at(decay.index) = mask; valid_diags.at(mask).push_back(diag_index); + set_mask_meta( + mask_meta, + mask, + {.mass = decay.mass, .width = decay.width, .pdg_id = decay.pdg_id} + ); } if (!has_t_channel) { @@ -185,16 +357,43 @@ MLMClustering::MLMClustering( } // for the t-channel part, one of the initial state particles has to be - // involved in the clustering - for (int mask = 1; std::size_t index : topo.decays().at(0).child_indices) { + // involved in the clustering. The k-th mask accumulated from either + // beam is the k-th t-channel propagator along the chain, which is + // walked starting from beam 2. + auto& t_masses = topo.t_propagator_masses(); + auto& t_pdg_ids = topo.t_propagator_pdg_ids(); + std::size_t t_count = t_masses.size(); + auto& t_children = topo.decays().at(0).child_indices; + std::size_t k = 0; + for (int mask = 1; std::size_t index : t_children) { mask |= particle_masks.at(index); valid_diags.at(mask).push_back(diag_index); + if (k < t_count) { + // width stays 0: a spacelike propagator is never resonant + set_mask_meta( + mask_meta, + mask, + {.mass = t_masses.at(t_count - 1 - k), + .width = 0., + .pdg_id = t_pdg_ids.at(t_count - 1 - k)} + ); + } + ++k; } - for (int mask = 2; - std::size_t index : - std::views::reverse(topo.decays().at(0).child_indices)) { + k = 0; + for (int mask = 2; std::size_t index : std::views::reverse(t_children)) { mask |= particle_masks.at(index); valid_diags.at(mask).push_back(diag_index); + if (k < t_count) { + set_mask_meta( + mask_meta, + mask, + {.mass = t_masses.at(k), + .width = 0., + .pdg_id = t_pdg_ids.at(k)} + ); + } + ++k; } } } @@ -206,8 +405,28 @@ MLMClustering::MLMClustering( } nested_vector2 states{{}}; std::map state_map; + std::map, int> bw_indices; state_map[{{masks}, {all_diags}}] = 0; - find_clusterings(masks, all_diags, valid_diags, states, state_map, 0); + CompileContext ctx{ + .valid_diags = valid_diags, + .mask_meta = mask_meta, + .bw_masses = _bw_masses, + .bw_widths = _bw_widths, + .bw_indices = bw_indices, + .have_pdg_ids = have_pdg_ids, + .max_jet_flavor = max_jet_flavor, + }; + find_clusterings(ctx, masks, all_diags, states, state_map, 0); + + for (auto& state : states) { + if (state.size() == 0) { + // A state with no way on would leave the kernel reading past the end + // of its transition list. + throw std::logic_error( + "MLM clustering reached a state with no valid clustering left" + ); + } + } std::vector first_indices; first_indices.reserve(states.size()); diff --git a/madspace/src/phasespace/topology.cpp b/madspace/src/phasespace/topology.cpp index 3e58c08801..63ccd6bbc3 100644 --- a/madspace/src/phasespace/topology.cpp +++ b/madspace/src/phasespace/topology.cpp @@ -17,6 +17,7 @@ bool find_t_vertices( std::vector& integration_order, std::vector& _t_propagator_masses, std::vector& _t_propagator_widths, + std::vector& _t_propagator_pdg_ids, std::size_t current_index, int source_propagator ) { @@ -49,6 +50,7 @@ bool find_t_vertices( integration_order, _t_propagator_masses, _t_propagator_widths, + _t_propagator_pdg_ids, vertices.at(0) == current_index ? vertices.at(1) : vertices.at(0), @@ -60,6 +62,7 @@ bool find_t_vertices( auto& propagator = diagram.propagators().at(line_ref.index()); _t_propagator_masses.push_back(propagator.mass); _t_propagator_widths.push_back(propagator.width); + _t_propagator_pdg_ids.push_back(propagator.pdg_id); } else { out_lines.push_back(line_ref); } @@ -306,6 +309,7 @@ std::vector Topology::topologies(const Diagram& diagram) { integration_order, topo._t_propagator_masses, topo._t_propagator_widths, + topo._t_propagator_pdg_ids, diagram.incoming_vertices().at(1), -1 ); diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index 996f05a212..057efc6a06 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -983,14 +983,24 @@ PYBIND11_MODULE(_madspace_py, m) { nested_vector2, double, double, - bool>(), + bool, + std::vector, + int>(), py::arg("topologies"), py::arg("permutations"), py::arg("diagram_indices"), py::arg("bw_cutoff") = 15., py::arg("jet_radius") = 0.4, - py::arg("hadronic") = true - ); + py::arg("hadronic") = true, + py::arg("external_pdg_ids") = std::vector{}, + py::arg("max_jet_flavor") = 4 + ) + .def_property_readonly( + "cluster_state_machine", &MLMClustering::cluster_state_machine + ) + .def_property_readonly("external_masses", &MLMClustering::external_masses) + .def_property_readonly("bw_masses", &MLMClustering::bw_masses) + .def_property_readonly("bw_widths", &MLMClustering::bw_widths); py::classh(m, "VegasGridOptimizer") .def( diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 8a8bc969e1..7b5fb82332 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -1,25 +1,60 @@ +"""Tests for the MLM back-clustering (madspace/src/phasespace/mlm_clustering.cpp +and madspace/src/kernels/mlm.hpp). + +The clustering compiler turns the diagram topologies of a subprocess into a +state machine of possible clustering histories; the kernel walks that state +machine for one phase-space point, picks a history, and reads the +renormalisation / factorisation / per-jet scales off it. + +There is no single "right answer" to compare against (see the discussion in the +branch notes: the resonance handling and the factorisation-scale definition +differ from what madevent does on purpose), so these tests pin down the +properties the implementation must have whatever those choices end up being: + + * the compiled state machine is well formed and reachable, + * the kernel's outputs are finite, positive and ordered as documented, + * the clustering is invariant under the symmetries of the phase-space point, + * the individual clustering measures agree with the Fortran ones they were + ported from (Template/NLO/SubProcesses/cluster.f), + * resonant propagators are recognised inside the Breit-Wigner window. +""" + import json import os import numpy as np -import torch +import pytest import madspace as ms -BATCH_SIZE = 100 CM_ENERGY = 13000.0 -rng = np.random.default_rng(5678) +BATCH_SIZE = 200 +SEED = 5678 + +M_TOP = 173.0 +M_Z, W_Z = 91.188, 2.4414 -TTGG_FILE = os.path.join( - os.path.dirname(os.path.realpath(__file__)), "processes", "ttgg.json" -) +TEST_DATA = os.path.join(os.path.dirname(os.path.realpath(__file__)), "test_data") +# g g > t t~ g g and g g > t t~ g g g, i.e. legs (g, g, t, t~, g, ...) +PROCESSES = { + "ttgg": ("ttgg.json", [21, 21, 6, -6, 21, 21]), + "ttggg": ("ttggg.json", [21, 21, 6, -6, 21, 21, 21]), +} -def test_mlm_clustering(): - with open(TTGG_FILE) as f: - diagrams = json.load(f) - topologies = [ +# -------------------------------------------------------------------------- +# fixtures / helpers +# -------------------------------------------------------------------------- + + +def load_diagrams(file_name): + with open(os.path.join(TEST_DATA, file_name)) as f: + return json.load(f) + + +def make_topologies(diagrams): + return [ ms.Topology( ms.Diagram( d["incoming_masses"], @@ -30,31 +65,799 @@ def test_mlm_clustering(): ) for d in diagrams ] - permutations = [d["permutations"] for d in diagrams] + + +def make_diagram_indices(diagrams): + """One running index per (topology, permutation) pair, matching what + launch.py hands to MLMClustering.""" offset = 0 - diagram_indices = [] + indices = [] for d in diagrams: - n_perms = len(d["permutations"]) - diagram_indices.append(list(range(offset, offset + n_perms))) - offset += n_perms + count = len(d["permutations"]) + indices.append(list(range(offset, offset + count))) + offset += count + return indices - clustering = ms.MLMClustering(topologies, permutations, diagram_indices) - first = diagrams[0] - mapping = ms.PhaseSpaceMapping( - topologies[0], CM_ENERGY, permutations=first["permutations"] +def make_clustering(diagrams, **kwargs): + return ms.MLMClustering( + make_topologies(diagrams), + [d["permutations"] for d in diagrams], + make_diagram_indices(diagrams), + **kwargs, ) - r = rng.random((BATCH_SIZE, mapping.random_dim())) - perm_count = len(first["permutations"]) + + +def sample_momenta(diagrams, batch_size=BATCH_SIZE, seed=SEED): + """Phase-space points for the first topology of the process.""" + rng = np.random.default_rng(seed) + topology = make_topologies(diagrams)[0] + permutations = diagrams[0]["permutations"] + mapping = ms.PhaseSpaceMapping(topology, CM_ENERGY, permutations=permutations) + r = rng.random((batch_size, mapping.random_dim())) condition = ( [] - if perm_count <= 1 - else [rng.integers(0, perm_count, BATCH_SIZE, dtype=np.int32)] + if len(permutations) <= 1 + else [rng.integers(0, len(permutations), batch_size, dtype=np.int32)] + ) + p_ext, *_ = mapping.map_forward([r], condition) + return np.asarray(p_ext) + + +@pytest.fixture(params=sorted(PROCESSES)) +def process(request): + file_name, pdg_ids = PROCESSES[request.param] + diagrams = load_diagrams(file_name) + return request.param, diagrams, pdg_ids + + +def run(clustering, momenta): + """Call a clustering and return the five outputs as numpy arrays.""" + out = clustering(momenta) + return tuple(np.asarray(v) for v in out) + + +def assert_jet_scales_agree(reference, other, max_flip_fraction=0.05): + """Compare two sets of per-jet clustering scales that should be identical. + + Which clustering wins a step is an argmin over the candidate measures, and + processes with identical final-state partons produce exactly degenerate + candidates. Recomputing the measures on a symmetry-transformed event + perturbs them at the level of floating-point noise, which can flip the + argmin and hand the scale to the other member of the degenerate pair. The + set of clustering *scales* is unchanged (so mu_R and mu_F are), only which + leg they are booked against, and it happens for a small fraction of events. + """ + matches = np.isclose(reference, other, rtol=1e-6, atol=1e-6) + flipped = np.mean(~matches.all(axis=1)) + assert flipped <= max_flip_fraction, ( + f"{flipped:.1%} of events changed their jet-scale assignment, " + f"more than the {max_flip_fraction:.0%} expected from degenerate " + "clustering candidates" + ) + + +# -------------------------------------------------------------------------- +# construction +# -------------------------------------------------------------------------- + + +def test_construction_succeeds(process): + """Every process in the test data compiles into a state machine. The + constructor throws on an unreachable or dead-ended state, so this also + covers the structure of the compiled machine.""" + _, diagrams, pdg_ids = process + assert make_clustering(diagrams) is not None + assert make_clustering(diagrams, external_pdg_ids=pdg_ids) is not None + + +def test_pdg_id_count_is_checked(process): + """A wrong number of external pdg ids is a caller error, not something to + silently truncate.""" + _, diagrams, pdg_ids = process + with pytest.raises(Exception): + make_clustering(diagrams, external_pdg_ids=pdg_ids[:-1]) + + +def test_flavor_information_selects_which_legs_are_jets(process): + """Only jets get a clustering scale in the LHE output. Without pdg ids the + clustering has to assume every leg is one; with them, the tops (legs 2 and + 3 of these processes) never are.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + *_, out_no_pdg, _ = run(make_clustering(diagrams), momenta) + *_, out_pdg, _ = run( + make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta + ) + # the tops are not jets, so with flavor information they never get a scale + assert np.all(out_pdg[:, 0] == 0.0) + assert np.all(out_pdg[:, 1] == 0.0) + # ... whereas without it they do, at least sometimes + assert np.any(out_no_pdg[:, :2] > 0.0) + # the gluons are jets either way, and usually pick up a scale; a history + # made up entirely of clusterings between the tops leaves them all at zero + assert np.mean(out_pdg[:, 2:].max(axis=1) > 0.0) > 0.5 + # a leg that gets a scale gets the same one either way when it is a jet in + # both, so the flavour information only ever removes scales, never changes + # the ones that survive + assigned = out_pdg > 0.0 + assert np.all(out_no_pdg[assigned] > 0.0) + + +# -------------------------------------------------------------------------- +# kernel outputs +# -------------------------------------------------------------------------- + + +def test_scales_are_finite_and_positive(process): + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + ren, fac1, fac2, out_scales, diagram = run( + make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta + ) + for name, value in [("ren", ren), ("fac1", fac1), ("fac2", fac2)]: + assert np.all(np.isfinite(value)), f"{name} scale not finite" + assert np.all(value > 0.0), f"{name} scale not positive" + assert np.all(np.isfinite(out_scales)) + assert np.all(out_scales >= 0.0) + + +def test_scales_are_below_the_collider_energy(process): + """No clustering scale can exceed the collider energy.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + ren, fac1, fac2, out_scales, _ = run( + make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta + ) + for value in (ren, fac1, fac2, out_scales): + assert np.all(value <= CM_ENERGY) + + +def test_factorization_scale_does_not_exceed_renormalization_scale(process): + """The kernel caps mu_F at mu_R, and uses one factorisation scale for both + beams.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + ren, fac1, fac2, _, _ = run( + make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta + ) + assert np.array_equal(fac1, fac2) + assert np.all(fac1 <= ren * (1.0 + 1e-12)) + + +def test_diagram_index_is_in_range(process): + """The selected diagram index has to be one of the indices handed to the + constructor, since it is passed straight to the matrix element.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + *_, diagram = run( + make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta + ) + total = sum(len(d["permutations"]) for d in diagrams) + assert diagram.min() >= 0 + assert diagram.max() < total + + +def test_batch_entries_are_independent(process): + """Each phase-space point is clustered on its own: running a subset of a + batch must give the same answer as running the whole batch. + + The kernel keeps per-event scratch arrays (momenta_tmp, masses_tmp, the + cluster history), so this catches state leaking between batch entries. + """ + _, diagrams, pdg_ids = process + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + momenta = sample_momenta(diagrams) + ren_full, fac_full, _, out_full, _ = run(clustering, momenta) + # the diagram index draws a random number, so only compare the scales + ren_half, fac_half, _, out_half, _ = run(clustering, momenta[: BATCH_SIZE // 2]) + n = BATCH_SIZE // 2 + assert np.array_equal(ren_full[:n], ren_half) + assert np.array_equal(fac_full[:n], fac_half) + assert np.array_equal(out_full[:n], out_half) + + +def test_longitudinal_boosts_barely_change_the_scales(process): + """The clustering measures themselves are longitudinally boost invariant - + they are built from mT, pseudorapidity differences and azimuthal angles - + but the *choice* of clustering is not. + + compute_scale multiplies an initial-state candidate by 1.000001 when the + emitted parton goes against the beam it is clustered onto, tested as + sign(pz1) != sign(pz2). That sign is not boost invariant, so a boost can + flip which candidate wins for events where two candidates are close. This is + inherited from the Fortran, which uses the same test. + + So: pin that the effect stays small rather than asserting exact invariance. + """ + _, diagrams, pdg_ids = process + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + momenta = sample_momenta(diagrams) + + rapidity = 0.3 + cosh_y, sinh_y = np.cosh(rapidity), np.sinh(rapidity) + boosted = momenta.copy() + energy, pz = momenta[..., 0], momenta[..., 3] + boosted[..., 0] = cosh_y * energy + sinh_y * pz + boosted[..., 3] = sinh_y * energy + cosh_y * pz + + ren, fac, _, out, _ = run(clustering, momenta) + ren_b, fac_b, _, out_b, _ = run(clustering, boosted) + + unchanged = np.isclose(ren_b, ren, rtol=1e-9) + assert unchanged.mean() > 0.8, ( + f"only {unchanged.mean():.0%} of events keep their renormalisation " + "scale under a longitudinal boost" + ) + # where the history is unchanged, the scales have to agree to full precision + assert ren_b[unchanged] == pytest.approx(ren[unchanged], rel=1e-9) + assert fac_b[unchanged] == pytest.approx(fac[unchanged], rel=1e-9) + assert_jet_scales_agree(out[unchanged], out_b[unchanged]) + + +def test_scales_are_azimuthally_invariant(process): + """Rotating the event around the beam axis must not change any scale.""" + _, diagrams, pdg_ids = process + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + momenta = sample_momenta(diagrams) + + angle = 0.7 + cos_a, sin_a = np.cos(angle), np.sin(angle) + rotated = momenta.copy() + px, py = momenta[..., 1], momenta[..., 2] + rotated[..., 1] = cos_a * px - sin_a * py + rotated[..., 2] = sin_a * px + cos_a * py + + ren, fac, _, out, _ = run(clustering, momenta) + ren_r, fac_r, _, out_r, _ = run(clustering, rotated) + assert ren_r == pytest.approx(ren, rel=1e-9) + assert fac_r == pytest.approx(fac, rel=1e-9) + assert_jet_scales_agree(out, out_r) + + +def test_scales_are_homogeneous_for_a_single_clustering(): + """Every clustering measure has mass dimension one, so scaling all momenta + by a factor scales every returned scale by the same factor. + + This is exact only as long as update_momenta is not involved: it keeps the + Fortran's absolute 100 GeV^2 guard against boosting into a degenerate frame + ("prevent too extreme boost" in Template/NLO/SubProcesses/cluster.f), which + is not scale free. A 2 -> 2 process needs a single clustering, whose + update_momenta result is never used again, so there the homogeneity is + exact. + """ + diagrams = [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0], + "propagators": [[0.0, 0.0]], + "vertices": [["i0", "o0", "p0"], ["p0", "o1", "i1"]], + "permutations": [[0, 1, 2, 3]], + } + ] + pdg_ids = [21, 21, 21, 21] + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + momenta = sample_momenta(diagrams, batch_size=128, seed=11) + factor = 3.0 + ren, fac, _, out, _ = run(clustering, momenta) + ren_s, fac_s, _, out_s, _ = run(clustering, momenta * factor) + # the only measure available here is djb_clus = (E - pz)(E + pz), which + # cancels badly for forward partons, so this cannot be pushed much below + # 1e-6 even though the relation is exact in real arithmetic + assert ren_s == pytest.approx(ren * factor, rel=1e-6) + assert fac_s == pytest.approx(fac * factor, rel=1e-6) + assert_jet_scales_agree(out * factor, out_s, max_flip_fraction=0.3) + + +def test_scales_mostly_scale_with_the_momenta(process): + """The same for a multi-step clustering, where the absolute boost guard in + update_momenta breaks exact homogeneity for the events that cross it. + + Pin how often that happens: the guard is a deliberate numerical safeguard, + but a large fraction here would mean the scales depend on the overall energy + of the event far more than intended. + """ + _, diagrams, _ = process + # drop the top masses so nothing else sets an absolute scale + massless = json.loads(json.dumps(diagrams)) + for d in massless: + d["outgoing_masses"] = [0.0] * len(d["outgoing_masses"]) + pdg_massless = [21] * (len(massless[0]["outgoing_masses"]) + 2) + + clustering = make_clustering(massless, external_pdg_ids=pdg_massless) + momenta = sample_momenta(massless) + factor = 2.0 + ren, _, _, _, _ = run(clustering, momenta) + ren_s, _, _, _, _ = run(clustering, momenta * factor) + + ratio = ren_s / (ren * factor) + exact = np.isclose(ratio, 1.0, rtol=1e-9) + assert np.median(ratio) == pytest.approx(1.0, rel=1e-9) + assert exact.mean() > 0.5, ( + f"only {exact.mean():.0%} of events scale exactly; the absolute boost " + "guard in update_momenta should affect far fewer" + ) + + +def test_renormalization_scale_is_a_geometric_mean(process): + """mu_R is the geometric mean of the clustering scales, so it lies between + the smallest and the largest of them. mu_F is the smallest QCD clustering + scale (capped at mu_R), which bounds mu_R from below.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + ren, fac, _, out, _ = run( + make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta + ) + assert np.all(ren >= fac * (1.0 - 1e-12)) + # mu_F is the smallest QCD clustering scale, so every jet scale - each of + # which is a QCD clustering scale - is at least as large + nonzero = out > 0.0 + smallest_jet_scale = np.where(nonzero, out, np.inf).min(axis=1) + assert np.all(smallest_jet_scale >= fac * (1.0 - 1e-9)) + + +# -------------------------------------------------------------------------- +# resonances +# -------------------------------------------------------------------------- + + +M_B = 4.7 + + +def z_to_bb_diagram(width): + """u u~ > Z(> b b~) g g with the Z either given its physical width, so it + can be recognised as a resonance, or a zero width, so it cannot. + + The b quarks are massive on purpose. compute_scale returns the invariant + mass of the pair both when the clustering is resonant and when the parent is + massive and both children are massless (itype 8 in the Fortran), so a + resonance decaying to massless particles cannot tell the two branches apart. + With massive children the non-resonant case falls through to the kt measure + instead, and the resonance branch becomes observable. + + Legs: (u, u~, b, b~, g, g); the Z propagator recombines legs 2 and 3. + """ + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [M_B, M_B, 0.0, 0.0], + # p0 = Z (b b~), p1/p2 = t-channel quark lines + "propagators": [[M_Z, width], [0.0, 0.0], [0.0, 0.0]], + "vertices": [ + ["o0", "o1", "p0"], + ["i0", "o2", "p1"], + ["p1", "o3", "p2"], + ["p2", "p0", "i1"], + ], + "permutations": [[0, 1, 2, 3, 4, 5]], + } + ] + + +Z_TO_BB_PDGS = [2, -2, 5, -5, 21, 21] + + +def test_resonant_propagators_get_a_breit_wigner_index(): + """Only a propagator with a width can be resonant, and only in a final-state + clustering: a t-channel line is spacelike and never goes on shell.""" + with_width = make_clustering( + z_to_bb_diagram(W_Z), external_pdg_ids=Z_TO_BB_PDGS + ) + without = make_clustering(z_to_bb_diagram(0.0), external_pdg_ids=Z_TO_BB_PDGS) + + assert list(with_width.bw_masses) == [pytest.approx(M_Z)] + assert list(with_width.bw_widths) == [pytest.approx(W_Z)] + assert list(without.bw_masses) == [] + + # exactly the b b~ clustering (legs 2 and 3) carries the index + resonant_pairs = set() + non_terminal, _ = walk(np.asarray(with_width.cluster_state_machine), 6) + for _, (_, transitions) in non_terminal.items(): + for data, _ in transitions: + if field(data, BIT_MASS_INDEX) != 0: + resonant_pairs.add( + (field(data, BIT_PARTICLE1), field(data, BIT_PARTICLE2)) + ) + assert resonant_pairs == {(2, 3)} + + +def test_resonant_clustering_uses_the_invariant_mass(): + """A clustering onto a propagator inside its Breit-Wigner window is scored + with the invariant mass of the pair rather than the kt measure. Compare + against the same topology with the width set to zero, which switches the + resonance branch off entirely.""" + momenta = sample_momenta(z_to_bb_diagram(W_Z)) + resonant = make_clustering( + z_to_bb_diagram(W_Z), external_pdg_ids=Z_TO_BB_PDGS, bw_cutoff=15.0 + ) + non_resonant = make_clustering( + z_to_bb_diagram(0.0), external_pdg_ids=Z_TO_BB_PDGS + ) + + ren_res, fac_res, _, _, _ = run(resonant, momenta) + ren_non, _, _, _, _ = run(non_resonant, momenta) + + # the resonance branch has to actually change the answer + assert not np.allclose(ren_res, ren_non) + assert np.all(np.isfinite(ren_res)) and np.all(ren_res > 0.0) + assert np.all(fac_res <= ren_res * (1.0 + 1e-12)) + + +def test_breit_wigner_cutoff_widens_the_resonance_window(): + """bw_cutoff scales the half width of the window in which a propagator counts + as resonant, exactly as it does for the phase-space mapping. A cutoff of zero + closes the window, which has to reproduce the zero-width result.""" + momenta = sample_momenta(z_to_bb_diagram(W_Z)) + + closed = make_clustering( + z_to_bb_diagram(W_Z), external_pdg_ids=Z_TO_BB_PDGS, bw_cutoff=0.0 + ) + zero_width = make_clustering( + z_to_bb_diagram(0.0), external_pdg_ids=Z_TO_BB_PDGS ) - p_ext, _, _, _ = mapping.map_forward([r], condition) - ren_scale, fact_scale1, fact_scale2, outgoing_scales, diagram_index = clustering( - p_ext + wide = make_clustering( + z_to_bb_diagram(W_Z), external_pdg_ids=Z_TO_BB_PDGS, bw_cutoff=15.0 ) - from icecream import ic - print(ren_scale, fact_scale1, fact_scale2, outgoing_scales, diagram_index) + ren_closed, *_ = run(closed, momenta) + ren_zero, *_ = run(zero_width, momenta) + ren_wide, *_ = run(wide, momenta) + + assert ren_closed == pytest.approx(ren_zero, rel=1e-12) + assert not np.allclose(ren_wide, ren_closed) + + +# -------------------------------------------------------------------------- +# clustering measures, against the Fortran they were ported from +# -------------------------------------------------------------------------- + + +def djb_clus_reference(p, hadronic=True): + """djb_clus from Template/NLO/SubProcesses/cluster.f.""" + r = (p[0] - p[3]) * (p[0] + p[3]) if hadronic else p[0] * p[0] + return max(r, 0.0) + + +def dj_clus_reference(p1, p2, mass1, mass2, jet_radius): + """The both-massless / both-massive branch of dj_clus.""" + pt1_sq = p1[1] ** 2 + p1[2] ** 2 + pt2_sq = p2[1] ** 2 + p2[2] ** 2 + if pt1_sq == 0.0 or pt2_sq == 0.0: + return 0.0 + p1a = np.sqrt(pt1_sq + p1[3] ** 2) + p2a = np.sqrt(pt2_sq + p2[3] ** 2) + eta1 = 0.5 * np.log((p1a + p1[3]) / (p1a - p1[3])) + eta2 = 0.5 * np.log((p2a + p2[3]) / (p2a - p2[3])) + m_max_sq = max(mass1**2, mass2**2) + dphi_cos = (p1[1] * p2[1] + p1[2] * p2[2]) / np.sqrt(pt1_sq * pt2_sq) + return max( + m_max_sq + + min(pt1_sq, pt2_sq) + * 2.0 + * (np.cosh(eta1 - eta2) - dphi_cos) + / jet_radius**2, + 0.0, + ) + + +def first_step_candidates(clustering, n_ext, event, jet_radius, masses): + """The clustering measures the kernel evaluates in its first step. + + Which pairs are candidates at all is decided by the compiled state machine - + only clusterings that some diagram topology allows are offered - so read + them off it rather than enumerating every pair. + """ + flat = np.asarray(clustering.cluster_state_machine) + candidates = {} + for data, _ in decode_transitions(flat, 0): + p1 = field(data, BIT_PARTICLE1) + p2 = field(data, BIT_PARTICLE2) + if p1 < 2: + # initial-state clustering: mT of the final-state parton, with a + # 1.000001 penalty when it goes against the beam + measure = np.sqrt(djb_clus_reference(event[p2])) + if (event[p1][3] < 0.0) != (event[p2][3] < 0.0): + measure *= 1.000001 + else: + measure = np.sqrt( + dj_clus_reference( + event[p1], event[p2], masses[p1], masses[p2], jet_radius + ) + ) + candidates[(p1, p2)] = measure + return candidates + + +def test_single_clustering_scale_matches_the_measures(): + """For a 2 -> 2 process there is exactly one clustering step, so the scale + the kernel returns is fully predicted by the measures above. + + This is the tightest available check that the kernel walks the state machine + and applies the same measure the Fortran does. + """ + diagrams = [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0], + "propagators": [[0.0, 0.0]], + "vertices": [["i0", "o0", "p0"], ["p0", "o1", "i1"]], + "permutations": [[0, 1, 2, 3]], + } + ] + pdg_ids = [21, 21, 21, 21] + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + momenta = sample_momenta(diagrams, batch_size=128, seed=99) + ren, fac, _, _, _ = run(clustering, momenta) + + # one clustering step: mu_R is that step's scale, and mu_F (the smallest QCD + # clustering scale, capped at mu_R) has to equal it + assert fac == pytest.approx(ren, rel=1e-12) + + masses = [0.0] * 4 + for event, scale in zip(momenta, ren): + candidates = first_step_candidates(clustering, 4, event, 0.4, masses) + assert candidates, "no clustering offered from the initial state" + assert scale == pytest.approx(min(candidates.values()), rel=1e-9) + + +def test_kt_measure_matches_the_fortran(): + """The first clustering step of g g > g g g, where the (o0, o1) final-state + pair competes with the initial-state candidates and is scored by the kt + measure. Checked against a transcription of dj_clus / djb_clus from + Template/NLO/SubProcesses/cluster.f. + """ + jet_radius = 0.4 + diagrams = [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0, 0.0], + "propagators": [[0.0, 0.0], [0.0, 0.0]], + "vertices": [ + ["o0", "o1", "p0"], + ["i0", "o2", "p1"], + ["p1", "p0", "i1"], + ], + "permutations": [[0, 1, 2, 3, 4]], + } + ] + pdg_ids = [21, 21, 21, 21, 21] + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_radius=jet_radius + ) + momenta = sample_momenta(diagrams, batch_size=128, seed=99) + _, fac, _, _, _ = run(clustering, momenta) + + # the state machine has to offer the final-state pair, otherwise this test + # would never exercise dj_clus at all + flat = np.asarray(clustering.cluster_state_machine) + offered = { + (field(d, BIT_PARTICLE1), field(d, BIT_PARTICLE2)) + for d, _ in decode_transitions(flat, 0) + } + assert (2, 3) in offered + + masses = [0.0] * 5 + for event, scale in zip(momenta, fac): + candidates = first_step_candidates( + clustering, 5, event, jet_radius, masses + ) + # two clustering steps here (n_ext - 3), and mu_F is the smallest QCD + # clustering scale of the whole history, so it is at most the smallest + # measure available in the first step + assert scale <= min(candidates.values()) * (1.0 + 1e-9) + + +# -------------------------------------------------------------------------- +# structure of the compiled state machine +# -------------------------------------------------------------------------- +# +# The kernel walks a flat integer array. A non-terminal state is a run of +# (data, next_offset) pairs ending at the pair whose data has bit 30 set; a +# terminal state is a count followed by that many diagram indices. Which of the +# two a given offset is depends only on how many clusterings have been done, so +# the walker below tracks the depth the same way the kernel does. + +BIT_PARTICLE1 = (0, 0xFF) +BIT_PARTICLE2 = (8, 0xFF) +BIT_MASS_INDEX = (16, 0xFF) +BIT_MASSIVE_IN = (24, 1) +BIT_MASSIVE_OUT1 = (25, 1) +BIT_MASSIVE_OUT2 = (26, 1) +BIT_IS_QCD = (27, 1) +BIT_IS_JET1 = (28, 1) +BIT_IS_JET2 = (29, 1) +BIT_IS_LAST = (30, 1) + + +def field(data, spec): + shift, mask = spec + return (data >> shift) & mask + + +def decode_transitions(machine, offset): + """The (data, next_offset) pairs of the non-terminal state at `offset`.""" + transitions = [] + while True: + assert offset + 1 < len(machine), "transition list runs past the machine" + data, next_offset = machine[offset], machine[offset + 1] + transitions.append((data, next_offset)) + if field(data, BIT_IS_LAST): + return transitions + offset += 2 + + +def walk(machine, n_ext): + """Visit every reachable state, returning + {offset: (depth, transitions)} for non-terminal states and + {offset: diagram indices} for terminal ones.""" + cluster_max = n_ext - 3 + non_terminal, terminal = {}, {} + todo = [(0, 0)] + seen = set() + while todo: + offset, depth = todo.pop() + if (offset, depth) in seen: + continue + seen.add((offset, depth)) + if depth == cluster_max: + count = machine[offset] + assert count > 0, f"terminal state at {offset} selects no diagram" + assert offset + count < len(machine) + terminal[offset] = list(machine[offset + 1 : offset + 1 + count]) + continue + transitions = decode_transitions(machine, offset) + non_terminal[offset] = (depth, transitions) + for _, next_offset in transitions: + todo.append((next_offset, depth + 1)) + return non_terminal, terminal + + +@pytest.fixture +def machine(process): + _, diagrams, pdg_ids = process + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + n_ext = len(pdg_ids) + return clustering, np.asarray(clustering.cluster_state_machine), n_ext + + +def test_state_machine_is_fully_reachable(machine): + """Every state the walk can reach is well formed, and a state is never + reached at two different depths (which would make the terminal/non-terminal + reading of an offset ambiguous).""" + _, flat, n_ext = machine + non_terminal, terminal = walk(flat, n_ext) + assert non_terminal, "no clustering states compiled" + assert terminal, "no terminal states compiled" + depths = {} + for offset, (depth, _) in non_terminal.items(): + assert depths.setdefault(offset, depth) == depth + for offset in terminal: + assert offset not in non_terminal + + +def test_state_machine_transitions_are_in_range(machine): + """Every transition names two distinct external particles in range and + points at an offset inside the machine.""" + _, flat, n_ext = machine + non_terminal, _ = walk(flat, n_ext) + for offset, (_, transitions) in non_terminal.items(): + for data, next_offset in transitions: + particle1 = field(data, BIT_PARTICLE1) + particle2 = field(data, BIT_PARTICLE2) + # the merged pseudo-particle is always named by the lower index, so + # the kernel's is_initial test (particle1 < 2) is meaningful + assert particle1 < particle2 < n_ext + assert 0 <= next_offset < len(flat) + + +def test_state_machine_has_no_duplicate_transitions(machine): + """A state reached along several paths must be expanded only once. + Expanding it again appends a second copy of the same transitions, which + grows the table by a factor that blows up with the multiplicity.""" + _, flat, n_ext = machine + non_terminal, _ = walk(flat, n_ext) + for offset, (_, transitions) in non_terminal.items(): + keys = [ + (field(d, BIT_PARTICLE1), field(d, BIT_PARTICLE2), nxt) + for d, nxt in transitions + ] + assert len(keys) == len(set(keys)), f"duplicate transitions at state {offset}" + + +def test_state_machine_marks_exactly_one_last_transition(machine): + """The kernel decides a state is finished when it sees the is_last bit, so + exactly one transition per state carries it, and it is the final one.""" + _, flat, n_ext = machine + non_terminal, _ = walk(flat, n_ext) + for offset, (_, transitions) in non_terminal.items(): + flags = [field(d, BIT_IS_LAST) for d, _ in transitions] + assert flags[-1] == 1 + assert sum(flags) == 1 + + +def test_state_machine_mass_indices_are_in_range(machine): + """mass_index is a 1-based index into the Breit-Wigner tables, or 0 for + 'never resonant'. An out-of-range value would read past those tables.""" + clustering, flat, n_ext = machine + bw_count = len(clustering.bw_masses) + assert len(clustering.bw_widths) == bw_count + non_terminal, _ = walk(flat, n_ext) + for _, (_, transitions) in non_terminal.items(): + for data, _ in transitions: + assert field(data, BIT_MASS_INDEX) <= bw_count + + +def test_initial_state_clusterings_are_never_resonant(machine): + """A t-channel propagator is spacelike, so it can never be on shell.""" + _, flat, n_ext = machine + non_terminal, _ = walk(flat, n_ext) + for _, (_, transitions) in non_terminal.items(): + for data, _ in transitions: + if field(data, BIT_PARTICLE1) < 2: + assert field(data, BIT_MASS_INDEX) == 0 + # a beam leg is not a final-state particle and gets no jet scale + assert field(data, BIT_IS_JET1) == 0 + + +def test_terminal_states_select_known_diagrams(machine): + _, flat, n_ext = machine + _, terminal = walk(flat, n_ext) + for offset, diagrams in terminal.items(): + assert len(diagrams) == len(set(diagrams)) + assert all(index >= 0 for index in diagrams) + + +def test_external_masses_are_in_leg_order(process): + """The kernel indexes external_masses by external leg while a topology's + masses are indexed by topology slot, so the permutation has to be applied. + For these processes legs 2 and 3 are the tops.""" + _, diagrams, pdg_ids = process + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + masses = list(clustering.external_masses) + assert len(masses) == len(pdg_ids) + assert masses[0] == 0.0 and masses[1] == 0.0 + assert masses[2] == pytest.approx(M_TOP) + assert masses[3] == pytest.approx(M_TOP) + assert all(m == 0.0 for m in masses[4:]) + + +# -------------------------------------------------------------------------- +# robustness +# -------------------------------------------------------------------------- + + +def test_extreme_kinematics_do_not_break_the_walk(process): + """The boost and rotation applied after an initial-state clustering can + leave a pair exactly collinear, making the clustering measure inf or NaN. + The kernel has to keep choosing a clustering anyway: if nothing wins a step + it would jump to a stale or unset state offset and read outside the state + machine. + + Strongly asymmetric beam energies reach that configuration, so this is a + regression test for the crash it used to cause. + """ + _, diagrams, pdg_ids = process + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + momenta = sample_momenta(diagrams, batch_size=2000, seed=1234) + ren, fac1, fac2, out, diagram = run(clustering, momenta) + assert np.all(np.isfinite(ren)) + assert np.all(np.isfinite(fac1)) and np.all(np.isfinite(fac2)) + assert np.all(np.isfinite(out)) + total = sum(len(d["permutations"]) for d in diagrams) + assert np.all((diagram >= 0) & (diagram < total)) + + +def test_exactly_collinear_momenta_are_handled(process): + """The same thing, but forced: put two final-state partons exactly along the + beam axis so the pseudorapidity of the pair is genuinely undefined.""" + _, diagrams, pdg_ids = process + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + momenta = sample_momenta(diagrams, batch_size=32, seed=7) + degenerate = momenta.copy() + # zero the transverse components of the last two legs + degenerate[:, -2:, 1] = 0.0 + degenerate[:, -2:, 2] = 0.0 + ren, fac1, _, out, diagram = run(clustering, degenerate) + assert np.all(np.isfinite(ren)) + assert np.all(np.isfinite(fac1)) + assert np.all(np.isfinite(out)) From 1159b6c50b1dca2dda3f1f4bd30c6c5483528a4d Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sat, 5 Sep 2026 10:47:32 +0200 Subject: [PATCH 14/81] Document the comparison against the madevent MLM p p > e+ ve j at 13 TeV with matched cuts, PDF set and beam energy, 5000 events on each side. The hardest-parton pT distributions agree, so what is left is the scale definitions. Where both codes assign a jet a real clustering scale they agree exactly: pt_clust / pT_jet is 1.000 at every quantile. mu_R differs by a factor 0.61, which follows from the definitions in closed form and is reproduced event by event from the LHE momenta for the q qbar subprocesses. The finding worth acting on is that mg7 leaves 27.2% of jets with pt_clust = 0, where madevent leaves none. It comes from the s-channel g q -> q* -> (W)(q) topology, whose only clusterings are the W formation and (W, jet) - the latter has a colourless parent, so it is not a QCD vertex and books no jet scale. madevent avoids this with an explicit fallback to sqrt(s), which it also applies to non-jet legs, where mg7 writes 0. Since pt_clust is what an MLM veto compares against qcut, a jet reported at zero is indistinguishable from a lepton. Co-Authored-By: Claude Opus 5 --- docs/mlm-merging-status.md | 88 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/docs/mlm-merging-status.md b/docs/mlm-merging-status.md index 9f7a9e0ba4..67d44e5ac1 100644 --- a/docs/mlm-merging-status.md +++ b/docs/mlm-merging-status.md @@ -164,3 +164,91 @@ comparison against the validated madevent MLM disagree. external legs (a photon and a Z, say). The compiler keeps whichever has a width, since that is the one the resonance test is about, but this is an arbitrary choice where the diagrams genuinely disagree. + +## Comparison against the madevent MLM + +Process `p p > e+ ve j` at 13 TeV, 5000 unweighted events each, with the cuts, +the PDF set (`NNPDF23_lo_as_0130_qed`, lhaid 247000) and the collider energy +matched between the two runs. + +* mg7: `output mg7`, then `dynamical_scale_choice = "mlm"` in + `Cards/run_card.toml`. +* madevent: `output madevent`, then `dynamical_scale_choice = -1` (the CKKW + back-clustering scale) in `Cards/run_card.dat`. A second madevent run adds + `ickkw = 1` and `xqcut = 20` — neither is written into the run card for a + fixed-multiplicity process, so they have to be appended by hand — because + `addmothers.f` only writes the `` block when `ickkw > 0`. + +The hardest-parton pT distributions of the two runs agree closely, so the +differences below are the scale definitions rather than a different population +of phase space. + +### Renormalisation scale + +| | mean | q05 | q50 | q95 | +|--------------------|-----:|----:|----:|----:| +| mg7 MLM | 62.1 | 40.9 | 54.1 | 92.7 | +| madevent CKKW | 101.7 | 81.1 | 87.5 | 172.8 | +| mg7 / pT of the hardest parton | 1.58 | 0.94 | 1.62 | 2.13 | +| madevent / pT of the hardest parton | 2.89 | 1.42 | 2.87 | 4.15 | + +mg7's mu_R is about 0.61 of madevent's. This follows from the definitions and +is reproducible in closed form. For this process there are two clustering +steps: the resonant `(e+, nu) -> W`, and the jet clustered onto a beam. The +first is not a QCD splitting, so mg7 replaces its scale by the largest scale in +the history and takes the geometric mean, giving +`mu_R = sqrt(max(m_W, mT_j) * mT_j)`. Recomputing that directly from the LHE +momenta reproduces mg7's `scale` field exactly for 72.8% of events (all of the +`q qbar` ones), the rest being the case discussed below. madevent instead takes +the geometric mean of the four `jfirst`/`jlast`/`jcentral` scales, of which +three are the W here, so its mu_R sits close to m_W. + +Cross sections, same cuts and PDF, no merging on either side: mg7 +1557.5 +- 7.2 pb against madevent 1616 +- 8.4 pb, a 3.7% difference driven by +the scale. + +### Per-jet clustering scales + +When both codes assign a real clustering scale to the jet they agree exactly: +`pt_clust / pT_jet` is 1.000 at every quantile in both. Two differences remain. + +**mg7 leaves 27.2% of jets with `pt_clust = 0`; madevent leaves none.** The +cause is a topology whose clustering history contains no QCD splitting at all. +For the `g q > e+ ve q` subprocess one of the two channels is the s-channel +`g q -> q* -> (W)(q)`, whose only available clusterings are `(e+, nu) -> W` and +`(W, jet) -> q`. The second has a colourless parent, so it is not a QCD vertex, +and the branch that books a clustering scale onto a jet only runs for QCD +clusterings. In about 36% of `g q` events that history has the smaller measure +and wins, and the jet comes out with no scale. That also explains the remaining +27% of the mu_R comparison above: both clusterings are non-QCD there, so every +scale is replaced by the largest one and mu_R comes out as m_W exactly. + +This matters for merging, not just for bookkeeping: `pt_clust` is what the +matching veto compares against `qcut`, and a jet reported at zero is +indistinguishable from a lepton. + +**Non-jet legs are written as 0 by mg7 and as sqrt(s) = 13000 by madevent.** +madevent has an explicit fallback (`ptclus(...) = etot` in +`Template/LO/SubProcesses/reweight.f`) that sets any leg without a jet vertex to +the collider energy, so a veto of the form "reject if any pt_clust < qcut" can +never trip on it. About 5% of madevent's *jets* also hit that fallback. mg7 +writes 0 in the same situations, which would make such a veto reject every +event. + +### What to do about it + +Both differences point the same way: something has to guarantee that every jet +ends up with a usable clustering scale. Options, roughly in increasing order of +work: + +1. Adopt madevent's fallback and write sqrt(s) rather than 0 for any leg that + no QCD clustering booked a scale onto. Cheap, and makes the LHE output + directly usable by an MLM veto. +2. Restrict the clustering to histories that do cluster every jet, by rejecting + candidate transitions that would leave a jet unclustered — a change to the + state-machine compiler rather than the kernel. +3. Trace the parton lines through the event the way `ipartupdate` does, which + is also what would be needed for per-beam factorisation scales. + +Option 1 is a band-aid over option 2 or 3, but it is what madevent does today, +so matching it is at least a defensible starting point. From e56c6e0ac1c409e07f3d91351c311f7ee671618e Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sat, 5 Sep 2026 11:20:55 +0200 Subject: [PATCH 15/81] Report unclustered outgoing legs at sqrt(s) instead of zero pt_clust is what an MLM veto compares against qcut, so a leg that no QCD clustering assigned a scale to has to come out at a value the veto can never trip on. madevent does this with the "ptclus = etot" fallback in Template/LO/SubProcesses/reweight.f; the kernel wrote 0, which a veto of the form "reject if any pt_clust < qcut" would reject on every event. The fallback value is the collider energy, which the kernel did not have: it is not derivable from the momenta, since those give the partonic energy. So MLMClustering takes it as a constructor argument (cm_energy, filled by launch.py from process.e_cm) and it is threaded through as a new input of the two mlm_clustering instructions. Rerunning the p p > e+ ve j comparison, jets reported at zero go from 27.2% to 0%, and the non-jet legs now read 13000 exactly, the same as madevent. Where both codes assign a real scale they still agree exactly (pt_clust / pT_jet is 1.000 at every quantile), and the assigned-scale medians are 29.50 against 30.67. This is the convention fix, not the underlying one: mg7 still reaches the fallback for 26.4% of jets against madevent's 10.1%, because the s-channel g q -> q* -> (W)(q) history contains no QCD splitting at all. docs/mlm-merging-status.md records what would actually close that gap. Co-Authored-By: Claude Opus 5 --- docs/mlm-merging-status.md | 60 ++++++++++---- madgraph/iolibs/template_files/mg7/launch.py | 1 + .../madspace/phasespace/mlm_clustering.hpp | 5 ++ madspace/instruction_set.yaml | 6 ++ madspace/src/kernels/mlm.hpp | 19 +++++ madspace/src/phasespace/mlm_clustering.cpp | 8 +- madspace/src/python/madspace.cpp | 2 + madspace/tests/test_mlm.py | 79 ++++++++++++++----- 8 files changed, 143 insertions(+), 37 deletions(-) diff --git a/docs/mlm-merging-status.md b/docs/mlm-merging-status.md index 67d44e5ac1..425762f09a 100644 --- a/docs/mlm-merging-status.md +++ b/docs/mlm-merging-status.md @@ -150,6 +150,11 @@ comparison against the validated madevent MLM disagree. another scale choice, and the clustering scale cannot be used without merging. A separate `[matching]`-style switch is the likely shape. +* Whether the sqrt(s) fallback should stay a fallback. It is what madevent does + and it makes the output veto-safe, but a jet at sqrt(s) carries no + information; if merging is to work well, the fraction of jets landing there + needs to come down. + * Pass the selected diagram index through to the matrix element. The plumbing is in place (`MatrixElement::diagram_in` replaces `random_diagram_in` when MLM is active) but the madmatrix side needs per-event diagram input support. Affects @@ -227,28 +232,49 @@ This matters for merging, not just for bookkeeping: `pt_clust` is what the matching veto compares against `qcut`, and a jet reported at zero is indistinguishable from a lepton. -**Non-jet legs are written as 0 by mg7 and as sqrt(s) = 13000 by madevent.** +**Non-jet legs were written as 0 by mg7 and as sqrt(s) = 13000 by madevent.** madevent has an explicit fallback (`ptclus(...) = etot` in `Template/LO/SubProcesses/reweight.f`) that sets any leg without a jet vertex to the collider energy, so a veto of the form "reject if any pt_clust < qcut" can -never trip on it. About 5% of madevent's *jets* also hit that fallback. mg7 -writes 0 in the same situations, which would make such a veto reject every -event. +never trip on it. + +### The sqrt(s) fallback, now implemented + +The kernel now does the same: any outgoing leg that no QCD clustering assigned a +scale to is reported at the collider energy rather than at zero. This needed the +collider energy in the kernel, which it did not have, so `MLMClustering` takes it +as a constructor argument (`cm_energy`, filled by `launch.py` from +`process.e_cm`) and it is threaded through as a new instruction input. + +Rerunning the same comparison afterwards: -### What to do about it +| | mg7 | madevent | +|---------------------------------------|------:|---------:| +| jets reported at zero | 0.0% | 0.0% | +| jets at the sqrt(s) fallback | 26.4% | 10.1% | +| `pt_clust / pT_jet`, where assigned | 1.000 | 1.000 | +| median assigned jet scale | 29.50 | 30.67 | +| non-jet legs | 13000 | 13000 | -Both differences point the same way: something has to guarantee that every jet -ends up with a usable clustering scale. Options, roughly in increasing order of -work: +The convention now matches exactly, and where both assign a real scale they +still agree exactly. madevent reaches the fallback for 10.1% of its own jets, so +the mechanism is shared rather than an mg7 artefact — but mg7 reaches it 2.6 +times as often. -1. Adopt madevent's fallback and write sqrt(s) rather than 0 for any leg that - no QCD clustering booked a scale onto. Cheap, and makes the LHE output - directly usable by an MLM veto. -2. Restrict the clustering to histories that do cluster every jet, by rejecting +### What is still open on this + +The fallback makes the LHE output usable by a matching veto; it does not remove +the reason mg7 lands there so much more often, which is the non-QCD clustering +history described above. Two further options, in increasing order of work: + +1. Restrict the clustering to histories that do cluster every jet, by rejecting candidate transitions that would leave a jet unclustered — a change to the state-machine compiler rather than the kernel. -3. Trace the parton lines through the event the way `ipartupdate` does, which - is also what would be needed for per-beam factorisation scales. - -Option 1 is a band-aid over option 2 or 3, but it is what madevent does today, -so matching it is at least a defensible starting point. +2. Trace the parton lines through the event the way `ipartupdate` does, which is + also what would be needed for per-beam factorisation scales. + +One more difference in the same area, not addressed: madevent takes the +**maximum** clustering scale over every jet vertex a leg takes part in +(`ptclus(leg) = max(ptclus(leg), ...)`), while the kernel takes the scale of the +**first** clustering the leg takes part in and freezes it there (the +`is_last_cluster` mask). diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index 2bedb0d23f..fc48279808 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -1358,6 +1358,7 @@ def __init__( topologies=[topo[0] for topo in mc_data.topologies], permutations=mc_data.permutations, diagram_indices=mc_data.diagram_indices, + cm_energy=self.process.e_cm, bw_cutoff=self.process.run_card["phasespace"]["bw_cutoff"], jet_radius=self.process.run_card["beam"]["jet_radius"], hadronic=not self.process.leptonic, diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp index e63bc60fdb..dc8929ec93 100644 --- a/madspace/include/madspace/phasespace/mlm_clustering.hpp +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -11,6 +11,10 @@ class MLMClustering : public FunctionGenerator { std::vector topologies, nested_vector3 permutations, nested_vector2 diagram_indices, + // Collider energy. An outgoing leg that no QCD clustering assigned a + // scale to is reported at this value rather than at zero, so that an + // MLM veto can never trip on it. + double cm_energy, double bw_cutoff = 15, double jet_radius = 0.4, bool hadronic = true, @@ -38,6 +42,7 @@ class MLMClustering : public FunctionGenerator { std::vector _external_masses; std::vector _bw_masses; std::vector _bw_widths; + double _cm_energy; double _bw_cutoff; double _jet_radius; bool _hadronic; diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index f547bae9a9..d8a37f3afc 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -2217,6 +2217,9 @@ mlm_clustering_hadronic: - name: jet_radius type: [float, single] desc: + - name: cm_energy + type: [float, single] + desc: outputs: - name: ren_scale type: [float] @@ -2261,6 +2264,9 @@ mlm_clustering_leptonic: - name: jet_radius type: [float, single] desc: + - name: cm_energy + type: [float, single] + desc: outputs: - name: ren_scale type: [float] diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 6ecd33c1fd..3289342970 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -196,6 +196,7 @@ KERNELSPEC void mlm_clustering( FIn bw_widths, FIn bw_cutoff, FIn jet_radius, + FIn cm_energy, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -354,6 +355,20 @@ KERNELSPEC void mlm_clustering( } is_last_cluster &= ~((1 << particle1) | (1 << particle2)); } + + // Any outgoing leg that no QCD clustering booked a scale onto keeps + // sqrt(s) rather than zero, mirroring the "ptclus = etot" fallback in + // Template/LO/SubProcesses/reweight.f. This covers the legs that are not + // jets at all, and jets whose winning clustering history happened to + // contain no QCD splitting. pt_clust is what an MLM veto compares against + // qcut, so a leg reported at zero would trip a veto that a leg with no + // clustering scale must never trip. + for (int i = 0; i < n_part - 2; ++i) { + if (outgoing_scales[i] == 0.0) { + outgoing_scales[i] = cm_energy; + } + } + ren_scale_val = pow(ren_scale_val, 1.0 / cluster_max); if (fac_scale > ren_scale_val) { fac_scale = ren_scale_val; @@ -381,6 +396,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( FIn bw_widths, FIn bw_cutoff, FIn jet_radius, + FIn cm_energy, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -396,6 +412,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( bw_widths, bw_cutoff, jet_radius, + cm_energy, ren_scale, fact_scale1, fact_scale2, @@ -415,6 +432,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( FIn bw_widths, FIn bw_cutoff, FIn jet_radius, + FIn cm_energy, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -430,6 +448,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( bw_widths, bw_cutoff, jet_radius, + cm_energy, ren_scale, fact_scale1, fact_scale2, diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index 4e6d3a1c88..9c06ba4daa 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -239,6 +239,7 @@ MLMClustering::MLMClustering( std::vector topologies, nested_vector3 permutations, nested_vector2 diagram_indices, + double cm_energy, double bw_cutoff, double jet_radius, bool hadronic, @@ -256,6 +257,7 @@ MLMClustering::MLMClustering( batch_float_array(topologies.at(0).outgoing_masses().size())}, {"diagram_index", batch_int}} ), + _cm_energy(cm_energy), _bw_cutoff(bw_cutoff), _jet_radius(jet_radius), _hadronic(hadronic) { @@ -473,7 +475,8 @@ NamedVector MLMClustering::build_function_impl( _bw_masses, _bw_widths, _bw_cutoff, - _jet_radius + _jet_radius, + _cm_energy ); } else { mlm_out = fb.mlm_clustering_leptonic( @@ -484,7 +487,8 @@ NamedVector MLMClustering::build_function_impl( _bw_masses, _bw_widths, _bw_cutoff, - _jet_radius + _jet_radius, + _cm_energy ); } return {return_types().keys(), {mlm_out.begin(), mlm_out.end()}}; diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index 057efc6a06..2b778073f0 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -983,12 +983,14 @@ PYBIND11_MODULE(_madspace_py, m) { nested_vector2, double, double, + double, bool, std::vector, int>(), py::arg("topologies"), py::arg("permutations"), py::arg("diagram_indices"), + py::arg("cm_energy"), py::arg("bw_cutoff") = 15., py::arg("jet_radius") = 0.4, py::arg("hadronic") = true, diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 7b5fb82332..28dcfebbdf 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -80,6 +80,7 @@ def make_diagram_indices(diagrams): def make_clustering(diagrams, **kwargs): + kwargs.setdefault("cm_energy", CM_ENERGY) return ms.MLMClustering( make_topologies(diagrams), [d["permutations"] for d in diagrams], @@ -160,28 +161,63 @@ def test_pdg_id_count_is_checked(process): def test_flavor_information_selects_which_legs_are_jets(process): - """Only jets get a clustering scale in the LHE output. Without pdg ids the - clustering has to assume every leg is one; with them, the tops (legs 2 and - 3 of these processes) never are.""" + """Only jets get a real clustering scale. Without pdg ids the clustering has + to assume every leg is one; with them, the tops (legs 2 and 3 of these + processes) never are and always fall back to sqrt(s).""" _, diagrams, pdg_ids = process momenta = sample_momenta(diagrams) *_, out_no_pdg, _ = run(make_clustering(diagrams), momenta) *_, out_pdg, _ = run( make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta ) - # the tops are not jets, so with flavor information they never get a scale - assert np.all(out_pdg[:, 0] == 0.0) - assert np.all(out_pdg[:, 1] == 0.0) + # the tops are not jets, so with flavor information they never get one + assert np.all(out_pdg[:, 0] == CM_ENERGY) + assert np.all(out_pdg[:, 1] == CM_ENERGY) # ... whereas without it they do, at least sometimes - assert np.any(out_no_pdg[:, :2] > 0.0) - # the gluons are jets either way, and usually pick up a scale; a history - # made up entirely of clusterings between the tops leaves them all at zero - assert np.mean(out_pdg[:, 2:].max(axis=1) > 0.0) > 0.5 - # a leg that gets a scale gets the same one either way when it is a jet in - # both, so the flavour information only ever removes scales, never changes - # the ones that survive - assigned = out_pdg > 0.0 - assert np.all(out_no_pdg[assigned] > 0.0) + assert np.any(out_no_pdg[:, :2] < CM_ENERGY) + # the gluons are jets either way and usually pick up a real scale + assert np.mean(out_pdg[:, 2:].min(axis=1) < CM_ENERGY) > 0.5 + + +def test_unclustered_legs_fall_back_to_the_collider_energy(process): + """Every outgoing leg is reported at a scale, never at zero. + + pt_clust is what an MLM veto compares against qcut, so a leg that no QCD + clustering assigned a scale to has to come out at a value no veto can trip + on. madevent does the same, via the "ptclus = etot" fallback in + Template/LO/SubProcesses/reweight.f. + """ + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams, batch_size=2000, seed=4321) + for clustering in ( + make_clustering(diagrams), + make_clustering(diagrams, external_pdg_ids=pdg_ids), + ): + *_, out, _ = run(clustering, momenta) + assert np.all(out > 0.0) + assert np.all(out <= CM_ENERGY) + # the fallback value appears exactly, not as a rounded-down scale + fallback = out == CM_ENERGY + assert fallback.any() + + +def test_the_fallback_value_follows_the_collider_energy(process): + """The fallback is the collider energy the clustering was built with, so + changing it moves only the unassigned legs.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + *_, out, _ = run( + make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta + ) + *_, out_half, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, cm_energy=CM_ENERGY / 2 + ), + momenta, + ) + assigned = out != CM_ENERGY + assert np.array_equal(out[assigned], out_half[assigned]) + assert np.all(out_half[~assigned] == CM_ENERGY / 2) # -------------------------------------------------------------------------- @@ -346,7 +382,14 @@ def test_scales_are_homogeneous_for_a_single_clustering(): # 1e-6 even though the relation is exact in real arithmetic assert ren_s == pytest.approx(ren * factor, rel=1e-6) assert fac_s == pytest.approx(fac * factor, rel=1e-6) - assert_jet_scales_agree(out * factor, out_s, max_flip_fraction=0.3) + # the sqrt(s) fallback is a fixed constant, so only the legs that were + # actually assigned a clustering scale scale with the momenta + assigned = (out != CM_ENERGY) & (out_s != CM_ENERGY) + assert_jet_scales_agree( + np.where(assigned, out * factor, 0.0), + np.where(assigned, out_s, 0.0), + max_flip_fraction=0.3, + ) def test_scales_mostly_scale_with_the_momenta(process): @@ -391,8 +434,8 @@ def test_renormalization_scale_is_a_geometric_mean(process): assert np.all(ren >= fac * (1.0 - 1e-12)) # mu_F is the smallest QCD clustering scale, so every jet scale - each of # which is a QCD clustering scale - is at least as large - nonzero = out > 0.0 - smallest_jet_scale = np.where(nonzero, out, np.inf).min(axis=1) + assigned = out != CM_ENERGY + smallest_jet_scale = np.where(assigned, out, np.inf).min(axis=1) assert np.all(smallest_jet_scale >= fac * (1.0 - 1e-9)) From ee8822192429d757f7144cefdf4a89908539acab Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sun, 6 Sep 2026 12:20:32 +0200 Subject: [PATCH 16/81] Add beam.jet_scale_scheme to pick madevent's jet-scale assignment pt_clust_i is the shower starting scale: an MLM run turns on Beams:setProductionScalesFromLHEF (madevent_interface.py), so Pythia reads it into event[i].scale() and limits the shower off that parton with it. The jet-matching veto never reads it, and only FxFx uses the per-particle scale, as a tag. madevent assigns each leg the hardest vertex on the parton *line* it belongs to - the scale at which the line was produced - by following the line with ipartupdate and taking a max. The kernel instead froze the scale at the first clustering the bare leg took part in, which is the last ME emission off that line. That is the CKKW question of where to restart the shower: madevent's choice lets the shower attempt the emissions the MLM veto then rejects, and those trials are the Sudakov suppression; freezing at the softest vertex never attempts them. Both are now selectable with beam.jet_scale_scheme, defaulting to "production" (madevent's). Getting there needed the parton line followed through the clustering, so: * the compiler works out per transition, from the colour representations of the mother and the two daughters, which daughter the line continues into - the first, the second, the harder one, or both after g -> q qbar - and stores it as a third word in the state machine; * the kernel keeps the resulting representative legs per slot, the equivalent of ipart, and compares transverse momenta in the original event when it has to pick the harder daughter, as the Fortran does; * colour representations come from the pdg_color_types map already exported in the subprocess metadata, with an SM fallback, and are inferred from the daughters when a propagator's flavour is unknown. Two fixes fell out of running p p > e+ ve j j, which crashed before: * find_clusterings created a transition whenever *some* diagram allowed the clustering, not whenever one of the diagrams still in play did. With none left the target state could never be expanded, and the walk had nowhere to go. Such transitions are now skipped, and a state whose expansion turns up nothing is dropped rather than pointed at. This blocked every process above 2 -> 3. * is_colored_pdg treated the merged-flavour placeholders 82 and 83 as coloured; clean_pids maps them to an electron and a neutrino. Co-Authored-By: Claude Opus 5 --- docs/mlm-merging-status.md | 55 +++- madgraph/iolibs/template_files/mg7/launch.py | 8 + .../iolibs/template_files/mg7/run_card.toml | 4 + madgraph/various/banner.py | 10 + madspace/generate_pyi.py | 1 + .../madspace/phasespace/mlm_clustering.hpp | 25 ++ madspace/instruction_set.yaml | 6 + madspace/src/kernels/mlm.hpp | 105 ++++++- madspace/src/phasespace/mlm_clustering.cpp | 219 +++++++++++--- madspace/src/python/madspace.cpp | 15 +- madspace/tests/test_mlm.py | 266 +++++++++++++++++- 11 files changed, 649 insertions(+), 65 deletions(-) diff --git a/docs/mlm-merging-status.md b/docs/mlm-merging-status.md index 425762f09a..98bfb5a285 100644 --- a/docs/mlm-merging-status.md +++ b/docs/mlm-merging-status.md @@ -273,8 +273,53 @@ history described above. Two further options, in increasing order of work: 2. Trace the parton lines through the event the way `ipartupdate` does, which is also what would be needed for per-beam factorisation scales. -One more difference in the same area, not addressed: madevent takes the -**maximum** clustering scale over every jet vertex a leg takes part in -(`ptclus(leg) = max(ptclus(leg), ...)`), while the kernel takes the scale of the -**first** clustering the leg takes part in and freezes it there (the -`is_last_cluster` mask). +### Which vertex a jet's scale comes from: `beam.jet_scale_scheme` + +madevent takes the **maximum** clustering scale over every jet vertex a leg's +*line* takes part in (`ptclus(leg) = max(ptclus(leg), ...)`, with the line +followed by `ipartupdate`), where the kernel originally took the scale of the +**first** clustering the bare leg took part in and froze it there. Both are now +available, selected by `beam.jet_scale_scheme`: + +* `"production"` (the default) - the hardest vertex on the parton line the leg + belongs to, i.e. the scale at which that line was produced. This is what + madevent does. +* `"emission"` - the vertex at which the leg itself was emitted, i.e. the + softest clustering it takes part in. + +**Why it matters.** `pt_clust_i` is not read by the jet-matching veto at all: +`madevent_interface.py` turns on `Beams:setProductionScalesFromLHEF` for an MLM +run, so Pythia reads it into `event[i].scale()` and uses it as the **starting +scale of the shower off parton i** (with `pTmaxMatch = 1`). The shipped +`JetMatching.h` uses the kinematical `pT()` for its veto, and only FxFx reads +the per-particle scale, as a tag. + +That makes `"production"` the motivated choice. It is the CKKW-style +prescription: reconstruct the branching history and restart the shower off each +line at the scale of the node that produced it, letting the veto handle the +overlap. In MLM the Sudakov suppression *is* the vetoed trial emissions, so +starting the shower at the last ME emission scale instead - which is what +`"emission"` does for a line that carries on - never attempts them, and the +relative normalisation between the n-jet samples comes out wrong. + +**What it took.** `"emission"` needs no knowledge of the event beyond the leg +indices; `"production"` needs the parton line followed through the clustering, +which is what `ipartupdate` exists for. The compiler now works out, per +transition and from the colour representations of the mother and the two +daughters, which daughter the line continues into (follow the first, the second, +the harder one, or both after a `g -> q qbar`), and stores it as a third word in +the state machine. The kernel keeps the resulting representative legs per slot, +the equivalent of `ipart`, and compares transverse momenta in the *original* +event when it has to pick the harder daughter, as the Fortran does. Colour +representations come from the `pdg_color_types` map already exported in the +subprocess metadata, with a Standard Model fallback. + +Where madevent's `ipartupdate` keys its leading cases on pdg equality +(`idmo.eq.idda1`), the colour-based table gives the same answer for every case +those cover. Where `ipartupdate` stops the run on an unrecognised vertex, the +compiler keeps the line on the first daughter: wrong, but local, and it costs +the clustering scale of one leg rather than the whole run. + +The two schemes coincide whenever no parton line carries on past the vertex that +emitted it, which is every leg of a 2 -> 3 process - and is why the earlier +`p p > e+ ve j` comparison could not have seen the difference. diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index fc48279808..5242330187 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -1359,6 +1359,14 @@ def __init__( permutations=mc_data.permutations, diagram_indices=mc_data.diagram_indices, cm_energy=self.process.e_cm, + jet_scale_scheme=getattr( + ms.MLMClustering.JetScaleScheme, + self.process.run_card["beam"]["jet_scale_scheme"], + ), + pdg_color_types={ + int(key): value + for key, value in self.meta["pdg_color_types"].items() + }, bw_cutoff=self.process.run_card["phasespace"]["bw_cutoff"], jet_radius=self.process.run_card["beam"]["jet_radius"], hadronic=not self.process.leptonic, diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index 01b62f22bb..9928ac1053 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -39,6 +39,10 @@ dynamical_scale_choice = %(beam.dynamical_scale_choice)s jet_radius = %(beam.jet_radius)s # heaviest quark flavour treated as a jet by the MLM back-clustering max_jet_flavor = %(beam.max_jet_flavor)s +# how the MLM back-clustering assigns a clustering scale to each outgoing jet +# options: production (the scale at which the parton's line was produced, as in +# madevent), emission (the scale at which the leg itself was emitted) +jet_scale_scheme = %(beam.jet_scale_scheme)s [generation] events = %(generation.events)s diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index d85c072019..78d50c7893 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6524,6 +6524,16 @@ def default_setup(self): # Heaviest quark flavour treated as a jet by the MLM back-clustering # (the counterpart of maxjetflavor in the legacy run_card). self.add_toml_param('beam', 'max_jet_flavor', 4) + # How the MLM back-clustering assigns a clustering scale to each + # outgoing jet (written to the LHE as pt_clust_i, and used by Pythia as + # the shower starting scale for that parton). + # "production": the hardest vertex on the parton line the leg belongs + # to, i.e. the scale at which that line was produced. + # This is what madevent does. + # "emission": the vertex at which the leg itself was emitted, i.e. + # the softest clustering it takes part in. + self.add_toml_param('beam', 'jet_scale_scheme', "production", + allowed=['production', 'emission']) # -------------------------- [generation] ---------------------- self.add_toml_param('generation', 'events', 100000, gridpack=True) diff --git a/madspace/generate_pyi.py b/madspace/generate_pyi.py index c1581c1d6b..d8f0c80cc5 100644 --- a/madspace/generate_pyi.py +++ b/madspace/generate_pyi.py @@ -57,6 +57,7 @@ def main(): "--enum-class-locations=Activation:MLP.Activation", "--enum-class-locations=CutMode:Cuts.CutMode", "--enum-class-locations=LRSchedule:AdamOptimizer.LRSchedule", + "--enum-class-locations=JetScaleScheme:MLMClustering.JetScaleScheme", # Fail the build on unresolvable names/expressions (e.g. an enum # default value needing its own --enum-class-locations entry) # instead of silently emitting a stub with holes in it. diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp index dc8929ec93..6a947dd53b 100644 --- a/madspace/include/madspace/phasespace/mlm_clustering.hpp +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -1,10 +1,28 @@ #pragma once +#include + #include "madspace/phasespace/base.hpp" #include "madspace/phasespace/topology.hpp" namespace madspace { +// Number of words each clustering transition occupies in the compiled state +// machine: (data, next_offset, trace_data). +inline constexpr int state_machine_item_size = 3; + +// How a clustering scale is assigned to an outgoing jet. +enum class JetScaleScheme { + // The scale of the vertex at which the leg itself was emitted, i.e. the + // softest clustering it takes part in. + emission = 0, + // The hardest vertex on the parton line the leg belongs to, which is the + // scale at which that line was produced. This is what madevent writes, and + // what Pythia uses as the shower starting scale for the leg when MLM + // merging turns on Beams:setProductionScalesFromLHEF. + production = 1, +}; + class MLMClustering : public FunctionGenerator { public: MLMClustering( @@ -15,6 +33,12 @@ class MLMClustering : public FunctionGenerator { // scale to is reported at this value rather than at zero, so that an // MLM veto can never trip on it. double cm_energy, + JetScaleScheme jet_scale_scheme = JetScaleScheme::production, + // Signed color representation per pdg id, as exported in the + // subprocess metadata. Used to follow a parton line through the + // clustering; falls back to the Standard Model assignment for a pdg id + // that is not listed. + std::unordered_map pdg_color_types = {}, double bw_cutoff = 15, double jet_radius = 0.4, bool hadronic = true, @@ -43,6 +67,7 @@ class MLMClustering : public FunctionGenerator { std::vector _bw_masses; std::vector _bw_widths; double _cm_energy; + JetScaleScheme _jet_scale_scheme; double _bw_cutoff; double _jet_radius; bool _hadronic; diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index d8a37f3afc..27d6318f46 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -2220,6 +2220,9 @@ mlm_clustering_hadronic: - name: cm_energy type: [float, single] desc: + - name: jet_scale_scheme + type: [int, single] + desc: outputs: - name: ren_scale type: [float] @@ -2267,6 +2270,9 @@ mlm_clustering_leptonic: - name: cm_energy type: [float, single] desc: + - name: jet_scale_scheme + type: [int, single] + desc: outputs: - name: ren_scale type: [float] diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 3289342970..6430cf1fda 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -11,6 +11,19 @@ constexpr double ONE_PLUS_TINY = 1.000001; // Stand-in for a clustering measure that came out inf or NaN; still far // above any physical scale, and small enough that SCALE_MAX * 10 is finite. constexpr double SCALE_MAX = 1e307; +// Words per clustering transition in the compiled state machine: +// (data, next_offset, trace_data). Must match state_machine_item_size in +// madspace/phasespace/mlm_clustering.hpp. +constexpr int STATE_ITEM_SIZE = 3; +// trace_data values, mirroring TraceMode in mlm_clustering.cpp: which daughter +// of a clustering the mother's parton line continues into. +constexpr int TRACE_FIRST = 0; +constexpr int TRACE_SECOND = 1; +constexpr int TRACE_HARDER = 2; +constexpr int TRACE_BOTH = 3; +// JetScaleScheme in mlm_clustering.hpp. +constexpr int SCHEME_EMISSION = 0; +constexpr int SCHEME_PRODUCTION = 1; // mT^2 = E^2 - pz^2 (hadronic) or E^2 (lepton collider). // based on djb_clus from Template/NLO/SubProcesses/cluster.f @@ -197,6 +210,7 @@ KERNELSPEC void mlm_clustering( FIn bw_cutoff, FIn jet_radius, FIn cm_energy, + IIn jet_scale_scheme, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -215,6 +229,7 @@ KERNELSPEC void mlm_clustering( FVal masses_tmp[N_EXT_MAX]; int alive = (1 << n_part) - 1; int cluster_history[N_EXT_MAX - 3]; + int cluster_trace[N_EXT_MAX - 3]; FVal cluster_scales[N_EXT_MAX - 3]; for (int i = 0; i < n_part; ++i) { @@ -224,12 +239,13 @@ KERNELSPEC void mlm_clustering( masses_tmp[i] = external_masses[i]; } - int win_next_state = -1, win_data = 0; + int win_next_state = -1, win_data = 0, win_trace = TRACE_FIRST; bool win_resonant = false; FVal win_scale = SCALE_MAX * 10.; while (cluster_count < cluster_max) { int data = state_machine[state]; int next_state = state_machine[state + 1]; + int trace_data = state_machine[state + 2]; int particle1 = data & 0xFF; int particle2 = (data >> 8) & 0xFF; int mass_index = (data >> 16) & 0xFF; @@ -289,6 +305,7 @@ KERNELSPEC void mlm_clustering( win_next_state = next_state; win_scale = scale; win_data = data; + win_trace = trace_data & 0x3; win_resonant = resonant; } if (is_last) { @@ -299,6 +316,7 @@ KERNELSPEC void mlm_clustering( ); state = win_next_state; cluster_history[cluster_count] = win_data; + cluster_trace[cluster_count] = win_trace; cluster_scales[cluster_count] = win_scale; ++cluster_count; // Reset the whole selection, not just the scale: leaving @@ -307,10 +325,11 @@ KERNELSPEC void mlm_clustering( // the walk to a stale state if that happened. win_next_state = -1; win_data = 0; + win_trace = TRACE_FIRST; win_resonant = false; win_scale = SCALE_MAX * 10.; } else { - state += 2; + state += STATE_ITEM_SIZE; } } @@ -332,6 +351,17 @@ KERNELSPEC void mlm_clustering( for (int i = 0; i < n_part - 2; ++i) { outgoing_scales[i] = 0.0; } + + // Which external legs the parton line of each surviving slot ends at. + // madevent calls this ipart; a slot stands for two legs after a + // g -> q qbar splitting, and for none once its line has been absorbed. + int rep1[N_EXT_MAX], rep2[N_EXT_MAX]; + for (int i = 0; i < n_part; ++i) { + rep1[i] = i; + rep2[i] = -1; + } + + bool by_production = jet_scale_scheme == SCHEME_PRODUCTION; FVal ren_scale_val = 1.0; int is_last_cluster = 0b11111111'11111111'11111100; for (int i = 0; i < cluster_max; ++i) { @@ -343,17 +373,74 @@ KERNELSPEC void mlm_clustering( bool is_jet1 = (data >> 28) & 1; bool is_jet2 = (data >> 29) & 1; if (is_qcd) { - if (is_jet1 && (is_last_cluster & (1 << particle1))) { - outgoing_scales[particle1 - 2] = scale; - } - if (is_jet2 && (is_last_cluster & (1 << particle2))) { - outgoing_scales[particle2 - 2] = scale; + if (by_production) { + // Book this vertex onto every external leg the two daughters' + // lines end at, keeping the hardest vertex each leg takes part + // in. That is the scale at which the leg's line was produced. + for (int k = 0; k < 2; ++k) { + if (!(k == 0 ? is_jet1 : is_jet2)) { + continue; + } + int daughter = k == 0 ? particle1 : particle2; + for (int m = 0; m < 2; ++m) { + int leg = m == 0 ? rep1[daughter] : rep2[daughter]; + if (leg >= 2 && scale > outgoing_scales[leg - 2]) { + outgoing_scales[leg - 2] = scale; + } + } + } + } else { + // Book the vertex at which the leg itself was emitted, i.e. + // the first (softest) clustering it takes part in. + if (is_jet1 && (is_last_cluster & (1 << particle1))) { + outgoing_scales[particle1 - 2] = scale; + } + if (is_jet2 && (is_last_cluster & (1 << particle2))) { + outgoing_scales[particle2 - 2] = scale; + } } ren_scale_val *= scale; } else { ren_scale_val *= max_scale; } is_last_cluster &= ~((1 << particle1) | (1 << particle2)); + + // Carry the parton line into the mother, which occupies slot + // particle1. Only needed for the production scheme, but keeping it + // unconditional costs nothing and keeps the two branches comparable. + int trace = cluster_trace[i]; + int a1 = rep1[particle1], a2 = rep2[particle1]; + int b1 = rep1[particle2], b2 = rep2[particle2]; + if (trace == TRACE_HARDER || trace == TRACE_BOTH) { + // madevent compares the transverse momenta of the representative + // legs in the original event, not in the clustered one + bool second_harder; + if (a1 < 0) { + second_harder = true; + } else if (b1 < 0) { + second_harder = false; + } else { + FVal pt_a = momenta[a1][1] * momenta[a1][1] + + momenta[a1][2] * momenta[a1][2]; + FVal pt_b = momenta[b1][1] * momenta[b1][1] + + momenta[b1][2] * momenta[b1][2]; + second_harder = pt_b > pt_a; + } + if (trace == TRACE_HARDER) { + if (second_harder) { + rep1[particle1] = b1; + rep2[particle1] = b2; + } + } else { + // both daughters carry the line on, hardest first + rep1[particle1] = second_harder ? b1 : a1; + rep2[particle1] = second_harder ? a1 : b1; + } + } else if (trace == TRACE_SECOND) { + rep1[particle1] = b1; + rep2[particle1] = b2; + } + // TRACE_FIRST: slot particle1 already holds daughter 1's line } // Any outgoing leg that no QCD clustering booked a scale onto keeps @@ -397,6 +484,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( FIn bw_cutoff, FIn jet_radius, FIn cm_energy, + IIn jet_scale_scheme, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -413,6 +501,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( bw_cutoff, jet_radius, cm_energy, + jet_scale_scheme, ren_scale, fact_scale1, fact_scale2, @@ -433,6 +522,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( FIn bw_cutoff, FIn jet_radius, FIn cm_energy, + IIn jet_scale_scheme, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -449,6 +539,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( bw_cutoff, jet_radius, cm_energy, + jet_scale_scheme, ren_scale, fact_scale1, fact_scale2, diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index 9c06ba4daa..58efc4604e 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include using namespace madspace; @@ -31,12 +33,85 @@ bool is_jet_pdg(int pdg_id, int max_jet_flavor) { return (a >= 1 && a <= max_jet_flavor) || a == 21 || a == 81; } +// Color representation of a line, as the signed color of the model (1 for a +// singlet, +-3 for a (anti)triplet, 8 for an octet, +-6 for a sextet). The +// merged-flavor placeholders that survive in propagator pdg ids stand for the +// particles clean_pids() maps them to in launch.py: 81 is a quark, 82 an +// electron and 83 a neutrino. +int color_rep(int pdg_id, const std::unordered_map& pdg_color_types) { + if (auto search = pdg_color_types.find(pdg_id); search != pdg_color_types.end()) { + return std::abs(search->second); + } + int a = std::abs(pdg_id); + if ((a >= 1 && a <= 6) || a == 81) { + return 3; + } + if (a == 21) { + return 8; + } + return 1; +} + // Whether a line carries color. Used to decide whether a *vertex* is a QCD // splitting, which is what selects the clustering scales entering mu_R/mu_F. -// 81-83 are the merged-flavor placeholders. -bool is_colored_pdg(int pdg_id) { - int a = std::abs(pdg_id); - return (a >= 1 && a <= 6) || a == 21 || (a >= 81 && a <= 83); +bool is_colored_pdg(int pdg_id, const std::unordered_map& pdg_color_types) { + return color_rep(pdg_id, pdg_color_types) != 1; +} + +// How the parton line of the mother of a clustering continues into its two +// daughters, so that a clustering scale can be booked onto the external legs +// the line ends at. Mirrors ipartupdate() in +// Template/LO/SubProcesses/reweight.f, which decides the same thing from the +// color representations (its leading pdg-equality tests give the same answer +// as the color ones for every case they cover). +enum TraceMode { + trace_first = 0, // the line continues into daughter 1 + trace_second = 1, // ... into daughter 2 + trace_harder = 2, // ... into whichever daughter is harder (decided per event) + trace_both = 3, // the mother stands for both daughters' lines +}; + +TraceMode trace_mode_for(int color_in, int color_1, int color_2) { + if (color_in == 8) { + // g -> g g, or an octet splitting to two octets: follow the harder one + if (color_1 == 8 && color_2 == 8) { + return trace_harder; + } + // g -> q qbar: both daughters carry the line on + if (color_1 == 3 && color_2 == 3) { + return trace_both; + } + // g -> g H and friends: follow the colored daughter + if (color_1 == 8) { + return trace_first; + } + if (color_2 == 8) { + return trace_second; + } + } else if (color_in == 3) { + // an epsilon^ijk vertex, 3 -> 3 3, is treated like a photon emission + // and follows the first daughter + if (color_1 == 3 && color_2 == 3) { + return trace_first; + } + // q -> q g, q -> q Z/H/W, and the exotic q -> q' S variants + if (color_1 == 3) { + return trace_first; + } + if (color_2 == 3) { + return trace_second; + } + } else if (color_in == 6) { + if (color_1 == 3 && color_2 == 3) { + return trace_both; + } + } else if (color_in == 1) { + return trace_both; + } + // Nothing matched. The Fortran stops the run here; keeping the line on the + // first daughter is wrong but local, and only costs the clustering scale of + // one leg rather than the whole event. + return trace_first; } struct CompileContext { @@ -45,6 +120,7 @@ struct CompileContext { std::vector& bw_masses; std::vector& bw_widths; std::map, int>& bw_indices; + const std::unordered_map& pdg_color_types; bool have_pdg_ids; int max_jet_flavor; }; @@ -61,6 +137,7 @@ struct StateItem { bool is_qcd; bool is_jet1; bool is_jet2; + TraceMode trace_mode; }; // 1-based index into the Breit-Wigner tables handed to the kernel, or 0 for @@ -97,6 +174,27 @@ StateItem make_state_item( // resonance test is only meaningful for a final-state clustering. int mass_index = is_initial ? 0 : breit_wigner_index(ctx, meta_in); + int color_1 = color_rep(meta_1.pdg_id, ctx.pdg_color_types); + int color_2 = color_rep(meta_2.pdg_id, ctx.pdg_color_types); + int color_in = color_rep(meta_in.pdg_id, ctx.pdg_color_types); + if (meta_in.pdg_id == 0) { + // The propagator's flavor was not supplied (the topology fixtures used + // by the tests carry masses only), so color_rep would call it a + // singlet. Infer it from the daughters instead, which is unambiguous + // for a QCD vertex. + if (color_1 == 3 && color_2 == 3) { + color_in = 8; + } else if (color_1 == 8 && color_2 == 8) { + color_in = 8; + } else if (color_1 == 3 || color_2 == 3) { + color_in = 3; + } + } + // An initial-state clustering carries the beam line on, which is daughter 1 + // by construction, so the color-based table does not apply to it. + TraceMode trace_mode = + is_initial ? trace_first : trace_mode_for(color_in, color_1, color_2); + bool is_qcd, is_jet1, is_jet2; if (!ctx.have_pdg_ids) { // No flavor information supplied: fall back to assuming every @@ -112,8 +210,8 @@ StateItem make_state_item( // the two children then: a splitting of two colored lines is QCD unless // the parent says otherwise, which is also the only test available for // t-channel lines whose flavor changes along the chain. - is_qcd = is_colored_pdg(meta_1.pdg_id) && is_colored_pdg(meta_2.pdg_id) && - (meta_in.pdg_id == 0 || is_colored_pdg(meta_in.pdg_id)); + is_qcd = color_1 != 1 && color_2 != 1 && + (meta_in.pdg_id == 0 || color_in != 1); // Only final state particles get a clustering scale in the LHE output, // so a beam leg is never a jet here. is_jet1 = !is_initial && is_jet_pdg(meta_1.pdg_id, ctx.max_jet_flavor); @@ -131,6 +229,7 @@ StateItem make_state_item( .is_qcd = is_qcd, .is_jet1 = is_jet1, .is_jet2 = is_jet2, + .trace_mode = trace_mode, }; } @@ -140,6 +239,7 @@ void find_clusterings( const std::vector& diagrams, nested_vector2& states, std::map& state_map, + std::set& dead_states, int prev_index ) { int n_masks = particle_masks.size(); @@ -169,6 +269,13 @@ void find_clusterings( new_diags.push_back(index); } } + // valid_diags says some diagram allows this clustering, not that + // any of the diagrams still in play does. With none left there is + // no clustering history to continue, and following it anyway + // builds a state the walk can never leave. + if (new_diags.size() == 0) { + continue; + } bool is_terminal = new_masks.size() == 3; StateKey key; @@ -182,41 +289,55 @@ void find_clusterings( if (auto search = state_map.find(key); search != state_map.end()) { index = search->second; is_new_state = false; + // A state already known to be a dead end stays one. + if (dead_states.contains(index)) { + continue; + } } else { index = states.size(); state_map[key] = index; states.push_back({}); is_new_state = true; } - states.at(prev_index) - .push_back(make_state_item(ctx, index, mask, mask_i, mask_j)); // Only expand a state the first time it is reached. Expanding it // again would append a second copy of the same transitions, once // per path leading to it. - if (!is_new_state) { - continue; - } - if (is_terminal) { - for (int diag_index : new_diags) { - states.at(index).push_back({ - .next_state = diag_index, - .particle1 = 0, - .particle2 = 0, - .mass_index = 0, - .massive_in = false, - .massive_out1 = false, - .massive_out2 = false, - .is_qcd = false, - .is_jet1 = false, - .is_jet2 = false, - }); + if (is_new_state) { + if (is_terminal) { + for (int diag_index : new_diags) { + states.at(index).push_back({ + .next_state = diag_index, + .particle1 = 0, + .particle2 = 0, + .mass_index = 0, + .massive_in = false, + .massive_out1 = false, + .massive_out2 = false, + .is_qcd = false, + .is_jet1 = false, + .is_jet2 = false, + .trace_mode = trace_first, + }); + } + } else { + find_clusterings( + ctx, new_masks, new_diags, states, state_map, + dead_states, index + ); + } + // The expansion may have found nothing: every clustering left + // is unsupported by the remaining diagrams. Drop the + // transition rather than pointing it at a state the walk + // cannot leave. + if (states.at(index).size() == 0) { + dead_states.insert(index); + continue; } - } else { - find_clusterings( - ctx, new_masks, new_diags, states, state_map, index - ); } + + states.at(prev_index) + .push_back(make_state_item(ctx, index, mask, mask_i, mask_j)); } } } @@ -240,6 +361,8 @@ MLMClustering::MLMClustering( nested_vector3 permutations, nested_vector2 diagram_indices, double cm_energy, + JetScaleScheme jet_scale_scheme, + std::unordered_map pdg_color_types, double bw_cutoff, double jet_radius, bool hadronic, @@ -258,6 +381,7 @@ MLMClustering::MLMClustering( {"diagram_index", batch_int}} ), _cm_energy(cm_energy), + _jet_scale_scheme(jet_scale_scheme), _bw_cutoff(bw_cutoff), _jet_radius(jet_radius), _hadronic(hadronic) { @@ -415,32 +539,48 @@ MLMClustering::MLMClustering( .bw_masses = _bw_masses, .bw_widths = _bw_widths, .bw_indices = bw_indices, + .pdg_color_types = pdg_color_types, .have_pdg_ids = have_pdg_ids, .max_jet_flavor = max_jet_flavor, }; - find_clusterings(ctx, masks, all_diags, states, state_map, 0); + std::set dead_states; + find_clusterings(ctx, masks, all_diags, states, state_map, dead_states, 0); - for (auto& state : states) { - if (state.size() == 0) { - // A state with no way on would leave the kernel reading past the end - // of its transition list. + if (states.at(0).size() == 0) { + throw std::logic_error( + "MLM clustering found no valid clustering for this process" + ); + } + for (std::size_t i = 0; auto& state : states) { + // Dead ends are unreachable: no transition points at them any more, but + // they still occupy a slot in `states` so that the offsets stay stable. + if (state.size() == 0 && !dead_states.contains(static_cast(i))) { throw std::logic_error( "MLM clustering reached a state with no valid clustering left" ); } + ++i; } + // Layout: a clustering state is a run of (data, next_offset, trace_data) + // triples ending at the triple whose data has the is_last bit set; a + // terminal state is a count followed by that many diagram indices. std::vector first_indices; first_indices.reserve(states.size()); for (int offset = 0; auto& state : states) { first_indices.push_back(offset); - if (state.at(0).particle1 == 0 && state.at(0).particle2 == 0) { + if (state.size() == 0) { + // a dropped dead end: nothing points at it, so it takes no space + } else if (state.at(0).particle1 == 0 && state.at(0).particle2 == 0) { offset += 1 + state.size(); } else { - offset += 2 * state.size(); + offset += state_machine_item_size * state.size(); } } for (auto& state : states) { + if (state.size() == 0) { + continue; + } if (state.at(0).particle1 == 0 && state.at(0).particle2 == 0) { _cluster_state_machine.push_back(state.size()); for (auto& item : state) { @@ -456,6 +596,7 @@ MLMClustering::MLMClustering( ((&item == &state.back()) << 30) ); _cluster_state_machine.push_back(first_indices.at(item.next_state)); + _cluster_state_machine.push_back(static_cast(item.trace_mode)); } } } @@ -476,7 +617,8 @@ NamedVector MLMClustering::build_function_impl( _bw_widths, _bw_cutoff, _jet_radius, - _cm_energy + _cm_energy, + static_cast(_jet_scale_scheme) ); } else { mlm_out = fb.mlm_clustering_leptonic( @@ -488,7 +630,8 @@ NamedVector MLMClustering::build_function_impl( _bw_widths, _bw_cutoff, _jet_radius, - _cm_energy + _cm_energy, + static_cast(_jet_scale_scheme) ); } return {return_types().keys(), {mlm_out.begin(), mlm_out.end()}}; diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index 2b778073f0..740b4db9fa 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -975,13 +975,24 @@ PYBIND11_MODULE(_madspace_py, m) { "initialize_globals", &DiscreteFlow::initialize_globals, py::arg("context") ); - py::classh(m, "MLMClustering") + py::classh mlm(m, "MLMClustering"); + add_enum( + mlm, + "JetScaleScheme", + { + {"emission", JetScaleScheme::emission}, + {"production", JetScaleScheme::production}, + } + ); + mlm .def( py::init< std::vector, nested_vector3, nested_vector2, double, + JetScaleScheme, + std::unordered_map, double, double, bool, @@ -991,6 +1002,8 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("permutations"), py::arg("diagram_indices"), py::arg("cm_energy"), + py::arg("jet_scale_scheme") = JetScaleScheme::production, + py::arg("pdg_color_types") = std::unordered_map{}, py::arg("bw_cutoff") = 15., py::arg("jet_radius") = 0.4, py::arg("hadronic") = true, diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 28dcfebbdf..63abc7de3d 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -496,7 +496,7 @@ def test_resonant_propagators_get_a_breit_wigner_index(): resonant_pairs = set() non_terminal, _ = walk(np.asarray(with_width.cluster_state_machine), 6) for _, (_, transitions) in non_terminal.items(): - for data, _ in transitions: + for data, _, _ in transitions: if field(data, BIT_MASS_INDEX) != 0: resonant_pairs.add( (field(data, BIT_PARTICLE1), field(data, BIT_PARTICLE2)) @@ -592,7 +592,7 @@ def first_step_candidates(clustering, n_ext, event, jet_radius, masses): """ flat = np.asarray(clustering.cluster_state_machine) candidates = {} - for data, _ in decode_transitions(flat, 0): + for data, _, _ in decode_transitions(flat, 0): p1 = field(data, BIT_PARTICLE1) p2 = field(data, BIT_PARTICLE2) if p1 < 2: @@ -675,7 +675,7 @@ def test_kt_measure_matches_the_fortran(): flat = np.asarray(clustering.cluster_state_machine) offered = { (field(d, BIT_PARTICLE1), field(d, BIT_PARTICLE2)) - for d, _ in decode_transitions(flat, 0) + for d, _, _ in decode_transitions(flat, 0) } assert (2, 3) in offered @@ -711,6 +711,13 @@ def test_kt_measure_matches_the_fortran(): BIT_IS_JET2 = (29, 1) BIT_IS_LAST = (30, 1) +# Words per transition: (data, next_offset, trace_data). +STATE_ITEM_SIZE = 3 + +# trace_data values: which daughter the mother's parton line continues into. +TRACE_FIRST, TRACE_SECOND, TRACE_HARDER, TRACE_BOTH = 0, 1, 2, 3 +TRACE_MODES = {TRACE_FIRST, TRACE_SECOND, TRACE_HARDER, TRACE_BOTH} + def field(data, spec): shift, mask = spec @@ -718,15 +725,18 @@ def field(data, spec): def decode_transitions(machine, offset): - """The (data, next_offset) pairs of the non-terminal state at `offset`.""" + """The (data, next_offset, trace) triples of the non-terminal state at + `offset`.""" transitions = [] while True: - assert offset + 1 < len(machine), "transition list runs past the machine" - data, next_offset = machine[offset], machine[offset + 1] - transitions.append((data, next_offset)) + assert offset + STATE_ITEM_SIZE <= len(machine), ( + "transition list runs past the machine" + ) + data, next_offset, trace = machine[offset : offset + STATE_ITEM_SIZE] + transitions.append((data, next_offset, trace)) if field(data, BIT_IS_LAST): return transitions - offset += 2 + offset += STATE_ITEM_SIZE def walk(machine, n_ext): @@ -750,7 +760,7 @@ def walk(machine, n_ext): continue transitions = decode_transitions(machine, offset) non_terminal[offset] = (depth, transitions) - for _, next_offset in transitions: + for _, next_offset, _ in transitions: todo.append((next_offset, depth + 1)) return non_terminal, terminal @@ -784,7 +794,7 @@ def test_state_machine_transitions_are_in_range(machine): _, flat, n_ext = machine non_terminal, _ = walk(flat, n_ext) for offset, (_, transitions) in non_terminal.items(): - for data, next_offset in transitions: + for data, next_offset, _ in transitions: particle1 = field(data, BIT_PARTICLE1) particle2 = field(data, BIT_PARTICLE2) # the merged pseudo-particle is always named by the lower index, so @@ -802,7 +812,7 @@ def test_state_machine_has_no_duplicate_transitions(machine): for offset, (_, transitions) in non_terminal.items(): keys = [ (field(d, BIT_PARTICLE1), field(d, BIT_PARTICLE2), nxt) - for d, nxt in transitions + for d, nxt, _ in transitions ] assert len(keys) == len(set(keys)), f"duplicate transitions at state {offset}" @@ -813,7 +823,7 @@ def test_state_machine_marks_exactly_one_last_transition(machine): _, flat, n_ext = machine non_terminal, _ = walk(flat, n_ext) for offset, (_, transitions) in non_terminal.items(): - flags = [field(d, BIT_IS_LAST) for d, _ in transitions] + flags = [field(d, BIT_IS_LAST) for d, _, _ in transitions] assert flags[-1] == 1 assert sum(flags) == 1 @@ -826,7 +836,7 @@ def test_state_machine_mass_indices_are_in_range(machine): assert len(clustering.bw_widths) == bw_count non_terminal, _ = walk(flat, n_ext) for _, (_, transitions) in non_terminal.items(): - for data, _ in transitions: + for data, _, _ in transitions: assert field(data, BIT_MASS_INDEX) <= bw_count @@ -835,13 +845,83 @@ def test_initial_state_clusterings_are_never_resonant(machine): _, flat, n_ext = machine non_terminal, _ = walk(flat, n_ext) for _, (_, transitions) in non_terminal.items(): - for data, _ in transitions: + for data, _, _ in transitions: if field(data, BIT_PARTICLE1) < 2: assert field(data, BIT_MASS_INDEX) == 0 # a beam leg is not a final-state particle and gets no jet scale assert field(data, BIT_IS_JET1) == 0 +def test_state_machine_trace_modes_are_valid(machine): + """Every transition says how the mother's parton line continues into its + daughters, and an initial-state clustering always carries the beam line on, + which is daughter 1 by construction.""" + _, flat, n_ext = machine + non_terminal, _ = walk(flat, n_ext) + for _, (_, transitions) in non_terminal.items(): + for data, _, trace in transitions: + assert trace in TRACE_MODES + if field(data, BIT_PARTICLE1) < 2: + assert trace == TRACE_FIRST + + +def trace_modes_of(clustering, n_ext): + non_terminal, _ = walk(np.asarray(clustering.cluster_state_machine), n_ext) + return { + (field(data, BIT_PARTICLE1), field(data, BIT_PARTICLE2)): trace + for _, (_, transitions) in non_terminal.items() + for data, _, trace in transitions + } + + +def three_gluon_diagram(propagator_pdgs): + """g g > g g g through an s-channel gluon splitting, legs (g, g, g, g, g).""" + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0, 0.0], + "propagators": [[0.0, 0.0, 0, 0.0, 0.0, pdg] for pdg in propagator_pdgs], + "vertices": [ + ["o0", "o1", "p0"], + ["i0", "o2", "p1"], + ["p1", "p0", "i1"], + ], + "permutations": [[0, 1, 2, 3, 4]], + } + ] + + +def test_gluon_splitting_follows_the_harder_gluon(): + """A g -> g g vertex carries the parton line into the harder gluon, as + ipartupdate does. p0 recombines legs 2 and 3 into a gluon.""" + clustering = make_clustering( + three_gluon_diagram([21, 21]), external_pdg_ids=[21] * 5 + ) + assert trace_modes_of(clustering, 5)[(2, 3)] == TRACE_HARDER + + +def test_gluon_to_quarks_carries_the_line_into_both(): + """A g -> q qbar vertex has no single continuing line, so both daughters + carry it on and both get booked at later vertices.""" + diagrams = three_gluon_diagram([21, 21]) + diagrams[0]["propagators"][0] = [0.0, 0.0, 0, 0.0, 0.0, 21] + clustering = make_clustering(diagrams, external_pdg_ids=[21, 21, 1, -1, 21]) + assert trace_modes_of(clustering, 5)[(2, 3)] == TRACE_BOTH + + +def test_quark_line_follows_the_quark(): + """A q -> q g vertex keeps the line on the quark, whichever daughter it is.""" + # p0 recombines legs 2 and 3 into a quark + quark_first = make_clustering( + three_gluon_diagram([1, 21]), external_pdg_ids=[21, 21, 1, 21, 21] + ) + assert trace_modes_of(quark_first, 5)[(2, 3)] == TRACE_FIRST + quark_second = make_clustering( + three_gluon_diagram([1, 21]), external_pdg_ids=[21, 21, 21, 1, 21] + ) + assert trace_modes_of(quark_second, 5)[(2, 3)] == TRACE_SECOND + + def test_terminal_states_select_known_diagrams(machine): _, flat, n_ext = machine _, terminal = walk(flat, n_ext) @@ -904,3 +984,161 @@ def test_exactly_collinear_momenta_are_handled(process): assert np.all(np.isfinite(ren)) assert np.all(np.isfinite(fac1)) assert np.all(np.isfinite(out)) + + +# -------------------------------------------------------------------------- +# the jet-scale scheme switch +# -------------------------------------------------------------------------- + + +EMISSION = ms.MLMClustering.JetScaleScheme.emission +PRODUCTION = ms.MLMClustering.JetScaleScheme.production + + +def test_production_scheme_is_never_softer_than_emission(process): + """The two schemes book the same vertices onto a leg plus, for "production", + every later vertex on the line the leg belongs to. Since "production" keeps + the hardest of those and "emission" keeps the one "production" starts from, + the production scale of a leg can only be larger or equal. + """ + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams, batch_size=1000, seed=2468) + *_, emission, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=EMISSION + ), + momenta, + ) + *_, production, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=PRODUCTION + ), + momenta, + ) + assert np.all(production >= emission * (1.0 - 1e-12)) + + +def gluon_chain_diagram(): + """g g > g g g g through a chain of two s-channel gluon splittings, so that + one gluon line carries on past the vertex that emitted it: + p0 = g(o0, o1), p1 = g(p0, o2). Legs (g, g, g, g, g, g).""" + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0, 0.0, 0.0], + "propagators": [[0.0, 0.0, 0, 0.0, 0.0, 21] for _ in range(3)], + "vertices": [ + ["o0", "o1", "p0"], + ["p0", "o2", "p1"], + ["i0", "o3", "p2"], + ["p2", "p1", "i1"], + ], + "permutations": [[0, 1, 2, 3, 4, 5]], + } + ] + + +def test_the_two_schemes_disagree_on_continuing_lines(): + """The schemes differ only for a leg whose parton line carries on past the + vertex that emitted it. In the chain above the harder of the first two + gluons stays on the line and is booked again at the second vertex, so + "production" gives it the harder of the two scales and "emission" the + softer. That is the whole point of the switch.""" + diagrams = gluon_chain_diagram() + pdg_ids = [21] * 6 + momenta = sample_momenta(diagrams, batch_size=1000, seed=2468) + *_, emission, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=EMISSION + ), + momenta, + ) + *_, production, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=PRODUCTION + ), + momenta, + ) + differs = ~np.isclose(production, emission) + assert differs.any(), "the two schemes gave identical jet scales" + # and where they differ it is because production kept a harder vertex + assert np.all(production[differs] > emission[differs]) + + +def test_the_schemes_coincide_when_no_line_continues(process): + """With propagator flavours unknown a merged node is not recognised as a + jet, so nothing is booked onto a continuing line and the two schemes fall + back to the same answer. The ttgg/ttggg fixtures carry propagator masses + only, so this pins that degradation as intentional rather than accidental. + """ + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams, batch_size=500, seed=2468) + *_, emission, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=EMISSION + ), + momenta, + ) + *_, production, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=PRODUCTION + ), + momenta, + ) + assert np.array_equal(production, emission) + + +def test_the_schemes_agree_for_a_single_clustering(): + """A 2 -> 3 process has one clustering per leg, so nothing continues past + the vertex that emitted it and the two schemes have to coincide.""" + diagrams = three_gluon_diagram([21, 21]) + pdg_ids = [21] * 5 + momenta = sample_momenta(diagrams, batch_size=256, seed=1357) + *_, emission, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=EMISSION + ), + momenta, + ) + *_, production, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=PRODUCTION + ), + momenta, + ) + assert np.array_equal(production, emission) + + +def test_the_scheme_does_not_change_the_scales(process): + """The switch only moves which leg a clustering scale is booked against; the + clustering history, and therefore mu_R and mu_F, are untouched.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams, batch_size=1000, seed=2468) + ren_e, fac_e, _, _, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=EMISSION + ), + momenta, + ) + ren_p, fac_p, _, _, _ = run( + make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_scale_scheme=PRODUCTION + ), + momenta, + ) + assert np.array_equal(ren_e, ren_p) + assert np.array_equal(fac_e, fac_p) + + +def test_production_is_the_default(): + """The default matches madevent, which is the validated reference.""" + diagrams = three_gluon_diagram([21, 21]) + assert ( + ms.MLMClustering.JetScaleScheme.__members__["production"] == PRODUCTION + ) + default = make_clustering(diagrams, external_pdg_ids=[21] * 5) + explicit = make_clustering( + diagrams, external_pdg_ids=[21] * 5, jet_scale_scheme=PRODUCTION + ) + momenta = sample_momenta(diagrams, batch_size=64, seed=11) + assert np.array_equal(run(default, momenta)[3], run(explicit, momenta)[3]) From e60634642b820f96dfd193caf28ddc0d27d42ac8 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sun, 6 Sep 2026 17:12:36 +0200 Subject: [PATCH 17/81] Point at the DJR validation tooling from the status notes The differential jet rates that say whether the merging works live on their own branch, since reconstructing them from a showered event record is as useful for madevent as it is for madspace. Co-Authored-By: Claude Opus 5 --- docs/mlm-merging-status.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/mlm-merging-status.md b/docs/mlm-merging-status.md index 98bfb5a285..69308e1759 100644 --- a/docs/mlm-merging-status.md +++ b/docs/mlm-merging-status.md @@ -141,6 +141,15 @@ comparison against the validated madevent MLM disagree. * **No Sudakov reweighting**, which is the other half of what the NLO code does. +## Validating the merging + +Differential jet rates reconstructed from a showered event record are what say +whether the merging works: each multiplicity sample has to switch off where the +next switches on, and their sum has to be smooth across the merging scale. That +tooling lives on its own branch (`claude/mlm-djr-plots`, +`madgraph/various/djr_from_hepmc.py`), since it is useful for madevent just as +much as for madspace. + ## Open items * **The trigger for MLM is `dynamical_scale_choice = "mlm"`, which is probably From 5de1dbd6763ab401ea414d0461c53c635d792683 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sun, 6 Sep 2026 19:31:32 +0200 Subject: [PATCH 18/81] Pin the external-mass ordering with a case that can tell it apart Reverting each fix in turn and rerunning the suite showed one that no test noticed: indexing external_masses by topology slot instead of by external leg. The shipped fixtures cannot see the difference, because their permutations only exchange identical gluons, so the two orderings agree. Add a topology whose permutations move the top onto a slot another permutation gives to a gluon. The two orderings then disagree about the mass of leg 2, which the kernel cannot represent, so the constructor has to refuse it rather than silently pick one - and with the slot-order bug put back the refusal does not happen and the test fails. Two other reverts also went unnoticed: removing dj_clus's NaN guard, and removing the "take the first candidate" guard. Those are deliberately redundant with the SCALE_MAX sanitisation, so dropping either on its own leaves the others covering it. Say so in the docstring of the test that covers the behaviour they exist for, rather than implying each guard is individually pinned. Co-Authored-By: Claude Opus 5 --- madspace/tests/test_mlm.py | 52 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 63abc7de3d..a87b5a1d1f 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -930,10 +930,53 @@ def test_terminal_states_select_known_diagrams(machine): assert all(index >= 0 for index in diagrams) +def massive_leg_permutation_diagram(): + """A topology whose permutations move a massive leg onto a slot that a + different permutation gives to a massless one. + + The shipped fixtures cannot exercise the slot-versus-leg question at all: + their permutations only exchange identical gluons, so the two orderings + agree and indexing by the wrong one is invisible. Here leg 2 is the top + under the first permutation and a gluon under the second. + """ + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [M_TOP, 0.0, 0.0], + "propagators": [[0.0, 0.0], [0.0, 0.0]], + "vertices": [ + ["o0", "o1", "p0"], + ["i0", "o2", "p1"], + ["p1", "p0", "i1"], + ], + "permutations": [[0, 1, 2, 3, 4], [0, 1, 3, 2, 4]], + } + ] + + +def test_permutations_moving_a_mass_between_legs_are_rejected(): + """external_masses is indexed by external leg, while a topology's masses + are indexed by topology slot, so the permutation has to be applied. The + kernel keeps a single mass per leg, so permutations that disagree about a + leg's mass cannot be represented at all and have to be refused rather than + silently given one of the two answers. + """ + with pytest.raises(Exception) as caught: + make_clustering( + massive_leg_permutation_diagram(), external_pdg_ids=[21, 21, 6, 21, 21] + ) + assert "mass" in str(caught.value).lower() + + def test_external_masses_are_in_leg_order(process): """The kernel indexes external_masses by external leg while a topology's masses are indexed by topology slot, so the permutation has to be applied. - For these processes legs 2 and 3 are the tops.""" + For these processes legs 2 and 3 are the tops. + + On its own this does not pin the ordering down: these fixtures permute only + identical gluons, so slot and leg order coincide. The case that tells them + apart is test_permutations_moving_a_mass_between_legs_are_rejected. + """ _, diagrams, pdg_ids = process clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) masses = list(clustering.external_masses) @@ -957,7 +1000,12 @@ def test_extreme_kinematics_do_not_break_the_walk(process): machine. Strongly asymmetric beam energies reach that configuration, so this is a - regression test for the crash it used to cause. + regression test for the crash it used to cause. Three guards stand between + that crash and this assertion - dj_clus zeroing a NaN, the selection + mapping a non-finite measure onto SCALE_MAX, and taking the first candidate + unconditionally - and they are deliberately redundant, so dropping any one + of them leaves the others covering it. What is pinned here is the behaviour + they exist for, not each guard individually. """ _, diagrams, pdg_ids = process clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) From 4ea4e79a6b04166c2c9574ba446232c9c3846ba8 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sun, 6 Sep 2026 17:24:55 +0200 Subject: [PATCH 19/81] Add phasespace.xqcut, the generation-level merging cut MLM merging needs the matrix element to stop describing radiation the parton shower is meant to produce. madevent enforces that with xqcut; madspace had no equivalent, so its samples reached down to whatever the jet cuts allowed and Pythia's matching veto had to throw the difference away. The rule is madevent's, from the "Check xqcut for vertices with jet daughters only" block of Template/LO/SubProcesses/reweight.f: walking the clustering history that was just selected, any step whose daughter is still a bare final-state jet has to be at or above xqcut, or the event is dropped. The test does not ask whether the vertex was a QCD one, and it reuses the same "still a bare external leg" bookkeeping the emission scheme already maintains, so it costs one branch in the loop that is already there. The clustering only runs for events that already passed the phase-space cuts, so the veto comes back as a weight - 1 or 0 - that multiplies the event weight rather than as one of the cuts. An event below xqcut ends up at weight zero and is never unweighted. It is a new trailing output of the two mlm_clustering instructions, which leaves every existing positional index alone. p p > t t~ plus t t~ j at 13 TeV, everything else held fixed: 1462 +- 20 pb with no cut, 1181 +- 17 at 30 GeV, 815 +- 13 at 60 GeV. Off by default, so turning it on is the only thing that can change an existing result. Co-Authored-By: Claude Opus 5 --- docs/mlm-merging-status.md | 30 +++++ madgraph/iolibs/template_files/mg7/launch.py | 1 + .../iolibs/template_files/mg7/run_card.toml | 4 + madgraph/various/banner.py | 5 + .../madspace/phasespace/mlm_clustering.hpp | 5 + madspace/instruction_set.yaml | 12 ++ madspace/src/kernels/mlm.hpp | 33 ++++- madspace/src/phasespace/integrand.cpp | 8 ++ madspace/src/phasespace/mlm_clustering.cpp | 13 +- madspace/src/python/madspace.cpp | 2 + madspace/tests/test_mlm.py | 123 +++++++++++++++++- 11 files changed, 229 insertions(+), 7 deletions(-) diff --git a/docs/mlm-merging-status.md b/docs/mlm-merging-status.md index 69308e1759..b5f7edf716 100644 --- a/docs/mlm-merging-status.md +++ b/docs/mlm-merging-status.md @@ -141,6 +141,36 @@ comparison against the validated madevent MLM disagree. * **No Sudakov reweighting**, which is the other half of what the NLO code does. +## The generation-level merging cut: `phasespace.xqcut` + +MLM merging needs the matrix element to stop describing radiation the shower is +meant to produce, which madevent enforces with `xqcut`. madspace had no +equivalent, so its samples reached down to whatever the jet cuts allowed and +Pythia's matching veto had to throw the difference away. + +The rule is madevent's, from the "Check xqcut for vertices with jet daughters +only" block of `Template/LO/SubProcesses/reweight.f`: walking the clustering +history that was just selected, any step whose daughter is still a bare +final-state jet has to be at or above `xqcut`, or the event is dropped. The +test does not ask whether the vertex was a QCD one, and it reuses the same +"still a bare external leg" bookkeeping the emission scheme already needs. + +The clustering only runs for events that already passed the phase-space cuts, +so the veto comes back as a weight (1 or 0) that multiplies the event weight, +rather than as one of the cuts. An event below `xqcut` ends up at weight zero +and is never unweighted. + +`p p > t t~` + `t t~ j` at 13 TeV, everything else held fixed: + +| `xqcut` | cross section (pb) | +|--------:|-------------------:| +| 0 | 1462 ± 20 | +| 30 | 1181 ± 17 | +| 60 | 815 ± 13 | + +It is off by default (`xqcut = 0`), so turning it on is the only thing that can +change an existing result. + ## Validating the merging Differential jet rates reconstructed from a showered event record are what say diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index 5242330187..a79ce68fe8 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -1367,6 +1367,7 @@ def __init__( int(key): value for key, value in self.meta["pdg_color_types"].items() }, + xqcut=self.process.run_card["phasespace"]["xqcut"], bw_cutoff=self.process.run_card["phasespace"]["bw_cutoff"], jet_radius=self.process.run_card["beam"]["jet_radius"], hadronic=not self.process.leptonic, diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index 9928ac1053..53b5b1473b 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -85,6 +85,10 @@ combine_channel_threshold = %(phasespace.combine_channel_threshold)s drop_qcd_s_channel = %(phasespace.drop_qcd_s_channel)s # -1 keeps all channels, otherwise drop channels without a QCD s-channel resonance once the channel count would exceed this invariant_power = %(phasespace.invariant_power)s bw_cutoff = %(phasespace.bw_cutoff)s +# minimum kt separation required of a matrix-element jet emission, the +# counterpart of xqcut in the legacy run_card (0 disables it; only used +# with dynamical_scale_choice = "mlm") +xqcut = %(phasespace.xqcut)s adaptive_symmetry_sampling = %(phasespace.adaptive_symmetry_sampling)s [multiparticles] diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index 78d50c7893..85b3423189 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6593,6 +6593,11 @@ def default_setup(self): "(-1 keeps all channels)") self.add_toml_param('phasespace', 'invariant_power', 0.7) self.add_toml_param('phasespace', 'bw_cutoff', 15) + # Generation-level merging cut, the counterpart of xqcut in the legacy + # run_card: a matrix-element jet emission below this scale is left to + # the parton shower, so the event is dropped. Only used with + # dynamical_scale_choice = "mlm"; 0 disables it. + self.add_toml_param('phasespace', 'xqcut', 0.0) self.add_toml_param('phasespace', 'adaptive_symmetry_sampling', True) # ----------------------------- [madnis] ----------------------- diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp index 6a947dd53b..4d75f368fd 100644 --- a/madspace/include/madspace/phasespace/mlm_clustering.hpp +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -39,6 +39,10 @@ class MLMClustering : public FunctionGenerator { // clustering; falls back to the Standard Model assignment for a pdg id // that is not listed. std::unordered_map pdg_color_types = {}, + // Generation-level merging cut, the counterpart of madevent's xqcut. + // A clustering that emitted a jet below this scale drops the event. + // Zero disables it. + double xqcut = 0, double bw_cutoff = 15, double jet_radius = 0.4, bool hadronic = true, @@ -68,6 +72,7 @@ class MLMClustering : public FunctionGenerator { std::vector _bw_widths; double _cm_energy; JetScaleScheme _jet_scale_scheme; + double _xqcut; double _bw_cutoff; double _jet_radius; bool _hadronic; diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index 27d6318f46..6741ba6ec3 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -2223,6 +2223,9 @@ mlm_clustering_hadronic: - name: jet_scale_scheme type: [int, single] desc: + - name: xqcut + type: [float, single] + desc: outputs: - name: ren_scale type: [float] @@ -2239,6 +2242,9 @@ mlm_clustering_hadronic: - name: diagram_index type: [int] desc: + - name: xqcut_weight + type: [float] + desc: vectorized: False mlm_clustering_leptonic: @@ -2273,6 +2279,9 @@ mlm_clustering_leptonic: - name: jet_scale_scheme type: [int, single] desc: + - name: xqcut + type: [float, single] + desc: outputs: - name: ren_scale type: [float] @@ -2289,6 +2298,9 @@ mlm_clustering_leptonic: - name: diagram_index type: [int] desc: + - name: xqcut_weight + type: [float] + desc: vectorized: False --- diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 6430cf1fda..c8f0b4682a 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -211,11 +211,13 @@ KERNELSPEC void mlm_clustering( FIn jet_radius, FIn cm_energy, IIn jet_scale_scheme, + FIn xqcut, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, FOut outgoing_scales, IOut diagram_index, + FOut xqcut_weight, bool hadronic ) { // we do not support SIMD for now, so we can assume simple types @@ -363,6 +365,17 @@ KERNELSPEC void mlm_clustering( bool by_production = jet_scale_scheme == SCHEME_PRODUCTION; FVal ren_scale_val = 1.0; + // The generation-level merging cut. A clustering that emitted a jet has to + // be at or above xqcut, otherwise the matrix element is describing + // radiation the parton shower is meant to produce and the event is + // dropped. This mirrors the "Check xqcut for vertices with jet daughters + // only" block of Template/LO/SubProcesses/reweight.f: the test runs per + // clustering step, on each daughter that is still a bare final-state jet, + // and it does not ask whether the vertex was a QCD one. + // + // is_last_cluster is exactly the "still a bare external leg" bookkeeping + // the emission scheme already needs, so the two share it. + bool passes_xqcut = true; int is_last_cluster = 0b11111111'11111111'11111100; for (int i = 0; i < cluster_max; ++i) { FVal scale = cluster_scales[i]; @@ -372,6 +385,11 @@ KERNELSPEC void mlm_clustering( bool is_qcd = (data >> 27) & 1; bool is_jet1 = (data >> 28) & 1; bool is_jet2 = (data >> 29) & 1; + if (FVal(xqcut) > 0.0 && scale < FVal(xqcut) && + ((is_jet1 && (is_last_cluster & (1 << particle1))) || + (is_jet2 && (is_last_cluster & (1 << particle2))))) { + passes_xqcut = false; + } if (is_qcd) { if (by_production) { // Book this vertex onto every external leg the two daughters' @@ -464,6 +482,9 @@ KERNELSPEC void mlm_clustering( ren_scale = ren_scale_val; fact_scale1 = fac_scale; fact_scale2 = fac_scale; + // A weight rather than a flag, so that it can simply multiply the event + // weight the way every other cut in madspace does. + xqcut_weight = passes_xqcut ? 1.0 : 0.0; int diag_count = state_machine[state]; int rand_index = static_cast(FVal(random) * diag_count); @@ -485,11 +506,13 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( FIn jet_radius, FIn cm_energy, IIn jet_scale_scheme, + FIn xqcut, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, FOut outgoing_scales, - IOut diagram_index + IOut diagram_index, + FOut xqcut_weight ) { mlm_clustering( momenta, @@ -502,11 +525,13 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( jet_radius, cm_energy, jet_scale_scheme, + xqcut, ren_scale, fact_scale1, fact_scale2, outgoing_scales, diagram_index, + xqcut_weight, true ); } @@ -523,11 +548,13 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( FIn jet_radius, FIn cm_energy, IIn jet_scale_scheme, + FIn xqcut, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, FOut outgoing_scales, - IOut diagram_index + IOut diagram_index, + FOut xqcut_weight ) { mlm_clustering( momenta, @@ -540,11 +567,13 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( jet_radius, cm_energy, jet_scale_scheme, + xqcut, ren_scale, fact_scale1, fact_scale2, outgoing_scales, diagram_index, + xqcut_weight, false ); } diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index beb306fd5b..2341d0903a 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -570,6 +570,14 @@ NamedVector Integrand::build_channel_part( weights_after_cuts.push_back(mirror_det); } + if (_energy_scale && _energy_scale->is_mlm()) { + // The merging cut comes out of the clustering, which only runs for + // events that already passed the phase-space cuts, so it enters as a + // factor on the weight rather than as one of the cuts themselves. An + // event below xqcut ends up with weight zero and is never unweighted. + weights_after_cuts.push_back(scales.at("xqcut_weight")); + } + Value weight_after_cuts = weights_after_cuts.empty() ? fb.full({1., batch_size_acc}) : fb.product(weights_after_cuts); diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index 58efc4604e..f412f392e1 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -363,6 +363,7 @@ MLMClustering::MLMClustering( double cm_energy, JetScaleScheme jet_scale_scheme, std::unordered_map pdg_color_types, + double xqcut, double bw_cutoff, double jet_radius, bool hadronic, @@ -378,10 +379,12 @@ MLMClustering::MLMClustering( {"fact_scale2", batch_float}, {"outgoing_scales", batch_float_array(topologies.at(0).outgoing_masses().size())}, - {"diagram_index", batch_int}} + {"diagram_index", batch_int}, + {"xqcut_weight", batch_float}} ), _cm_energy(cm_energy), _jet_scale_scheme(jet_scale_scheme), + _xqcut(xqcut), _bw_cutoff(bw_cutoff), _jet_radius(jet_radius), _hadronic(hadronic) { @@ -605,7 +608,7 @@ MLMClustering::MLMClustering( NamedVector MLMClustering::build_function_impl( FunctionBuilder& fb, const NamedVector& args ) const { - std::array mlm_out; + std::array mlm_out; Value random = fb.squeeze(fb.random(fb.batch_size(args.values()), 1)); if (_hadronic) { mlm_out = fb.mlm_clustering_hadronic( @@ -618,7 +621,8 @@ NamedVector MLMClustering::build_function_impl( _bw_cutoff, _jet_radius, _cm_energy, - static_cast(_jet_scale_scheme) + static_cast(_jet_scale_scheme), + _xqcut ); } else { mlm_out = fb.mlm_clustering_leptonic( @@ -631,7 +635,8 @@ NamedVector MLMClustering::build_function_impl( _bw_cutoff, _jet_radius, _cm_energy, - static_cast(_jet_scale_scheme) + static_cast(_jet_scale_scheme), + _xqcut ); } return {return_types().keys(), {mlm_out.begin(), mlm_out.end()}}; diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index 740b4db9fa..9d619ce014 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -995,6 +995,7 @@ PYBIND11_MODULE(_madspace_py, m) { std::unordered_map, double, double, + double, bool, std::vector, int>(), @@ -1004,6 +1005,7 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("cm_energy"), py::arg("jet_scale_scheme") = JetScaleScheme::production, py::arg("pdg_color_types") = std::unordered_map{}, + py::arg("xqcut") = 0., py::arg("bw_cutoff") = 15., py::arg("jet_radius") = 0.4, py::arg("hadronic") = true, diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index a87b5a1d1f..6074e9ac34 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -113,7 +113,13 @@ def process(request): def run(clustering, momenta): - """Call a clustering and return the five outputs as numpy arrays.""" + """The scale outputs of a clustering, as numpy arrays: ren_scale, + fact_scale1, fact_scale2, outgoing_scales, diagram_index.""" + return run_all(clustering, momenta)[:5] + + +def run_all(clustering, momenta): + """As run(), plus the trailing xqcut_weight.""" out = clustering(momenta) return tuple(np.asarray(v) for v in out) @@ -1190,3 +1196,118 @@ def test_production_is_the_default(): ) momenta = sample_momenta(diagrams, batch_size=64, seed=11) assert np.array_equal(run(default, momenta)[3], run(explicit, momenta)[3]) + + +# -------------------------------------------------------------------------- +# the generation-level merging cut +# -------------------------------------------------------------------------- + + +def xqcut_weights(diagrams, pdg_ids, momenta, xqcut, **kwargs): + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, xqcut=xqcut, **kwargs + ) + return run_all(clustering, momenta)[5] + + +def test_xqcut_is_off_by_default(process): + """Without a merging cut every event keeps its weight, so turning the + feature on is the only thing that can change an existing result.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + weights = run_all( + make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta + )[5] + assert np.all(weights == 1.0) + + +def test_xqcut_weight_is_a_veto(process): + """It multiplies the event weight, so it has to be exactly zero or one.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + weights = xqcut_weights(diagrams, pdg_ids, momenta, 50.0) + assert np.all((weights == 0.0) | (weights == 1.0)) + assert np.any(weights == 0.0), "a 50 GeV cut should reject something" + + +def test_xqcut_rejects_monotonically(process): + """Raising the cut can only ever reject more events.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams, batch_size=1000, seed=808) + kept = [ + float(xqcut_weights(diagrams, pdg_ids, momenta, cut).sum()) + for cut in (0.0, 10.0, 30.0, 60.0, 120.0) + ] + assert kept == sorted(kept, reverse=True), kept + assert kept[0] == len(momenta) + assert kept[-1] < kept[0] + + +def test_xqcut_rejects_exactly_the_soft_jet_emissions(process): + """madevent's rule: a clustering step whose daughter is still a bare + final-state jet has to be at or above xqcut. In the "emission" scheme the + per-jet scales are booked at exactly those steps, so the veto can be + recomputed from them and compared.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams, batch_size=500, seed=99) + cut = 40.0 + clustering = make_clustering( + diagrams, + external_pdg_ids=pdg_ids, + xqcut=cut, + jet_scale_scheme=EMISSION, + ) + *_, jet_scales, _, weights = run_all(clustering, momenta) + # a leg left at the sqrt(s) fallback was never booked and cannot fail + booked = jet_scales < CM_ENERGY + softest = np.where(booked, jet_scales, np.inf).min(axis=1) + expected = np.where(np.isfinite(softest) & (softest < cut), 0.0, 1.0) + assert np.array_equal(weights, expected) + + +def test_xqcut_does_not_disturb_the_scales(process): + """The cut only vetoes. Events that survive keep exactly the scales the + clustering would have given without it.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + ren_off, fac_off, _, out_off, _ = run( + make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta + ) + ren_on, fac_on, _, out_on, _, weights = run_all( + make_clustering(diagrams, external_pdg_ids=pdg_ids, xqcut=40.0), momenta + ) + keep = weights > 0 + assert keep.any() + assert np.array_equal(ren_off[keep], ren_on[keep]) + assert np.array_equal(fac_off[keep], fac_on[keep]) + assert np.array_equal(out_off[keep], out_on[keep]) + + +def test_xqcut_survivors_have_no_soft_jet_scale(process): + """Every booked jet scale of a surviving event is at or above the cut, + which is what makes the merging scale mean anything downstream.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams, batch_size=1000, seed=4242) + cut = 35.0 + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, xqcut=cut, jet_scale_scheme=EMISSION + ) + *_, jet_scales, _, weights = run_all(clustering, momenta) + booked = jet_scales[weights > 0] + booked = booked[booked < CM_ENERGY] + assert len(booked) > 0 + assert np.all(booked >= cut) + + +def test_xqcut_does_not_depend_on_the_jet_scale_scheme(process): + """The veto is defined on the clustering history, which the scheme switch + does not touch: it only moves which leg a scale is booked against.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams, batch_size=500, seed=17) + emission = xqcut_weights( + diagrams, pdg_ids, momenta, 40.0, jet_scale_scheme=EMISSION + ) + production = xqcut_weights( + diagrams, pdg_ids, momenta, 40.0, jet_scale_scheme=PRODUCTION + ) + assert np.array_equal(emission, production) From 68d7764d112720a15fcac5175cf536d8ca5e3447 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Mon, 7 Sep 2026 22:47:43 +0200 Subject: [PATCH 20/81] Write mu_R and mu_F into the LHE tag SCALUP holds one number and the two codes disagree about which: unwgt.f writes sqrt(max(q2fact)), i.e. mu_F, while madspace writes ren_scale. Comparing that field between them is apples to oranges, which is how the earlier claim that the mu_R definition explains the high-multiplicity cross-section difference came to rest on nothing. Emit both explicitly as the standard LHEF3 attributes - mur, muf, and muf1/muf2 since madspace can carry a different factorisation scale per beam - so a comparison does not have to guess. Nothing that was written before changes. Co-Authored-By: Claude Opus 5 --- madspace/include/madspace/driver/lhe_output.hpp | 9 +++++++++ madspace/src/driver/event_generator.cpp | 11 +++++++++++ madspace/src/driver/lhe_output.cpp | 16 +++++++++++++++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/madspace/include/madspace/driver/lhe_output.hpp b/madspace/include/madspace/driver/lhe_output.hpp index 327d9291ca..618c7ac4de 100644 --- a/madspace/include/madspace/driver/lhe_output.hpp +++ b/madspace/include/madspace/driver/lhe_output.hpp @@ -62,6 +62,15 @@ struct LHEEvent { double alpha_qcd; std::vector particles; bool has_cluster_scales; + // The renormalisation and factorisation scales, written as the standard + // LHEF3 attributes. SCALUP carries only one number, and the two + // codes do not agree on which: unwgt.f writes sqrt(max(q2fact)) there, + // i.e. mu_F, while this writer puts mu_R. Emitting both explicitly means a + // comparison does not have to guess. + double mu_r; + double mu_f1; + double mu_f2; + bool has_scales; void format_to(std::string& buffer) const; }; diff --git a/madspace/src/driver/event_generator.cpp b/madspace/src/driver/event_generator.cpp index f90fd6aeca..889dec6188 100644 --- a/madspace/src/driver/event_generator.cpp +++ b/madspace/src/driver/event_generator.cpp @@ -745,6 +745,17 @@ void EventGenerator::fill_lhe_event( lhe_event.scale = event_in.ren_scale(); lhe_event.alpha_qed = 0; // TODO: populate this lhe_event.alpha_qcd = event_in.alpha_qcd(); + // Both scales, as LHEF3 attributes: SCALUP holds only one number + // and the codes do not agree on which, so write them out explicitly. + int scale_flags = _channels.at(0)->event_layout_extra_flags(); + lhe_event.mu_r = event_in.ren_scale(); + lhe_event.mu_f1 = (scale_flags & EventRecord::f_beam1) + ? double(event_in.fact_scale1()) + : double(event_in.ren_scale()); + lhe_event.mu_f2 = (scale_flags & EventRecord::f_beam2) + ? double(event_in.fact_scale2()) + : lhe_event.mu_f1; + lhe_event.has_scales = true; lhe_event.particles.clear(); for (std::size_t i = 0; i < buffer.particle_count(); ++i) { auto particle_in = buffer.particle(event_index, i); diff --git a/madspace/src/driver/lhe_output.cpp b/madspace/src/driver/lhe_output.cpp index e92a3fa334..cc669f6008 100644 --- a/madspace/src/driver/lhe_output.cpp +++ b/madspace/src/driver/lhe_output.cpp @@ -138,8 +138,20 @@ void LHEEvent::format_to(std::string& buffer) const { particle.spin ); } - if (has_cluster_scales) { + if (has_cluster_scales || has_scales) { std::format_to(insert_iter, "\n"); } buffer += "\n"; From 9665417d1914347cb516471ad4ff261b8830aecf Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sun, 6 Sep 2026 23:48:18 +0200 Subject: [PATCH 21/81] Add beam.scale_scheme with madevent's mu_R / mu_F definition NOT VALIDATED - see below. Committed so the machinery is not lost, but this should not be merged as it stands. madevent reads its scales off the clustering history by following each beam's parton line and taking mu_R = (s[jlast1] s[jcentral1] s[jlast2] s[jcentral2])^(1/4) mu_F,b = sqrt(s[jlast_b] s[jcentral_b]) where jlast is the last initial-state clustering while that beam's line is still a jet and jcentral the last one while it is still coloured. madspace instead takes the geometric mean of every clustering scale and a single factorisation scale. beam.scale_scheme selects between them, defaulting to the existing behaviour because unlike jet_scale_scheme this one moves the cross section. The walk needs two things the clustering already had after the tracing work: beam j's line always sits in slot j, because a clustering keeps the lower of the two indices, so the slot index is madevent's ibeam(j) for free; and the third state-machine word had spare bits for the mother's flavour, which is what says where a line stops being a jet and where it stops being coloured. Why it is not validated: the check was going to be the mean scale per jet multiplicity, measured from the LHE. That comparison is not meaningful as it stands, because the two write different quantities into SCALUP - unwgt.f writes sqrt(max(q2fact)) there, i.e. mu_F, while madspace writes ren_scale. Comparing them was apples to oranges, so neither the earlier claim that the mu_R definition explains the high-multiplicity cross-section difference, nor this implementation, rests on evidence yet. Co-Authored-By: Claude Opus 5 --- madgraph/iolibs/template_files/mg7/launch.py | 4 + .../iolibs/template_files/mg7/run_card.toml | 4 + madgraph/various/banner.py | 10 ++ madspace/generate_pyi.py | 1 + .../madspace/phasespace/mlm_clustering.hpp | 22 +++ madspace/instruction_set.yaml | 12 ++ madspace/src/kernels/mlm.hpp | 132 +++++++++++++++++- madspace/src/phasespace/mlm_clustering.cpp | 38 ++++- madspace/src/python/madspace.cpp | 10 ++ madspace/tests/test_mlm.py | 16 ++- 10 files changed, 238 insertions(+), 11 deletions(-) diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index a79ce68fe8..9d0d45ccbb 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -1363,6 +1363,10 @@ def __init__( ms.MLMClustering.JetScaleScheme, self.process.run_card["beam"]["jet_scale_scheme"], ), + scale_scheme=getattr( + ms.MLMClustering.ScaleScheme, + self.process.run_card["beam"]["scale_scheme"], + ), pdg_color_types={ int(key): value for key, value in self.meta["pdg_color_types"].items() diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index 53b5b1473b..6b04e40792 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -43,6 +43,10 @@ max_jet_flavor = %(beam.max_jet_flavor)s # options: production (the scale at which the parton's line was produced, as in # madevent), emission (the scale at which the leg itself was emitted) jet_scale_scheme = %(beam.jet_scale_scheme)s +# how mu_R and mu_F are read off the clustering history +# options: clustering_mean (geometric mean of every clustering scale), +# madevent (follow each beam's parton line, as madevent does) +scale_scheme = %(beam.scale_scheme)s [generation] events = %(generation.events)s diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index 85b3423189..a4ef0527c9 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6534,6 +6534,16 @@ def default_setup(self): # the softest clustering it takes part in. self.add_toml_param('beam', 'jet_scale_scheme', "production", allowed=['production', 'emission']) + # How mu_R and mu_F are read off the clustering history: + # "clustering_mean": geometric mean of every clustering scale, with a + # single factorisation scale. + # "madevent": follow each beam's parton line and use + # (s_jlast1 s_jcentral1 s_jlast2 s_jcentral2)^(1/4), + # with a separate factorisation scale per beam. + # Left at "clustering_mean" by default because it moves the cross + # section, unlike jet_scale_scheme. + self.add_toml_param('beam', 'scale_scheme', "clustering_mean", + allowed=['clustering_mean', 'madevent']) # -------------------------- [generation] ---------------------- self.add_toml_param('generation', 'events', 100000, gridpack=True) diff --git a/madspace/generate_pyi.py b/madspace/generate_pyi.py index d8f0c80cc5..5ae9ddc6dd 100644 --- a/madspace/generate_pyi.py +++ b/madspace/generate_pyi.py @@ -58,6 +58,7 @@ def main(): "--enum-class-locations=CutMode:Cuts.CutMode", "--enum-class-locations=LRSchedule:AdamOptimizer.LRSchedule", "--enum-class-locations=JetScaleScheme:MLMClustering.JetScaleScheme", + "--enum-class-locations=ScaleScheme:MLMClustering.ScaleScheme", # Fail the build on unresolvable names/expressions (e.g. an enum # default value needing its own --enum-class-locations entry) # instead of silently emitting a stub with holes in it. diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp index 4d75f368fd..722b154f2c 100644 --- a/madspace/include/madspace/phasespace/mlm_clustering.hpp +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -23,6 +23,23 @@ enum class JetScaleScheme { production = 1, }; +// How the renormalisation and factorisation scales are read off the clustering +// history once it has been chosen. +enum class ScaleScheme { + // Geometric mean of every clustering scale, with the non-QCD ones replaced + // by the largest, and a single factorisation scale taken as the smallest + // QCD clustering scale capped at mu_R. + clustering_mean = 0, + // What madevent does: follow each beam's parton line through the + // clustering and take + // mu_R = (s[jlast1] s[jcentral1] s[jlast2] s[jcentral2])^(1/4) + // mu_F,b = sqrt(s[jlast_b] s[jcentral_b]) + // where jlast is the last initial-state clustering while the beam line is + // still a jet, and jcentral the last one while it is still coloured. The + // two beams get different factorisation scales. + madevent = 1, +}; + class MLMClustering : public FunctionGenerator { public: MLMClustering( @@ -34,6 +51,9 @@ class MLMClustering : public FunctionGenerator { // MLM veto can never trip on it. double cm_energy, JetScaleScheme jet_scale_scheme = JetScaleScheme::production, + // Left at the existing definition by default: unlike + // jet_scale_scheme, this one moves the cross section. + ScaleScheme scale_scheme = ScaleScheme::clustering_mean, // Signed color representation per pdg id, as exported in the // subprocess metadata. Used to follow a parton line through the // clustering; falls back to the Standard Model assignment for a pdg id @@ -72,6 +92,8 @@ class MLMClustering : public FunctionGenerator { std::vector _bw_widths; double _cm_energy; JetScaleScheme _jet_scale_scheme; + ScaleScheme _scale_scheme; + int _beam_flags; double _xqcut; double _bw_cutoff; double _jet_radius; diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index 6741ba6ec3..03668ceef3 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -2226,6 +2226,12 @@ mlm_clustering_hadronic: - name: xqcut type: [float, single] desc: + - name: scale_scheme + type: [int, single] + desc: + - name: beam_flags + type: [int, single] + desc: outputs: - name: ren_scale type: [float] @@ -2282,6 +2288,12 @@ mlm_clustering_leptonic: - name: xqcut type: [float, single] desc: + - name: scale_scheme + type: [int, single] + desc: + - name: beam_flags + type: [int, single] + desc: outputs: - name: ren_scale type: [float] diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index c8f0b4682a..27b809928b 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -24,6 +24,12 @@ constexpr int TRACE_BOTH = 3; // JetScaleScheme in mlm_clustering.hpp. constexpr int SCHEME_EMISSION = 0; constexpr int SCHEME_PRODUCTION = 1; +// ScaleScheme in mlm_clustering.hpp. +constexpr int SCALES_CLUSTERING_MEAN = 0; +constexpr int SCALES_MADEVENT = 1; +// trace_data bits above the 2-bit trace mode: the flavour of the mother. +constexpr int TRACE_IS_JET_IN = 1 << 2; +constexpr int TRACE_IS_COLORED_IN = 1 << 3; // mT^2 = E^2 - pz^2 (hadronic) or E^2 (lepton collider). // based on djb_clus from Template/NLO/SubProcesses/cluster.f @@ -212,6 +218,8 @@ KERNELSPEC void mlm_clustering( FIn cm_energy, IIn jet_scale_scheme, FIn xqcut, + IIn scale_scheme, + IIn beam_flags, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -307,7 +315,7 @@ KERNELSPEC void mlm_clustering( win_next_state = next_state; win_scale = scale; win_data = data; - win_trace = trace_data & 0x3; + win_trace = trace_data; win_resonant = resonant; } if (is_last) { @@ -426,7 +434,7 @@ KERNELSPEC void mlm_clustering( // Carry the parton line into the mother, which occupies slot // particle1. Only needed for the production scheme, but keeping it // unconditional costs nothing and keeps the two branches comparable. - int trace = cluster_trace[i]; + int trace = cluster_trace[i] & 0x3; int a1 = rep1[particle1], a2 = rep2[particle1]; int b1 = rep1[particle2], b2 = rep2[particle2]; if (trace == TRACE_HARDER || trace == TRACE_BOTH) { @@ -478,10 +486,118 @@ KERNELSPEC void mlm_clustering( if (fac_scale > ren_scale_val) { fac_scale = ren_scale_val; } + FVal fac_scale1 = fac_scale, fac_scale2 = fac_scale; + + if (scale_scheme == SCALES_MADEVENT) { + // Follow each beam's parton line through the clustering, exactly as + // setclscales does in Template/LO/SubProcesses/reweight.f. + // + // jfirst the first initial-state clustering on this beam + // jlast the last one while the line is still a jet + // jcentral the last one while the line is still coloured, which + // carries on past jlast through e.g. a W emission + // + // Beam j's line always occupies slot j here, because a clustering + // keeps the lower of the two indices, so the slot index is madevent's + // ibeam(j) without any extra bookkeeping. + int jfirst[2] = {-1, -1}, jlast[2] = {-1, -1}, jcentral[2] = {-1, -1}; + bool partonline[2], qcdline[2]; + for (int j = 0; j < 2; ++j) { + qcdline[j] = (beam_flags >> (2 * j)) & 1; + partonline[j] = qcdline[j]; + } + for (int i = 0; i < cluster_max; ++i) { + int data = cluster_history[i]; + int j = data & 0xFF; + if (j >= 2) { + continue; // final-state clustering, no beam line involved + } + bool is_jet2 = (data >> 29) & 1; + int trace = cluster_trace[i]; + bool is_jet_in = trace & TRACE_IS_JET_IN; + bool is_colored_in = trace & TRACE_IS_COLORED_IN; + + if (partonline[j]) { + if (jfirst[j] < 0) { + jfirst[j] = i; + } + jlast[j] = i; + partonline[j] = is_jet2 && is_jet_in; + } else if (jfirst[j] < 0) { + jfirst[j] = i; + } + if (qcdline[j]) { + jcentral[j] = i; + qcdline[j] = is_colored_in; + } + } + for (int j = 0; j < 2; ++j) { + if (jfirst[j] < 0) { + jfirst[j] = jlast[j]; + } + } + + // "Ensure that last scales are at least as big as first scales" + FVal s_last[2]; + for (int j = 0; j < 2; ++j) { + s_last[j] = jlast[j] < 0 ? 0.0 : cluster_scales[jlast[j]]; + if (jlast[j] >= 0 && jfirst[j] >= 0 && + cluster_scales[jfirst[j]] > s_last[j]) { + s_last[j] = cluster_scales[jfirst[j]]; + } + } + FVal s_central[2]; + for (int j = 0; j < 2; ++j) { + s_central[j] = jcentral[j] < 0 ? 0.0 : cluster_scales[jcentral[j]]; + } + + // mu_R: the geometric mean of the four scales. madevent writes it as + // (pt2 pt2 pt2 pt2)^(1/8) over squared scales, which is the same thing. + FVal last_scale = cluster_scales[cluster_max - 1]; + if (jlast[0] >= 0 && jlast[1] >= 0) { + ren_scale_val = pow( + s_last[0] * s_central[0] * s_last[1] * s_central[1], 0.25 + ); + } else if (jlast[0] >= 0) { + ren_scale_val = sqrt(s_last[0] * s_central[0]); + } else if (jlast[1] >= 0) { + ren_scale_val = sqrt(s_last[1] * s_central[1]); + } else if (jcentral[0] >= 0 && jcentral[1] >= 0) { + ren_scale_val = sqrt(s_central[0] * s_central[1]); + } else if (jcentral[0] >= 0) { + ren_scale_val = s_central[0]; + } else if (jcentral[1] >= 0) { + ren_scale_val = s_central[1]; + } else { + ren_scale_val = last_scale; + } + + // mu_F, one per beam. madevent stores q2fact = mu_F^2 as + // sqrt(pt2[jlast] pt2[jcentral]), i.e. mu_F is the geometric mean of + // the two scales. + fac_scale1 = jlast[0] >= 0 ? sqrt(s_last[0] * s_central[0]) + : ren_scale_val; + fac_scale2 = jlast[1] >= 0 ? sqrt(s_last[1] * s_central[1]) + : ren_scale_val; + // "We have a qcd line going through the whole event, use single scale" + if (jcentral[0] >= 0 && jcentral[0] == jcentral[1]) { + fac_scale1 = max(fac_scale1, fac_scale2); + fac_scale2 = fac_scale1; + } + if (!(ren_scale_val > 0.0)) { + ren_scale_val = last_scale; + } + if (!(fac_scale1 > 0.0)) { + fac_scale1 = ren_scale_val; + } + if (!(fac_scale2 > 0.0)) { + fac_scale2 = ren_scale_val; + } + } ren_scale = ren_scale_val; - fact_scale1 = fac_scale; - fact_scale2 = fac_scale; + fact_scale1 = fac_scale1; + fact_scale2 = fac_scale2; // A weight rather than a flag, so that it can simply multiply the event // weight the way every other cut in madspace does. xqcut_weight = passes_xqcut ? 1.0 : 0.0; @@ -507,6 +623,8 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( FIn cm_energy, IIn jet_scale_scheme, FIn xqcut, + IIn scale_scheme, + IIn beam_flags, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -526,6 +644,8 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( cm_energy, jet_scale_scheme, xqcut, + scale_scheme, + beam_flags, ren_scale, fact_scale1, fact_scale2, @@ -549,6 +669,8 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( FIn cm_energy, IIn jet_scale_scheme, FIn xqcut, + IIn scale_scheme, + IIn beam_flags, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -568,6 +690,8 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( cm_energy, jet_scale_scheme, xqcut, + scale_scheme, + beam_flags, ren_scale, fact_scale1, fact_scale2, diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index f412f392e1..40fa15ec66 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -138,6 +138,10 @@ struct StateItem { bool is_jet1; bool is_jet2; TraceMode trace_mode; + // Flavour of the *mother*. madevent's scale definition needs to know where + // a beam's parton line stops being a jet and where it stops being coloured. + bool is_jet_in; + bool is_colored_in; }; // 1-based index into the Breit-Wigner tables handed to the kernel, or 0 for @@ -230,6 +234,9 @@ StateItem make_state_item( .is_jet1 = is_jet1, .is_jet2 = is_jet2, .trace_mode = trace_mode, + .is_jet_in = !ctx.have_pdg_ids || + is_jet_pdg(meta_in.pdg_id, ctx.max_jet_flavor), + .is_colored_in = !ctx.have_pdg_ids || color_in != 1, }; } @@ -318,6 +325,8 @@ void find_clusterings( .is_jet1 = false, .is_jet2 = false, .trace_mode = trace_first, + .is_jet_in = false, + .is_colored_in = false, }); } } else { @@ -362,6 +371,7 @@ MLMClustering::MLMClustering( nested_vector2 diagram_indices, double cm_energy, JetScaleScheme jet_scale_scheme, + ScaleScheme scale_scheme, std::unordered_map pdg_color_types, double xqcut, double bw_cutoff, @@ -384,6 +394,8 @@ MLMClustering::MLMClustering( ), _cm_energy(cm_energy), _jet_scale_scheme(jet_scale_scheme), + _scale_scheme(scale_scheme), + _beam_flags(0), _xqcut(xqcut), _bw_cutoff(bw_cutoff), _jet_radius(jet_radius), @@ -403,6 +415,19 @@ MLMClustering::MLMClustering( )); } + // madevent seeds its walk with the flavour of each beam: qcdline from + // isqcd(beam) and partonline from the same. Bake those two bits per beam + // in, since they are a property of the subprocess. + for (int beam = 0; beam < 2; ++beam) { + int pdg = have_pdg_ids ? external_pdg_ids.at(beam) : 21; + if (color_rep(pdg, pdg_color_types) != 1) { + _beam_flags |= 1 << (2 * beam); + } + if (is_jet_pdg(pdg, max_jet_flavor)) { + _beam_flags |= 1 << (2 * beam + 1); + } + } + nested_vector2 valid_diags(1 << n_ext); std::vector mask_meta(1 << n_ext); std::vector particle_masks; @@ -599,7 +624,10 @@ MLMClustering::MLMClustering( ((&item == &state.back()) << 30) ); _cluster_state_machine.push_back(first_indices.at(item.next_state)); - _cluster_state_machine.push_back(static_cast(item.trace_mode)); + _cluster_state_machine.push_back( + static_cast(item.trace_mode) + (item.is_jet_in << 2) + + (item.is_colored_in << 3) + ); } } } @@ -622,7 +650,9 @@ NamedVector MLMClustering::build_function_impl( _jet_radius, _cm_energy, static_cast(_jet_scale_scheme), - _xqcut + _xqcut, + static_cast(_scale_scheme), + static_cast(_beam_flags) ); } else { mlm_out = fb.mlm_clustering_leptonic( @@ -636,7 +666,9 @@ NamedVector MLMClustering::build_function_impl( _jet_radius, _cm_energy, static_cast(_jet_scale_scheme), - _xqcut + _xqcut, + static_cast(_scale_scheme), + static_cast(_beam_flags) ); } return {return_types().keys(), {mlm_out.begin(), mlm_out.end()}}; diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index 9d619ce014..dfdc3d5be2 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -976,6 +976,14 @@ PYBIND11_MODULE(_madspace_py, m) { ); py::classh mlm(m, "MLMClustering"); + add_enum( + mlm, + "ScaleScheme", + { + {"clustering_mean", ScaleScheme::clustering_mean}, + {"madevent", ScaleScheme::madevent}, + } + ); add_enum( mlm, "JetScaleScheme", @@ -992,6 +1000,7 @@ PYBIND11_MODULE(_madspace_py, m) { nested_vector2, double, JetScaleScheme, + ScaleScheme, std::unordered_map, double, double, @@ -1004,6 +1013,7 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("diagram_indices"), py::arg("cm_energy"), py::arg("jet_scale_scheme") = JetScaleScheme::production, + py::arg("scale_scheme") = ScaleScheme::clustering_mean, py::arg("pdg_color_types") = std::unordered_map{}, py::arg("xqcut") = 0., py::arg("bw_cutoff") = 15., diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 6074e9ac34..8bedf9915c 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -720,9 +720,17 @@ def test_kt_measure_matches_the_fortran(): # Words per transition: (data, next_offset, trace_data). STATE_ITEM_SIZE = 3 -# trace_data values: which daughter the mother's parton line continues into. +# trace_data: the low two bits say which daughter the mother's parton line +# continues into, the next two carry the mother's own flavour. +TRACE_MODE_MASK = 0x3 TRACE_FIRST, TRACE_SECOND, TRACE_HARDER, TRACE_BOTH = 0, 1, 2, 3 TRACE_MODES = {TRACE_FIRST, TRACE_SECOND, TRACE_HARDER, TRACE_BOTH} +TRACE_IS_JET_IN = 1 << 2 +TRACE_IS_COLORED_IN = 1 << 3 + + +def trace_mode(trace): + return trace & TRACE_MODE_MASK def field(data, spec): @@ -866,15 +874,15 @@ def test_state_machine_trace_modes_are_valid(machine): non_terminal, _ = walk(flat, n_ext) for _, (_, transitions) in non_terminal.items(): for data, _, trace in transitions: - assert trace in TRACE_MODES + assert trace_mode(trace) in TRACE_MODES if field(data, BIT_PARTICLE1) < 2: - assert trace == TRACE_FIRST + assert trace_mode(trace) == TRACE_FIRST def trace_modes_of(clustering, n_ext): non_terminal, _ = walk(np.asarray(clustering.cluster_state_machine), n_ext) return { - (field(data, BIT_PARTICLE1), field(data, BIT_PARTICLE2)): trace + (field(data, BIT_PARTICLE1), field(data, BIT_PARTICLE2)): trace_mode(trace) for _, (_, transitions) in non_terminal.items() for data, _, trace in transitions } From c0c87c0718c13b159e905c795da566dc3f260538 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Mon, 7 Sep 2026 23:11:17 +0200 Subject: [PATCH 22/81] Add beam.min_scale, a floor on mu_R and mu_F for any scale choice Below the lowest Q of a PDF grid the densities are undefined, and a scale of a few MeV comes back as a NaN that no later cut can remove: the veto zeroes the weight, but the pdf has already been evaluated and NaN times zero is still NaN. So the floor both vetoes the event, through a new scale_weight output, and clamps the scales so that what is computed on the way stays finite. madevent applies the same floor to mu_F at 2 GeV, in reweight.f, as part of setclscales. Putting it in EnergyScale instead of in the MLM kernel keeps it out of the merging path and makes it apply to every dynamical scale choice, which is where it belongs. Verified directly: with min_scale = 2 on a Drell-Yan + 2 jet topology whose madevent-scheme mu_F reaches 0.15 GeV, fact_scale1 comes back floored at exactly 2 and scale_weight is 0 for the events below it. Also extends the madevent scale scheme to take the final 2 -> 1 clustering step. madevent's loop runs one step further than this kernel, and that step - which absorbs what is left of the final state into a beam - is where jcentral usually lands, which is why its factorisation scale comes out as the transverse mass of the lepton pair rather than a jet scale. Rather than restructuring the state machine, the step is taken in the kernel: after the walk exactly one final-state slot is still alive and the clustering is fixed by which beam it goes to. The madevent scheme is NOT working: it makes some channel of p p > e+ e- + 0,1,2,3 jets return a NaN weight, where clustering_mean on the same directory with the same floor integrates fine. Not diagnosed yet, so the scheme should not be used and this should not be merged as it stands. Co-Authored-By: Claude Opus 5 --- madgraph/iolibs/template_files/mg7/launch.py | 7 +- .../iolibs/template_files/mg7/run_card.toml | 3 + madgraph/various/banner.py | 5 + .../madspace/phasespace/mlm_clustering.hpp | 1 + .../include/madspace/phasespace/scale.hpp | 29 ++++- madspace/instruction_set.yaml | 6 + madspace/src/kernels/mlm.hpp | 120 +++++++++++++----- madspace/src/phasespace/integrand.cpp | 5 + madspace/src/phasespace/mlm_clustering.cpp | 14 +- madspace/src/phasespace/scale.cpp | 65 ++++++++-- madspace/src/python/madspace.cpp | 10 +- 11 files changed, 212 insertions(+), 53 deletions(-) diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index 9d0d45ccbb..64e98dc654 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -1377,11 +1377,14 @@ def __init__( hadronic=not self.process.leptonic, external_pdg_ids=all_pids, max_jet_flavor=self.process.run_card["beam"]["max_jet_flavor"], - ) + ), + min_scale=self.process.run_card["beam"]["min_scale"], ) else: self.scale = ms.EnergyScale( - particle_count=self.particle_count, **self.process.scale_kwargs + particle_count=self.particle_count, + min_scale=self.process.run_card["beam"]["min_scale"], + **self.process.scale_kwargs, ) if self.process.run_card["run"]["dummy_matrix_element"]: diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index 6b04e40792..1f529179c8 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -47,6 +47,9 @@ jet_scale_scheme = %(beam.jet_scale_scheme)s # options: clustering_mean (geometric mean of every clustering scale), # madevent (follow each beam's parton line, as madevent does) scale_scheme = %(beam.scale_scheme)s +# floor on mu_R and mu_F: an event whose scales fall below this is dropped, +# since a PDF grid has no densities there (0 disables it) +min_scale = %(beam.min_scale)s [generation] events = %(generation.events)s diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index a4ef0527c9..93801521a4 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6544,6 +6544,11 @@ def default_setup(self): # section, unlike jet_scale_scheme. self.add_toml_param('beam', 'scale_scheme', "clustering_mean", allowed=['clustering_mean', 'madevent']) + # Floor on mu_R and mu_F, whatever the dynamical scale choice. Below + # the lowest Q of a PDF grid the densities are undefined, so an event + # whose scales fall under this is dropped. madevent applies the same + # floor to mu_F. 0 disables it. + self.add_toml_param('beam', 'min_scale', 2.0) # -------------------------- [generation] ---------------------- self.add_toml_param('generation', 'events', 100000, gridpack=True) diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp index 722b154f2c..b5e0996366 100644 --- a/madspace/include/madspace/phasespace/mlm_clustering.hpp +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -94,6 +94,7 @@ class MLMClustering : public FunctionGenerator { JetScaleScheme _jet_scale_scheme; ScaleScheme _scale_scheme; int _beam_flags; + int _jet_leg_mask; double _xqcut; double _bw_cutoff; double _jet_radius; diff --git a/madspace/include/madspace/phasespace/scale.hpp b/madspace/include/madspace/phasespace/scale.hpp index f95cfbed38..6bdee92d2c 100644 --- a/madspace/include/madspace/phasespace/scale.hpp +++ b/madspace/include/madspace/phasespace/scale.hpp @@ -16,9 +16,13 @@ class EnergyScale : public FunctionGenerator { EnergyScale(std::size_t particle_count) : EnergyScale(particle_count, half_transverse_mass, false, false, 0., 0., 0.) {} - EnergyScale(std::size_t particle_count, DynamicalScaleType type) : - EnergyScale(particle_count, type, false, false, 0., 0., 0.) {} - EnergyScale(std::size_t particle_count, double fixed_scale) : + EnergyScale( + std::size_t particle_count, DynamicalScaleType type, double min_scale = 0. + ) : + EnergyScale(particle_count, type, false, false, 0., 0., 0., min_scale) {} + EnergyScale( + std::size_t particle_count, double fixed_scale, double min_scale = 0. + ) : EnergyScale( particle_count, half_transverse_mass, @@ -26,7 +30,8 @@ class EnergyScale : public FunctionGenerator { true, fixed_scale, fixed_scale, - fixed_scale + fixed_scale, + min_scale ) {} EnergyScale( std::size_t particle_count, @@ -35,13 +40,24 @@ class EnergyScale : public FunctionGenerator { bool fact_scale_fixed, double ren_scale, double fact_scale1, - double fact_scale2 + double fact_scale2, + // Floor on mu_R and mu_F. An event below it is vetoed through the + // scale_weight output and the scales are clamped, so that a pdf is + // never asked for a density below the bottom of its grid. Zero + // disables it. + double min_scale = 0. ); - EnergyScale(const MLMClustering& clustering); + EnergyScale(const MLMClustering& clustering, double min_scale = 0.); bool is_mlm() const { return _clustering.has_value(); } + bool has_min_scale() const { return _min_scale > 0.; } + private: + NamedVector apply_min_scale( + FunctionBuilder& fb, NamedVector scales + ) const; + NamedVector build_function_impl( FunctionBuilder& fb, const NamedVector& args ) const override; @@ -52,6 +68,7 @@ class EnergyScale : public FunctionGenerator { double _ren_scale; double _fact_scale1; double _fact_scale2; + double _min_scale; std::optional _clustering; }; diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index 03668ceef3..bc2a6cdf7f 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -2232,6 +2232,9 @@ mlm_clustering_hadronic: - name: beam_flags type: [int, single] desc: + - name: jet_leg_mask + type: [int, single] + desc: outputs: - name: ren_scale type: [float] @@ -2294,6 +2297,9 @@ mlm_clustering_leptonic: - name: beam_flags type: [int, single] desc: + - name: jet_leg_mask + type: [int, single] + desc: outputs: - name: ren_scale type: [float] diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 27b809928b..84276ec6d7 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -220,6 +220,7 @@ KERNELSPEC void mlm_clustering( FIn xqcut, IIn scale_scheme, IIn beam_flags, + IIn jet_leg_mask, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -366,9 +367,11 @@ KERNELSPEC void mlm_clustering( // madevent calls this ipart; a slot stands for two legs after a // g -> q qbar splitting, and for none once its line has been absorbed. int rep1[N_EXT_MAX], rep2[N_EXT_MAX]; + bool slot_is_jet[N_EXT_MAX]; for (int i = 0; i < n_part; ++i) { rep1[i] = i; rep2[i] = -1; + slot_is_jet[i] = (jet_leg_mask >> i) & 1; } bool by_production = jet_scale_scheme == SCHEME_PRODUCTION; @@ -467,6 +470,9 @@ KERNELSPEC void mlm_clustering( rep2[particle1] = b2; } // TRACE_FIRST: slot particle1 already holds daughter 1's line + + // the merged line takes the mother's flavour + slot_is_jet[particle1] = (cluster_trace[i] & TRACE_IS_JET_IN) != 0; } // Any outgoing leg that no QCD clustering booked a scale onto keeps @@ -489,6 +495,40 @@ KERNELSPEC void mlm_clustering( FVal fac_scale1 = fac_scale, fac_scale2 = fac_scale; if (scale_scheme == SCALES_MADEVENT) { + // madevent clusters one step further than this kernel does: its loop + // runs all the way to the 2 -> 1 configuration, so it has one more + // scale than cluster_max. That last step absorbs whatever is left of + // the final state into a beam, and its scale is the transverse mass of + // that object - for Drell-Yan, the boson's mT. It is also the step + // jcentral usually lands on, which is why madevent's factorisation + // scale comes out as mT of the lepton pair rather than a jet scale. + // + // Rather than extending the state machine, take that step here: with + // the walk finished exactly one final-state slot is still alive, and + // the clustering is fully determined by which beam it goes to. + int leftover = -1; + for (int i = 2; i < n_part; ++i) { + if (alive & (1 << i)) { + leftover = i; + break; + } + } + int extra_beam = -1; + FVal extra_scale = 0.0; + if (leftover >= 0) { + FVal base = sqrt(djb_clus(momenta_tmp[leftover], hadronic)); + for (int b = 0; b < 2; ++b) { + FVal candidate = base; + if ((momenta_tmp[b][3] < 0.0) != + (momenta_tmp[leftover][3] < 0.0)) { + candidate = candidate * ONE_PLUS_TINY; + } + if (extra_beam < 0 || candidate < extra_scale) { + extra_beam = b; + extra_scale = candidate; + } + } + } // Follow each beam's parton line through the clustering, exactly as // setclscales does in Template/LO/SubProcesses/reweight.f. // @@ -506,16 +546,28 @@ KERNELSPEC void mlm_clustering( qcdline[j] = (beam_flags >> (2 * j)) & 1; partonline[j] = qcdline[j]; } - for (int i = 0; i < cluster_max; ++i) { - int data = cluster_history[i]; - int j = data & 0xFF; - if (j >= 2) { - continue; // final-state clustering, no beam line involved + int steps = cluster_max + (extra_beam >= 0 ? 1 : 0); + for (int i = 0; i < steps; ++i) { + bool is_extra = i == cluster_max; + int j, trace; + bool is_jet2, is_jet_in, is_colored_in; + if (is_extra) { + j = extra_beam; + is_jet2 = slot_is_jet[leftover]; + // the 2 -> 1 root turns the beam line into the other beam + is_jet_in = (beam_flags >> (2 * (1 - j) + 1)) & 1; + is_colored_in = (beam_flags >> (2 * (1 - j))) & 1; + } else { + int data = cluster_history[i]; + j = data & 0xFF; + if (j >= 2) { + continue; // final-state clustering, no beam line involved + } + is_jet2 = (data >> 29) & 1; + trace = cluster_trace[i]; + is_jet_in = trace & TRACE_IS_JET_IN; + is_colored_in = trace & TRACE_IS_COLORED_IN; } - bool is_jet2 = (data >> 29) & 1; - int trace = cluster_trace[i]; - bool is_jet_in = trace & TRACE_IS_JET_IN; - bool is_colored_in = trace & TRACE_IS_COLORED_IN; if (partonline[j]) { if (jfirst[j] < 0) { @@ -537,33 +589,38 @@ KERNELSPEC void mlm_clustering( } } + // index across the recorded history plus the extra 2 -> 1 step + auto step_scale = [&](int index) { + return index == cluster_max ? extra_scale : cluster_scales[index]; + }; + // "Ensure that last scales are at least as big as first scales" - FVal s_last[2]; + FVal s_last[2], s_central[2]; for (int j = 0; j < 2; ++j) { - s_last[j] = jlast[j] < 0 ? 0.0 : cluster_scales[jlast[j]]; + s_last[j] = jlast[j] < 0 ? 0.0 : step_scale(jlast[j]); if (jlast[j] >= 0 && jfirst[j] >= 0 && - cluster_scales[jfirst[j]] > s_last[j]) { - s_last[j] = cluster_scales[jfirst[j]]; + step_scale(jfirst[j]) > s_last[j]) { + s_last[j] = step_scale(jfirst[j]); } - } - FVal s_central[2]; - for (int j = 0; j < 2; ++j) { - s_central[j] = jcentral[j] < 0 ? 0.0 : cluster_scales[jcentral[j]]; + s_central[j] = jcentral[j] < 0 ? 0.0 : step_scale(jcentral[j]); } // mu_R: the geometric mean of the four scales. madevent writes it as // (pt2 pt2 pt2 pt2)^(1/8) over squared scales, which is the same thing. - FVal last_scale = cluster_scales[cluster_max - 1]; + FVal last_scale = extra_beam >= 0 ? extra_scale + : cluster_scales[cluster_max - 1]; + // Take the roots before multiplying. A degenerate clustering leaves a + // scale at SCALE_MAX, and the product of four of those overflows to + // infinity, which then reaches alpha_s as a NaN. if (jlast[0] >= 0 && jlast[1] >= 0) { - ren_scale_val = pow( - s_last[0] * s_central[0] * s_last[1] * s_central[1], 0.25 - ); + ren_scale_val = pow(s_last[0], 0.25) * pow(s_central[0], 0.25) * + pow(s_last[1], 0.25) * pow(s_central[1], 0.25); } else if (jlast[0] >= 0) { - ren_scale_val = sqrt(s_last[0] * s_central[0]); + ren_scale_val = sqrt(s_last[0]) * sqrt(s_central[0]); } else if (jlast[1] >= 0) { - ren_scale_val = sqrt(s_last[1] * s_central[1]); + ren_scale_val = sqrt(s_last[1]) * sqrt(s_central[1]); } else if (jcentral[0] >= 0 && jcentral[1] >= 0) { - ren_scale_val = sqrt(s_central[0] * s_central[1]); + ren_scale_val = sqrt(s_central[0]) * sqrt(s_central[1]); } else if (jcentral[0] >= 0) { ren_scale_val = s_central[0]; } else if (jcentral[1] >= 0) { @@ -575,22 +632,23 @@ KERNELSPEC void mlm_clustering( // mu_F, one per beam. madevent stores q2fact = mu_F^2 as // sqrt(pt2[jlast] pt2[jcentral]), i.e. mu_F is the geometric mean of // the two scales. - fac_scale1 = jlast[0] >= 0 ? sqrt(s_last[0] * s_central[0]) + fac_scale1 = jlast[0] >= 0 ? sqrt(s_last[0]) * sqrt(s_central[0]) : ren_scale_val; - fac_scale2 = jlast[1] >= 0 ? sqrt(s_last[1] * s_central[1]) + fac_scale2 = jlast[1] >= 0 ? sqrt(s_last[1]) * sqrt(s_central[1]) : ren_scale_val; // "We have a qcd line going through the whole event, use single scale" if (jcentral[0] >= 0 && jcentral[0] == jcentral[1]) { fac_scale1 = max(fac_scale1, fac_scale2); fac_scale2 = fac_scale1; } - if (!(ren_scale_val > 0.0)) { + // and keep everything inside the range the rest of the kernel uses + if (!(ren_scale_val > 0.0) || !(ren_scale_val < SCALE_MAX)) { ren_scale_val = last_scale; } - if (!(fac_scale1 > 0.0)) { + if (!(fac_scale1 > 0.0) || !(fac_scale1 < SCALE_MAX)) { fac_scale1 = ren_scale_val; } - if (!(fac_scale2 > 0.0)) { + if (!(fac_scale2 > 0.0) || !(fac_scale2 < SCALE_MAX)) { fac_scale2 = ren_scale_val; } } @@ -625,6 +683,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( FIn xqcut, IIn scale_scheme, IIn beam_flags, + IIn jet_leg_mask, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -646,6 +705,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( xqcut, scale_scheme, beam_flags, + jet_leg_mask, ren_scale, fact_scale1, fact_scale2, @@ -671,6 +731,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( FIn xqcut, IIn scale_scheme, IIn beam_flags, + IIn jet_leg_mask, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -692,6 +753,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( xqcut, scale_scheme, beam_flags, + jet_leg_mask, ren_scale, fact_scale1, fact_scale2, diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index 2341d0903a..8d59b01a9d 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -577,6 +577,11 @@ NamedVector Integrand::build_channel_part( // event below xqcut ends up with weight zero and is never unweighted. weights_after_cuts.push_back(scales.at("xqcut_weight")); } + if (_energy_scale && _energy_scale->has_min_scale()) { + // Same for the floor on the scales themselves, which applies to every + // dynamical scale choice rather than only to the merging one. + weights_after_cuts.push_back(scales.at("scale_weight")); + } Value weight_after_cuts = weights_after_cuts.empty() ? fb.full({1., batch_size_acc}) diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index 40fa15ec66..48239234ed 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -396,6 +396,7 @@ MLMClustering::MLMClustering( _jet_scale_scheme(jet_scale_scheme), _scale_scheme(scale_scheme), _beam_flags(0), + _jet_leg_mask(0), _xqcut(xqcut), _bw_cutoff(bw_cutoff), _jet_radius(jet_radius), @@ -428,6 +429,13 @@ MLMClustering::MLMClustering( } } + for (std::size_t leg = 0; leg < n_ext; ++leg) { + int pdg = have_pdg_ids ? external_pdg_ids.at(leg) : 21; + if (is_jet_pdg(pdg, max_jet_flavor)) { + _jet_leg_mask |= 1 << leg; + } + } + nested_vector2 valid_diags(1 << n_ext); std::vector mask_meta(1 << n_ext); std::vector particle_masks; @@ -652,7 +660,8 @@ NamedVector MLMClustering::build_function_impl( static_cast(_jet_scale_scheme), _xqcut, static_cast(_scale_scheme), - static_cast(_beam_flags) + static_cast(_beam_flags), + static_cast(_jet_leg_mask) ); } else { mlm_out = fb.mlm_clustering_leptonic( @@ -668,7 +677,8 @@ NamedVector MLMClustering::build_function_impl( static_cast(_jet_scale_scheme), _xqcut, static_cast(_scale_scheme), - static_cast(_beam_flags) + static_cast(_beam_flags), + static_cast(_jet_leg_mask) ); } return {return_types().keys(), {mlm_out.begin(), mlm_out.end()}}; diff --git a/madspace/src/phasespace/scale.cpp b/madspace/src/phasespace/scale.cpp index d39d71bcd7..0fd5831b75 100644 --- a/madspace/src/phasespace/scale.cpp +++ b/madspace/src/phasespace/scale.cpp @@ -9,40 +9,81 @@ EnergyScale::EnergyScale( bool fact_scale_fixed, double ren_scale, double fact_scale1, - double fact_scale2 + double fact_scale2, + double min_scale ) : FunctionGenerator( "EnergyScale", {{"momenta", batch_four_vec_array(particle_count)}}, - {{"ren_scale", batch_float}, - {"fact_scale1", batch_float}, - {"fact_scale2", batch_float}} + min_scale > 0. + ? NamedVector{{"ren_scale", batch_float}, + {"fact_scale1", batch_float}, + {"fact_scale2", batch_float}, + {"scale_weight", batch_float}} + : NamedVector{{"ren_scale", batch_float}, + {"fact_scale1", batch_float}, + {"fact_scale2", batch_float}} ), _dynamical_scale_type(dynamical_scale_type), _ren_scale_fixed(ren_scale_fixed), _fact_scale_fixed(fact_scale_fixed), _ren_scale(ren_scale), _fact_scale1(fact_scale1), - _fact_scale2(fact_scale2) {} + _fact_scale2(fact_scale2), + _min_scale(min_scale) {} -EnergyScale::EnergyScale(const MLMClustering& clustering) : - FunctionGenerator("EnergyScale", clustering.arg_types(), clustering.return_types()), +EnergyScale::EnergyScale(const MLMClustering& clustering, double min_scale) : + FunctionGenerator( + "EnergyScale", + clustering.arg_types(), + min_scale > 0. + ? [&] { + auto types = clustering.return_types(); + types.push_back("scale_weight", batch_float); + return types; + }() + : clustering.return_types() + ), + _min_scale(min_scale), _clustering(clustering) {} +// A floor on the scales the event is evaluated at, applied to whatever +// dynamical scale choice produced them. Below the lowest Q of a PDF grid the +// densities are not defined, and a scale of a few MeV comes back as a NaN that +// no later cut can remove, so the event is both vetoed and clamped: the veto +// weight drops it, the clamp keeps what is computed on the way finite. +// madevent applies the same floor to mu_F, at 2 GeV, in reweight.f. +NamedVector EnergyScale::apply_min_scale( + FunctionBuilder& fb, NamedVector scales +) const { + if (_min_scale <= 0.) { + return scales; + } + Value weight; + for (auto name : {"fact_scale1", "fact_scale2", "ren_scale"}) { + auto& scale = scales.at(name); + auto pass = fb.cut_one(scale, _min_scale, 1e30); + weight = weight ? fb.mul(weight, pass) : pass; + scale = fb.max(scale, fb.full({_min_scale, fb.batch_size({scale})})); + } + scales.push_back("scale_weight", weight); + return scales; +} + NamedVector EnergyScale::build_function_impl( FunctionBuilder& fb, const NamedVector& args ) const { auto momenta = args.at(0); if (_clustering) { - return _clustering.value().build_function(fb, args); + return apply_min_scale(fb, _clustering.value().build_function(fb, args)); } if (_ren_scale_fixed && _fact_scale_fixed) { auto batch_size = fb.batch_size({momenta}); - return { + return apply_min_scale(fb, { {"ren_scale", fb.full({_ren_scale, batch_size})}, {"fact_scale1", fb.full({_fact_scale1, batch_size})}, {"fact_scale2", fb.full({_fact_scale2, batch_size})}, - }; + }); } Value scale; switch (_dynamical_scale_type) { @@ -62,10 +103,10 @@ NamedVector EnergyScale::build_function_impl( throw std::runtime_error("invalid dynamical scale type"); } auto batch_size = fb.batch_size({momenta}); - return { + return apply_min_scale(fb, { {"ren_scale", _ren_scale_fixed ? fb.full({_ren_scale, batch_size}) : scale}, {"fact_scale1", _fact_scale_fixed ? fb.full({_fact_scale1, batch_size}) : scale}, {"fact_scale2", _fact_scale_fixed ? fb.full({_fact_scale2, batch_size}) : scale} - }; + }); } diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index dfdc3d5be2..741083e5d0 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -1220,6 +1220,7 @@ PYBIND11_MODULE(_madspace_py, m) { bool, double, double, + double, double>(), py::arg("particle_count"), py::arg("dynamical_scale_type"), @@ -1227,9 +1228,14 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("fact_scale_fixed"), py::arg("ren_scale"), py::arg("fact_scale1"), - py::arg("fact_scale2") + py::arg("fact_scale2"), + py::arg("min_scale") = 0. + ) + .def( + py::init(), + py::arg("clustering"), + py::arg("min_scale") = 0. ) - .def(py::init(), py::arg("clustering")) .def("is_mlm", &EnergyScale::is_mlm); py::classh(m, "CachedPdf").def(py::init<>()); From e913eb8025db8d5496803cf60ab701098ac58b24 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Tue, 8 Sep 2026 00:11:25 +0200 Subject: [PATCH 23/81] Bound the scales to the PDF grid's Q range, not just from below beam.min_scale kept mu_R and mu_F off the bottom of the grid, where a density of a few MeV is undefined. The top end has the same hole, and the madevent MLM scheme reaches it: it multiplies four clustering scales under a fourth root and produced mu_F up to 12.3 TeV against NNPDF23's Qmax of 10 TeV. The density came back NaN, and since the PDF is evaluated before the veto weight multiplies it, no later cut could remove it - one such point aborted the whole channel. So the floor becomes a range. The upper bound is not a new run-card knob: it is read off the grid itself in launch.py, which is where the right number actually lives. Events outside are both vetoed and clamped, the clamp only so what is computed on the way stays finite. Co-Authored-By: Claude Opus 5 --- madgraph/iolibs/template_files/mg7/launch.py | 9 ++++ .../include/madspace/phasespace/scale.hpp | 34 ++++++++++--- madspace/src/phasespace/integrand.cpp | 2 +- madspace/src/phasespace/scale.cpp | 51 ++++++++++++------- madspace/src/python/madspace.cpp | 9 ++-- 5 files changed, 75 insertions(+), 30 deletions(-) diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index 64e98dc654..d4b28367ba 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -448,6 +448,12 @@ def init_beam(self) -> None: if PDF_PATH is None: raise RuntimeError("Can't load lhapdf module. Please set LHAPDF_DATA_PATH manually") self.pdf_grid = ms.PdfGrid(os.path.join(PDF_PATH, pdf_set, f"{pdf_set}_0000.dat")) + # The grid only covers a band in Q. Asking for a density above its + # highest Q reads past the end of the interpolation and comes back as a + # NaN, so the scale is capped there. A dynamical scale rarely gets near + # it, but the madevent MLM scheme, which multiplies four clustering + # scales together under a fourth root, can. + self.max_scale = max(self.pdf_grid.q) self.alphas_grid = ms.AlphaSGrid(os.path.join(PDF_PATH, pdf_set, f"{pdf_set}.info")) for context in self.contexts: self.pdf_grid.initialize_globals(context) @@ -1351,6 +1357,7 @@ def __init__( else None ) + max_scale = 0.0 if self.process.leptonic else self.process.max_scale if self.process.mlm_clustering: mc_data = self.build_multi_channel_data() self.scale = ms.EnergyScale( @@ -1379,11 +1386,13 @@ def __init__( max_jet_flavor=self.process.run_card["beam"]["max_jet_flavor"], ), min_scale=self.process.run_card["beam"]["min_scale"], + max_scale=max_scale, ) else: self.scale = ms.EnergyScale( particle_count=self.particle_count, min_scale=self.process.run_card["beam"]["min_scale"], + max_scale=max_scale, **self.process.scale_kwargs, ) diff --git a/madspace/include/madspace/phasespace/scale.hpp b/madspace/include/madspace/phasespace/scale.hpp index 6bdee92d2c..f5a5e6e09d 100644 --- a/madspace/include/madspace/phasespace/scale.hpp +++ b/madspace/include/madspace/phasespace/scale.hpp @@ -17,11 +17,19 @@ class EnergyScale : public FunctionGenerator { EnergyScale(std::size_t particle_count) : EnergyScale(particle_count, half_transverse_mass, false, false, 0., 0., 0.) {} EnergyScale( - std::size_t particle_count, DynamicalScaleType type, double min_scale = 0. + std::size_t particle_count, + DynamicalScaleType type, + double min_scale = 0., + double max_scale = 0. ) : - EnergyScale(particle_count, type, false, false, 0., 0., 0., min_scale) {} + EnergyScale( + particle_count, type, false, false, 0., 0., 0., min_scale, max_scale + ) {} EnergyScale( - std::size_t particle_count, double fixed_scale, double min_scale = 0. + std::size_t particle_count, + double fixed_scale, + double min_scale = 0., + double max_scale = 0. ) : EnergyScale( particle_count, @@ -31,7 +39,8 @@ class EnergyScale : public FunctionGenerator { fixed_scale, fixed_scale, fixed_scale, - min_scale + min_scale, + max_scale ) {} EnergyScale( std::size_t particle_count, @@ -45,16 +54,24 @@ class EnergyScale : public FunctionGenerator { // scale_weight output and the scales are clamped, so that a pdf is // never asked for a density below the bottom of its grid. Zero // disables it. - double min_scale = 0. + double min_scale = 0., + // Upper end of the same range, normally the top of the PDF grid. + double max_scale = 0. + ); + EnergyScale( + const MLMClustering& clustering, + double min_scale = 0., + double max_scale = 0. ); - EnergyScale(const MLMClustering& clustering, double min_scale = 0.); bool is_mlm() const { return _clustering.has_value(); } - bool has_min_scale() const { return _min_scale > 0.; } + bool has_scale_range() const { + return _min_scale > 0. || _max_scale > 0.; + } private: - NamedVector apply_min_scale( + NamedVector apply_scale_range( FunctionBuilder& fb, NamedVector scales ) const; @@ -69,6 +86,7 @@ class EnergyScale : public FunctionGenerator { double _fact_scale1; double _fact_scale2; double _min_scale; + double _max_scale; std::optional _clustering; }; diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index 8d59b01a9d..cf804ba821 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -577,7 +577,7 @@ NamedVector Integrand::build_channel_part( // event below xqcut ends up with weight zero and is never unweighted. weights_after_cuts.push_back(scales.at("xqcut_weight")); } - if (_energy_scale && _energy_scale->has_min_scale()) { + if (_energy_scale && _energy_scale->has_scale_range()) { // Same for the floor on the scales themselves, which applies to every // dynamical scale choice rather than only to the merging one. weights_after_cuts.push_back(scales.at("scale_weight")); diff --git a/madspace/src/phasespace/scale.cpp b/madspace/src/phasespace/scale.cpp index 0fd5831b75..09b79d470d 100644 --- a/madspace/src/phasespace/scale.cpp +++ b/madspace/src/phasespace/scale.cpp @@ -10,12 +10,13 @@ EnergyScale::EnergyScale( double ren_scale, double fact_scale1, double fact_scale2, - double min_scale + double min_scale, + double max_scale ) : FunctionGenerator( "EnergyScale", {{"momenta", batch_four_vec_array(particle_count)}}, - min_scale > 0. + (min_scale > 0. || max_scale > 0.) ? NamedVector{{"ren_scale", batch_float}, {"fact_scale1", batch_float}, {"fact_scale2", batch_float}, @@ -30,13 +31,16 @@ EnergyScale::EnergyScale( _ren_scale(ren_scale), _fact_scale1(fact_scale1), _fact_scale2(fact_scale2), - _min_scale(min_scale) {} + _min_scale(min_scale), + _max_scale(max_scale) {} -EnergyScale::EnergyScale(const MLMClustering& clustering, double min_scale) : +EnergyScale::EnergyScale( + const MLMClustering& clustering, double min_scale, double max_scale +) : FunctionGenerator( "EnergyScale", clustering.arg_types(), - min_scale > 0. + (min_scale > 0. || max_scale > 0.) ? [&] { auto types = clustering.return_types(); types.push_back("scale_weight", batch_float); @@ -45,26 +49,37 @@ EnergyScale::EnergyScale(const MLMClustering& clustering, double min_scale) : : clustering.return_types() ), _min_scale(min_scale), + _max_scale(max_scale), _clustering(clustering) {} -// A floor on the scales the event is evaluated at, applied to whatever -// dynamical scale choice produced them. Below the lowest Q of a PDF grid the -// densities are not defined, and a scale of a few MeV comes back as a NaN that -// no later cut can remove, so the event is both vetoed and clamped: the veto -// weight drops it, the clamp keeps what is computed on the way finite. -// madevent applies the same floor to mu_F, at 2 GeV, in reweight.f. -NamedVector EnergyScale::apply_min_scale( +// The range of scales an event may be evaluated at, applied to whatever +// dynamical scale choice produced them. A PDF grid only covers a band in Q - +// 1 to 10000 GeV for NNPDF23, say - and outside it the densities are not +// defined: below, a scale of a few MeV, above, one of several TeV. Either +// comes back as a NaN that no later cut can remove, because the pdf is +// evaluated before the veto weight multiplies it and NaN times zero is still +// NaN. So an event outside the range is both vetoed, through the scale_weight +// output, and clamped, which keeps what is computed on the way finite. +// +// madevent applies the same floor to mu_F, at 2 GeV, in reweight.f. The upper +// end has no counterpart there: LHAPDF extrapolates rather than returning a +// hole, so madevent never has to look. +NamedVector EnergyScale::apply_scale_range( FunctionBuilder& fb, NamedVector scales ) const { - if (_min_scale <= 0.) { + if (_min_scale <= 0. && _max_scale <= 0.) { return scales; } + double low = _min_scale > 0. ? _min_scale : 0.; + double high = _max_scale > 0. ? _max_scale : 1e30; Value weight; for (auto name : {"fact_scale1", "fact_scale2", "ren_scale"}) { auto& scale = scales.at(name); - auto pass = fb.cut_one(scale, _min_scale, 1e30); + auto pass = fb.cut_one(scale, low, high); weight = weight ? fb.mul(weight, pass) : pass; - scale = fb.max(scale, fb.full({_min_scale, fb.batch_size({scale})})); + auto batch_size = fb.batch_size({scale}); + scale = fb.max(scale, fb.full({low, batch_size})); + scale = fb.min(scale, fb.full({high, batch_size})); } scales.push_back("scale_weight", weight); return scales; @@ -75,11 +90,11 @@ NamedVector EnergyScale::build_function_impl( ) const { auto momenta = args.at(0); if (_clustering) { - return apply_min_scale(fb, _clustering.value().build_function(fb, args)); + return apply_scale_range(fb, _clustering.value().build_function(fb, args)); } if (_ren_scale_fixed && _fact_scale_fixed) { auto batch_size = fb.batch_size({momenta}); - return apply_min_scale(fb, { + return apply_scale_range(fb, { {"ren_scale", fb.full({_ren_scale, batch_size})}, {"fact_scale1", fb.full({_fact_scale1, batch_size})}, {"fact_scale2", fb.full({_fact_scale2, batch_size})}, @@ -103,7 +118,7 @@ NamedVector EnergyScale::build_function_impl( throw std::runtime_error("invalid dynamical scale type"); } auto batch_size = fb.batch_size({momenta}); - return apply_min_scale(fb, { + return apply_scale_range(fb, { {"ren_scale", _ren_scale_fixed ? fb.full({_ren_scale, batch_size}) : scale}, {"fact_scale1", _fact_scale_fixed ? fb.full({_fact_scale1, batch_size}) : scale}, diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index 741083e5d0..15f02d04eb 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -1221,6 +1221,7 @@ PYBIND11_MODULE(_madspace_py, m) { double, double, double, + double, double>(), py::arg("particle_count"), py::arg("dynamical_scale_type"), @@ -1229,12 +1230,14 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("ren_scale"), py::arg("fact_scale1"), py::arg("fact_scale2"), - py::arg("min_scale") = 0. + py::arg("min_scale") = 0., + py::arg("max_scale") = 0. ) .def( - py::init(), + py::init(), py::arg("clustering"), - py::arg("min_scale") = 0. + py::arg("min_scale") = 0., + py::arg("max_scale") = 0. ) .def("is_mlm", &EnergyScale::is_mlm); From f91409e1cacb75e8a9a3c14e68f2d36dc08e2b55 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Tue, 8 Sep 2026 00:12:04 +0200 Subject: [PATCH 24/81] Keep the per-beam factorisation scales in the event record They were only recorded under partial_weights, so for an ordinary run the event buffer had no fact_scale1/2, ChannelEventGenerator never set the beam flags, and fill_lhe_event fell back to writing mu_R into all three of mur, muf1 and muf2. The PDF itself was always evaluated at the right scale - only the reporting was wrong - but it made every mu_F comparison against madevent silently a mu_R comparison. A dynamical scale choice can give the two beams different scales, and madevent's MLM definition always does, so these are worth the two doubles per event whether or not the run is being reweighted. Co-Authored-By: Claude Opus 5 --- madspace/src/phasespace/integrand.cpp | 75 +++++++++++++-------------- 1 file changed, 37 insertions(+), 38 deletions(-) diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index cf804ba821..bfbae084c6 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -131,18 +131,22 @@ Integrand::Integrand( } ret_types.push_back("ren_scale", batch_float); ret_types.push_back("alpha_qcd", batch_float); - if (partial_weights) { - if (diff_xs_first.has_pdf(0)) { - ret_types.push_back("x1", batch_float); - ret_types.push_back("fact_scale1", batch_float); - } - if (diff_xs_first.has_pdf(1)) { - ret_types.push_back("x2", batch_float); - ret_types.push_back("fact_scale2", batch_float); - } - if (diff_xs_first.has_pdf(0) || diff_xs_first.has_pdf(1)) { - ret_types.push_back("partial_weight_product", batch_float); - } + // The per-beam factorisation scales ride along with x1/x2 in + // the event record. They are worth keeping for any hadronic + // run, not just a reweighted one: a dynamical scale choice can + // give the two beams different scales, and without them the + // LHE writer has nothing to report but mu_R. + if (diff_xs_first.has_pdf(0)) { + ret_types.push_back("x1", batch_float); + ret_types.push_back("fact_scale1", batch_float); + } + if (diff_xs_first.has_pdf(1)) { + ret_types.push_back("x2", batch_float); + ret_types.push_back("fact_scale2", batch_float); + } + if (partial_weights && + (diff_xs_first.has_pdf(0) || diff_xs_first.has_pdf(1))) { + ret_types.push_back("partial_weight_product", batch_float); } if (energy_scale && energy_scale->is_mlm()) { ret_types.push_back( @@ -949,32 +953,27 @@ NamedVector Integrand::build_common_part( "ren_scale", scatter_or_drop(zeros_float, args.at("ren_scale")) ); outputs.push_back("alpha_qcd", scatter_or_drop(zeros_float, alpha_qcd_acc)); - if (_partial_weights) { - ValueVec pdf_vals; - if (_diff_xs.at(0).has_pdf(0)) { - outputs.push_back( - "x1", scatter_or_drop(zeros_float, args.at("x1_acc")) - ); - outputs.push_back( - "fact_scale1", scatter_or_drop(zeros_float, args.at("fact_scale1")) - ); - pdf_vals.push_back(args.at("pdf1")); - } - if (_diff_xs.at(0).has_pdf(1)) { - outputs.push_back( - "x2", scatter_or_drop(zeros_float, args.at("x2_acc")) - ); - outputs.push_back( - "fact_scale2", scatter_or_drop(zeros_float, args.at("fact_scale2")) - ); - pdf_vals.push_back(args.at("pdf2")); - } - if (_diff_xs.at(0).has_pdf(0) || _diff_xs.at(0).has_pdf(1)) { - outputs.push_back( - "partial_weight_product", - scatter_or_drop(zeros_float, fb.product(pdf_vals)) - ); - } + ValueVec pdf_vals; + if (_diff_xs.at(0).has_pdf(0)) { + outputs.push_back("x1", scatter_or_drop(zeros_float, args.at("x1_acc"))); + outputs.push_back( + "fact_scale1", scatter_or_drop(zeros_float, args.at("fact_scale1")) + ); + pdf_vals.push_back(args.at("pdf1")); + } + if (_diff_xs.at(0).has_pdf(1)) { + outputs.push_back("x2", scatter_or_drop(zeros_float, args.at("x2_acc"))); + outputs.push_back( + "fact_scale2", scatter_or_drop(zeros_float, args.at("fact_scale2")) + ); + pdf_vals.push_back(args.at("pdf2")); + } + if (_partial_weights && + (_diff_xs.at(0).has_pdf(0) || _diff_xs.at(0).has_pdf(1))) { + outputs.push_back( + "partial_weight_product", + scatter_or_drop(zeros_float, fb.product(pdf_vals)) + ); } if (_energy_scale && _energy_scale->is_mlm()) { auto outgoing_count = static_cast(_mapping.particle_count() - 2); From 7b5f370f91263c9b163ca35d5a88d62abc72d9db Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Tue, 8 Sep 2026 00:15:12 +0200 Subject: [PATCH 25/81] Follow setclscales faithfully in the madevent scale scheme Five separate divergences from Template/LO/SubProcesses/reweight.f, each found by comparing mu_F per jet multiplicity for Z+0,1,2,3j against a madevent reference and each measurable on its own: - mt2ij was never recorded. Every initial-state clustering keeps the mT of its final-state daughter (cluster.f:841) and pt2ijcl(jcentral) is overwritten by it (reweight.f:1059). - The 2 -> 1 root lies on both beam lines, and reweight.f's do j=1,2 walks it once per beam. The extra step here was attached to a single "winning" beam, so jlast never reached the root. Largest effect. - pt2ijcl(jlast) is raised in place, so when jlast and jcentral are the same vertex - the usual case, both lines running QCD to the root - the central read sees the raised value and mu_F is that one scale rather than a geometric mean of two. - The root's scale is a lab-frame transverse mass: cluster.f rotates and boosts back before taking it. Computing it in the frame the walk ended up in agrees at 0 jets, where no boost has happened yet, and drifts from 1 jet on - which is exactly how it was found. - The chain handling a beam line that is never coloured was missing. mu_F per multiplicity, against madevent with pdfwgt off: 0j 1j 2j 3j madevent 78.1 121.0 167.2 218.6 madspace 78.1 138.1 184.8 220.4 What is left is goodjet: madevent propagates a per-line "every clustering so far was a jet one" flag, while this kernel reads each daughter's static flavour. That makes the parton line survive to the root too often, which shows up as too many events sitting exactly at mT(ll) at 2 and 3 jets. Co-Authored-By: Claude Opus 5 --- madspace/src/kernels/mlm.hpp | 189 +++++++++++++++++++++++++---------- 1 file changed, 134 insertions(+), 55 deletions(-) diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 84276ec6d7..6b70d8bff1 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -237,15 +237,25 @@ KERNELSPEC void mlm_clustering( int cluster_max = momenta.size() - 3; int n_part = momenta.size(); FourMom momenta_tmp[N_EXT_MAX]; + // The same clustering tracked in the lab frame, without the boosts and + // rotations update_momenta applies. Only the 2 -> 1 root needs it: cluster.f + // boosts and rotates back before taking that vertex's scale, so it is a + // transverse mass in the lab and not in whatever frame the walk ended up in. + FourMom momenta_lab[N_EXT_MAX]; FVal masses_tmp[N_EXT_MAX]; int alive = (1 << n_part) - 1; int cluster_history[N_EXT_MAX - 3]; int cluster_trace[N_EXT_MAX - 3]; FVal cluster_scales[N_EXT_MAX - 3]; + // mT of the final-state daughter of each initial-state clustering, + // zero for a final-state one. This is mt2ij in cluster.f, kept + // unsquared like every other scale here. + FVal cluster_mt[N_EXT_MAX - 3]; for (int i = 0; i < n_part; ++i) { for (int j = 0; j < 4; ++j) { momenta_tmp[i][j] = momenta[i][j]; + momenta_lab[i][j] = momenta[i][j]; } masses_tmp[i] = external_masses[i]; } @@ -322,6 +332,21 @@ KERNELSPEC void mlm_clustering( if (is_last) { int p1_win = win_data & 0xFF; int p2_win = (win_data >> 8) & 0xFF; + // Read the daughter before update_momenta boosts it away. Only an + // initial-state clustering has one, matching the iwin < 3 guard in + // cluster.f. + cluster_mt[cluster_count] = p1_win < 2 + ? sqrt(djb_clus(momenta_tmp[p2_win], hadronic)) + : FVal(0.0); + // The mother keeps slot p1_win. A final-state clustering merges the + // two daughters; an initial-state one takes the emission back out + // of the beam, as pcl(imo) = pcl(ida1) - pcl(ida2) does in + // cluster.f. + for (int k = 0; k < 4; ++k) { + momenta_lab[p1_win][k] = p1_win < 2 + ? momenta_lab[p1_win][k] - momenta_lab[p2_win][k] + : momenta_lab[p1_win][k] + momenta_lab[p2_win][k]; + } update_momenta( n_part, momenta_tmp, masses_tmp, alive, p2_win, p1_win, win_resonant ); @@ -513,22 +538,9 @@ KERNELSPEC void mlm_clustering( break; } } - int extra_beam = -1; - FVal extra_scale = 0.0; - if (leftover >= 0) { - FVal base = sqrt(djb_clus(momenta_tmp[leftover], hadronic)); - for (int b = 0; b < 2; ++b) { - FVal candidate = base; - if ((momenta_tmp[b][3] < 0.0) != - (momenta_tmp[leftover][3] < 0.0)) { - candidate = candidate * ONE_PLUS_TINY; - } - if (extra_beam < 0 || candidate < extra_scale) { - extra_beam = b; - extra_scale = candidate; - } - } - } + FVal extra_scale = leftover < 0 + ? FVal(0.0) + : sqrt(djb_clus(momenta_lab[leftover], hadronic)); // Follow each beam's parton line through the clustering, exactly as // setclscales does in Template/LO/SubProcesses/reweight.f. // @@ -546,29 +558,9 @@ KERNELSPEC void mlm_clustering( qcdline[j] = (beam_flags >> (2 * j)) & 1; partonline[j] = qcdline[j]; } - int steps = cluster_max + (extra_beam >= 0 ? 1 : 0); - for (int i = 0; i < steps; ++i) { - bool is_extra = i == cluster_max; - int j, trace; - bool is_jet2, is_jet_in, is_colored_in; - if (is_extra) { - j = extra_beam; - is_jet2 = slot_is_jet[leftover]; - // the 2 -> 1 root turns the beam line into the other beam - is_jet_in = (beam_flags >> (2 * (1 - j) + 1)) & 1; - is_colored_in = (beam_flags >> (2 * (1 - j))) & 1; - } else { - int data = cluster_history[i]; - j = data & 0xFF; - if (j >= 2) { - continue; // final-state clustering, no beam line involved - } - is_jet2 = (data >> 29) & 1; - trace = cluster_trace[i]; - is_jet_in = trace & TRACE_IS_JET_IN; - is_colored_in = trace & TRACE_IS_COLORED_IN; - } - + // One step of the beam-line bookkeeping, for beam j at step i. + auto walk_line = [&](int j, int i, bool is_jet2, bool is_jet_in, + bool is_colored_in) { if (partonline[j]) { if (jfirst[j] < 0) { jfirst[j] = i; @@ -582,6 +574,36 @@ KERNELSPEC void mlm_clustering( jcentral[j] = i; qcdline[j] = is_colored_in; } + }; + + int steps = cluster_max + (leftover >= 0 ? 1 : 0); + for (int i = 0; i < cluster_max; ++i) { + int data = cluster_history[i]; + int j = data & 0xFF; + if (j >= 2) { + continue; // final-state clustering, no beam line involved + } + int trace = cluster_trace[i]; + walk_line( + j, i, (data >> 29) & 1, trace & TRACE_IS_JET_IN, + trace & TRACE_IS_COLORED_IN + ); + } + if (leftover >= 0) { + // The 2 -> 1 root clusters both beams into what is left of the + // final state, so it lies on both beam lines and reweight.f walks + // it twice, once per beam. Seen from beam j the emitted object is + // that leftover and the mother is the *other* beam's parton, which + // is what decides whether either line carries on past the root. + for (int j = 0; j < 2; ++j) { + walk_line( + j, + cluster_max, + slot_is_jet[leftover], + (beam_flags >> (2 * (1 - j) + 1)) & 1, + (beam_flags >> (2 * (1 - j))) & 1 + ); + } } for (int j = 0; j < 2; ++j) { if (jfirst[j] < 0) { @@ -589,26 +611,54 @@ KERNELSPEC void mlm_clustering( } } - // index across the recorded history plus the extra 2 -> 1 step - auto step_scale = [&](int index) { - return index == cluster_max ? extra_scale : cluster_scales[index]; - }; + // The scale of each step, across the recorded history plus the extra + // 2 -> 1 one, as a mutable table because the central vertices are + // overwritten in place below just as pt2ijcl is in reweight.f. + FVal pt_step[N_EXT_MAX - 2]; + for (int i = 0; i < steps; ++i) { + pt_step[i] = i == cluster_max ? extra_scale : cluster_scales[i]; + } - // "Ensure that last scales are at least as big as first scales" - FVal s_last[2], s_central[2]; + // "Set central scale to mT2": at an initial-state clustering the + // relevant scale is not the clustering measure but the transverse mass + // of what was emitted there. For Drell-Yan the central vertex is where + // the lepton pair attaches, which is why madevent's factorisation + // scale comes out as mT(ll) and not a jet scale. + for (int j = 0; j < 2; ++j) { + if (jcentral[j] < 0) { + continue; + } + // At the root cluster.f takes mt2ij from daughter 2, which there + // is the second beam's parton: mT of a beam parton is zero, so + // the root vertex never gets overridden. + FVal mt = jcentral[j] == cluster_max ? FVal(0.0) + : cluster_mt[jcentral[j]]; + if (mt > 0.0) { + pt_step[jcentral[j]] = mt; + } + } + + // "Ensure that last scales are at least as big as first scales". + // reweight.f raises the entry in pt2ijcl itself, so when jlast and + // jcentral are the same vertex - which is the usual case, both beam + // lines running QCD all the way to the root - the central scale is + // raised with it and mu_F comes out as that one scale rather than a + // geometric mean of two. Writing into pt_step keeps that. for (int j = 0; j < 2; ++j) { - s_last[j] = jlast[j] < 0 ? 0.0 : step_scale(jlast[j]); if (jlast[j] >= 0 && jfirst[j] >= 0 && - step_scale(jfirst[j]) > s_last[j]) { - s_last[j] = step_scale(jfirst[j]); + pt_step[jfirst[j]] > pt_step[jlast[j]]) { + pt_step[jlast[j]] = pt_step[jfirst[j]]; } - s_central[j] = jcentral[j] < 0 ? 0.0 : step_scale(jcentral[j]); + } + FVal s_last[2], s_central[2]; + for (int j = 0; j < 2; ++j) { + s_last[j] = jlast[j] < 0 ? 0.0 : pt_step[jlast[j]]; + s_central[j] = jcentral[j] < 0 ? 0.0 : pt_step[jcentral[j]]; } // mu_R: the geometric mean of the four scales. madevent writes it as // (pt2 pt2 pt2 pt2)^(1/8) over squared scales, which is the same thing. - FVal last_scale = extra_beam >= 0 ? extra_scale - : cluster_scales[cluster_max - 1]; + FVal last_scale = pt_step[steps - 1]; // Take the roots before multiplying. A degenerate clustering leaves a // scale at SCALE_MAX, and the product of four of those overflows to // infinity, which then reaches alpha_s as a NaN. @@ -632,15 +682,44 @@ KERNELSPEC void mlm_clustering( // mu_F, one per beam. madevent stores q2fact = mu_F^2 as // sqrt(pt2[jlast] pt2[jcentral]), i.e. mu_F is the geometric mean of // the two scales. - fac_scale1 = jlast[0] >= 0 ? sqrt(s_last[0]) * sqrt(s_central[0]) - : ren_scale_val; - fac_scale2 = jlast[1] >= 0 ? sqrt(s_last[1]) * sqrt(s_central[1]) - : ren_scale_val; + fac_scale1 = jlast[0] >= 0 ? sqrt(s_last[0]) * sqrt(s_central[0]) : 0.0; + fac_scale2 = jlast[1] >= 0 ? sqrt(s_last[1]) * sqrt(s_central[1]) : 0.0; // "We have a qcd line going through the whole event, use single scale" if (jcentral[0] >= 0 && jcentral[0] == jcentral[1]) { fac_scale1 = max(fac_scale1, fac_scale2); fac_scale2 = fac_scale1; } + + // "Take care of case when jcentral are zero". + // + // The branch that ends that chain in reweight.f, capping each beam at + // min(pt2ijcl(jfirst), q2fact) when pdfwgt is on, is deliberately not + // ported. It exists so madevent can evaluate the PDF low on the + // clustering ladder and reweight back up; the central scale is kept in + // q2bck and restored, which is why turning pdfwgt off moves madevent's + // cross section (1113 -> 1093 pb for Z+0,1,2,3j) but leaves SCALUP + // unchanged. madspace has no such ladder reweighting, so the central + // scale is what both the PDF and the LHE should see. + if (jcentral[0] < 0 && jcentral[1] < 0) { + if (!(fac_scale1 > 0.0) && !(fac_scale2 > 0.0)) { + fac_scale1 = pt_step[steps - 1]; + fac_scale2 = fac_scale1; + } + } else if (jcentral[0] < 0) { + if (jfirst[0] >= 0) { + fac_scale1 = pt_step[jfirst[0]]; + } + } else if (jcentral[1] < 0) { + if (jfirst[1] >= 0) { + fac_scale2 = pt_step[jfirst[1]]; + } + } + if (!(fac_scale1 > 0.0)) { + fac_scale1 = ren_scale_val; + } + if (!(fac_scale2 > 0.0)) { + fac_scale2 = ren_scale_val; + } // and keep everything inside the range the rest of the kernel uses if (!(ren_scale_val > 0.0) || !(ren_scale_val < SCALE_MAX)) { ren_scale_val = last_scale; From 0a29429dbcfe47719e622070c2fff624b456ab5b Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Tue, 8 Sep 2026 00:42:47 +0200 Subject: [PATCH 26/81] Add beam.parton_line_scheme, madevent's goodjet bookkeeping reweight.f decides whether a beam line carries on past a vertex from goodjet: a propagated flag saying every clustering that line has been through so far was a jet vertex, so one non-jet vertex anywhere in its history stops it for good. The kernel was reading each daughter's static flavour instead, which cannot see that history. Both are available through beam.parton_line_scheme, defaulting to the faithful "goodjet". The flag only matters under scale_scheme = "madevent". Two smaller divergences went with it. goodjet is cleared on a beam line once it stops being a parton line, and at the 2 -> 1 root the mother seen from one beam is the *other* beam's line as it stands there - ipdgcl(imo) with imo = idacl(n,3-i) - not the external parton it started as, so its flavour and colour are now tracked through the clustering rather than read off beam_flags. Measured, this changes almost nothing: for Z+0,1,2,3j the two schemes give mu_F within the run-to-run scatter at every multiplicity. That is a real result rather than a dead switch - forcing the emitted object to count as a non-jet moves 3 jets from 219 to 180 GeV, so the code path is live, and the schemes coincide because every object a beam emits in this process is an external parton whose goodjet was never cleared. It is therefore not the explanation for the residual gap against madevent at 2 and 3 jets: 0j 1j 2j 3j madevent 78.1 121.0 167.2 218.6 madspace 78.1 137.9 182.7 218.9 I could not build a unit test that separates the two schemes: it needs a beam to emit an object formed at a non-jet vertex and then cluster again, and neither test fixture produces that on a winning history, with or without photons substituted into the final state. Co-Authored-By: Claude Opus 5 --- madgraph/iolibs/template_files/mg7/launch.py | 4 + madgraph/various/banner.py | 11 +++ madspace/generate_pyi.py | 1 + .../madspace/phasespace/mlm_clustering.hpp | 16 +++- madspace/instruction_set.yaml | 6 ++ madspace/src/kernels/mlm.hpp | 76 +++++++++++++++---- madspace/src/phasespace/mlm_clustering.cpp | 10 ++- madspace/src/python/madspace.cpp | 14 +++- 8 files changed, 119 insertions(+), 19 deletions(-) diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index d4b28367ba..025a7ec91c 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -1384,6 +1384,10 @@ def __init__( hadronic=not self.process.leptonic, external_pdg_ids=all_pids, max_jet_flavor=self.process.run_card["beam"]["max_jet_flavor"], + parton_line_scheme=getattr( + ms.MLMClustering.PartonLineScheme, + self.process.run_card["beam"]["parton_line_scheme"], + ), ), min_scale=self.process.run_card["beam"]["min_scale"], max_scale=max_scale, diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index 93801521a4..e487ad423e 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6544,6 +6544,17 @@ def default_setup(self): # section, unlike jet_scale_scheme. self.add_toml_param('beam', 'scale_scheme', "clustering_mean", allowed=['clustering_mean', 'madevent']) + # How a beam's parton line is decided to carry on past a vertex (only + # read when scale_scheme = "madevent"): + # "goodjet": a line stays a parton line only while every clustering + # it has been through was a jet vertex, so one non-jet + # vertex in its history stops it for good. What madevent + # does. + # "flavor": decided from the flavour of the emitted object alone, + # which keeps lines alive past vertices where madevent + # would have stopped them. + self.add_toml_param('beam', 'parton_line_scheme', "goodjet", + allowed=['goodjet', 'flavor']) # Floor on mu_R and mu_F, whatever the dynamical scale choice. Below # the lowest Q of a PDF grid the densities are undefined, so an event # whose scales fall under this is dropped. madevent applies the same diff --git a/madspace/generate_pyi.py b/madspace/generate_pyi.py index 5ae9ddc6dd..6177733ded 100644 --- a/madspace/generate_pyi.py +++ b/madspace/generate_pyi.py @@ -59,6 +59,7 @@ def main(): "--enum-class-locations=LRSchedule:AdamOptimizer.LRSchedule", "--enum-class-locations=JetScaleScheme:MLMClustering.JetScaleScheme", "--enum-class-locations=ScaleScheme:MLMClustering.ScaleScheme", + "--enum-class-locations=PartonLineScheme:MLMClustering.PartonLineScheme", # Fail the build on unresolvable names/expressions (e.g. an enum # default value needing its own --enum-class-locations entry) # instead of silently emitting a stub with holes in it. diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp index b5e0996366..6417573538 100644 --- a/madspace/include/madspace/phasespace/mlm_clustering.hpp +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -40,6 +40,18 @@ enum class ScaleScheme { madevent = 1, }; +// How the beam parton line is decided to carry on past a vertex. Only read by +// ScaleScheme::madevent. +enum class PartonLineScheme { + // From the flavour of the object emitted at that vertex alone. + flavor = 0, + // From goodjet of reweight.f: a line counts as a parton line only while + // every clustering it has been through was a jet vertex, so one non-jet + // vertex anywhere in its history stops it for good. This is what madevent + // does, and it stops beam lines earlier than flavor does. + goodjet = 1, +}; + class MLMClustering : public FunctionGenerator { public: MLMClustering( @@ -69,7 +81,8 @@ class MLMClustering : public FunctionGenerator { // pdg ids of the external particles, in leg order. When empty, every // clustering is assumed to be a QCD splitting between jets. std::vector external_pdg_ids = {}, - int max_jet_flavor = 4 + int max_jet_flavor = 4, + PartonLineScheme parton_line_scheme = PartonLineScheme::goodjet ); // The compiled clustering state machine, in the flat encoding the kernel @@ -93,6 +106,7 @@ class MLMClustering : public FunctionGenerator { double _cm_energy; JetScaleScheme _jet_scale_scheme; ScaleScheme _scale_scheme; + PartonLineScheme _parton_line_scheme; int _beam_flags; int _jet_leg_mask; double _xqcut; diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index bc2a6cdf7f..fcf8a99225 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -2235,6 +2235,9 @@ mlm_clustering_hadronic: - name: jet_leg_mask type: [int, single] desc: + - name: parton_line_scheme + type: [int, single] + desc: outputs: - name: ren_scale type: [float] @@ -2300,6 +2303,9 @@ mlm_clustering_leptonic: - name: jet_leg_mask type: [int, single] desc: + - name: parton_line_scheme + type: [int, single] + desc: outputs: - name: ren_scale type: [float] diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 6b70d8bff1..8a94c72fae 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -27,6 +27,10 @@ constexpr int SCHEME_PRODUCTION = 1; // ScaleScheme in mlm_clustering.hpp. constexpr int SCALES_CLUSTERING_MEAN = 0; constexpr int SCALES_MADEVENT = 1; +// How the beam parton line is decided to carry on past a vertex: from each +// daughter's own flavour, or from the propagated goodjet flag of reweight.f. +constexpr int LINE_FLAVOR = 0; +constexpr int LINE_GOODJET = 1; // trace_data bits above the 2-bit trace mode: the flavour of the mother. constexpr int TRACE_IS_JET_IN = 1 << 2; constexpr int TRACE_IS_COLORED_IN = 1 << 3; @@ -221,6 +225,7 @@ KERNELSPEC void mlm_clustering( IIn scale_scheme, IIn beam_flags, IIn jet_leg_mask, + IIn parton_line_scheme, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -393,10 +398,14 @@ KERNELSPEC void mlm_clustering( // g -> q qbar splitting, and for none once its line has been absorbed. int rep1[N_EXT_MAX], rep2[N_EXT_MAX]; bool slot_is_jet[N_EXT_MAX]; + // Colour of the line currently in each slot, tracked the same way. Only + // the two beam slots are ever read back, at the 2 -> 1 root. + bool slot_is_colored[N_EXT_MAX]; for (int i = 0; i < n_part; ++i) { rep1[i] = i; rep2[i] = -1; slot_is_jet[i] = (jet_leg_mask >> i) & 1; + slot_is_colored[i] = i < 2 ? (((beam_flags >> (2 * i)) & 1) != 0) : true; } bool by_production = jet_scale_scheme == SCHEME_PRODUCTION; @@ -498,6 +507,7 @@ KERNELSPEC void mlm_clustering( // the merged line takes the mother's flavour slot_is_jet[particle1] = (cluster_trace[i] & TRACE_IS_JET_IN) != 0; + slot_is_colored[particle1] = (cluster_trace[i] & TRACE_IS_COLORED_IN) != 0; } // Any outgoing leg that no QCD clustering booked a scale onto keeps @@ -558,17 +568,38 @@ KERNELSPEC void mlm_clustering( qcdline[j] = (beam_flags >> (2 * j)) & 1; partonline[j] = qcdline[j]; } + // goodjet(i) of reweight.f: "every clustering this line has been + // through so far was a jet one". It starts as the flavour of each + // external - QCD for a beam, jet for an outgoing leg - and can only + // ever be cleared, so a line that once passed through a non-jet vertex + // never counts as a parton line again. Reading each daughter's flavour + // instead, which is what LINE_FLAVOR does, keeps beam lines alive past + // vertices where madevent would have stopped them. + bool goodjet[N_EXT_MAX]; + for (int i = 0; i < n_part; ++i) { + goodjet[i] = i < 2 ? ((beam_flags >> (2 * i)) & 1) != 0 + : ((jet_leg_mask >> i) & 1) != 0; + } + bool use_goodjet = parton_line_scheme == LINE_GOODJET; + // One step of the beam-line bookkeeping, for beam j at step i. - auto walk_line = [&](int j, int i, bool is_jet2, bool is_jet_in, + // is_emitted_jet is goodjet(ida(3-i)), the emitted object; is_jet_in + // and is_colored_in are the mother's flavour. + auto walk_line = [&](int j, int i, bool is_emitted_jet, bool is_jet_in, bool is_colored_in) { if (partonline[j]) { if (jfirst[j] < 0) { jfirst[j] = i; } jlast[j] = i; - partonline[j] = is_jet2 && is_jet_in; - } else if (jfirst[j] < 0) { - jfirst[j] = i; + partonline[j] = is_emitted_jet && is_jet_in; + } else { + if (jfirst[j] < 0) { + jfirst[j] = i; + } + // the beam line has stopped being a parton line, so whatever + // it becomes is not a good jet either + goodjet[j] = false; } if (qcdline[j]) { jcentral[j] = i; @@ -579,14 +610,24 @@ KERNELSPEC void mlm_clustering( int steps = cluster_max + (leftover >= 0 ? 1 : 0); for (int i = 0; i < cluster_max; ++i) { int data = cluster_history[i]; - int j = data & 0xFF; - if (j >= 2) { - continue; // final-state clustering, no beam line involved - } + int particle1 = data & 0xFF; + int particle2 = (data >> 8) & 0xFF; int trace = cluster_trace[i]; + bool is_jet_in = (trace & TRACE_IS_JET_IN) != 0; + if (particle1 >= 2) { + // final-state clustering: no beam line, but the mother's + // goodjet has to be worked out for whoever clusters with it + // later - including the root, which reads the leftover's. + bool is_jet_vertex = (data >> 27) & 1; + goodjet[particle1] = is_jet_vertex && is_jet_in && + goodjet[particle1] && goodjet[particle2]; + continue; + } + bool emitted = use_goodjet ? goodjet[particle2] + : ((data >> 29) & 1) != 0; walk_line( - j, i, (data >> 29) & 1, trace & TRACE_IS_JET_IN, - trace & TRACE_IS_COLORED_IN + particle1, i, emitted, is_jet_in, + (trace & TRACE_IS_COLORED_IN) != 0 ); } if (leftover >= 0) { @@ -595,13 +636,18 @@ KERNELSPEC void mlm_clustering( // it twice, once per beam. Seen from beam j the emitted object is // that leftover and the mother is the *other* beam's parton, which // is what decides whether either line carries on past the root. + bool leftover_jet = + use_goodjet ? goodjet[leftover] : slot_is_jet[leftover]; for (int j = 0; j < 2; ++j) { + // Seen from beam j the mother is the other beam's line as it + // stands at the root, not the external parton it started as - + // reweight.f reads ipdgcl(imo) with imo = idacl(n,3-i). walk_line( j, cluster_max, - slot_is_jet[leftover], - (beam_flags >> (2 * (1 - j) + 1)) & 1, - (beam_flags >> (2 * (1 - j))) & 1 + leftover_jet, + slot_is_jet[1 - j], + slot_is_colored[1 - j] ); } } @@ -763,6 +809,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( IIn scale_scheme, IIn beam_flags, IIn jet_leg_mask, + IIn parton_line_scheme, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -785,6 +832,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( scale_scheme, beam_flags, jet_leg_mask, + parton_line_scheme, ren_scale, fact_scale1, fact_scale2, @@ -811,6 +859,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( IIn scale_scheme, IIn beam_flags, IIn jet_leg_mask, + IIn parton_line_scheme, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -833,6 +882,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( scale_scheme, beam_flags, jet_leg_mask, + parton_line_scheme, ren_scale, fact_scale1, fact_scale2, diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index 48239234ed..073bcae2b2 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -378,7 +378,8 @@ MLMClustering::MLMClustering( double jet_radius, bool hadronic, std::vector external_pdg_ids, - int max_jet_flavor + int max_jet_flavor, + PartonLineScheme parton_line_scheme ) : FunctionGenerator( "MLMClustering", @@ -395,6 +396,7 @@ MLMClustering::MLMClustering( _cm_energy(cm_energy), _jet_scale_scheme(jet_scale_scheme), _scale_scheme(scale_scheme), + _parton_line_scheme(parton_line_scheme), _beam_flags(0), _jet_leg_mask(0), _xqcut(xqcut), @@ -661,7 +663,8 @@ NamedVector MLMClustering::build_function_impl( _xqcut, static_cast(_scale_scheme), static_cast(_beam_flags), - static_cast(_jet_leg_mask) + static_cast(_jet_leg_mask), + static_cast(_parton_line_scheme) ); } else { mlm_out = fb.mlm_clustering_leptonic( @@ -678,7 +681,8 @@ NamedVector MLMClustering::build_function_impl( _xqcut, static_cast(_scale_scheme), static_cast(_beam_flags), - static_cast(_jet_leg_mask) + static_cast(_jet_leg_mask), + static_cast(_parton_line_scheme) ); } return {return_types().keys(), {mlm_out.begin(), mlm_out.end()}}; diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index 15f02d04eb..366487ee97 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -992,6 +992,14 @@ PYBIND11_MODULE(_madspace_py, m) { {"production", JetScaleScheme::production}, } ); + add_enum( + mlm, + "PartonLineScheme", + { + {"flavor", PartonLineScheme::flavor}, + {"goodjet", PartonLineScheme::goodjet}, + } + ); mlm .def( py::init< @@ -1007,7 +1015,8 @@ PYBIND11_MODULE(_madspace_py, m) { double, bool, std::vector, - int>(), + int, + PartonLineScheme>(), py::arg("topologies"), py::arg("permutations"), py::arg("diagram_indices"), @@ -1020,7 +1029,8 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("jet_radius") = 0.4, py::arg("hadronic") = true, py::arg("external_pdg_ids") = std::vector{}, - py::arg("max_jet_flavor") = 4 + py::arg("max_jet_flavor") = 4, + py::arg("parton_line_scheme") = PartonLineScheme::goodjet ) .def_property_readonly( "cluster_state_machine", &MLMClustering::cluster_state_machine From 0beba6af95905d2597a931137be86656c41a93d8 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Tue, 8 Sep 2026 14:10:52 +0200 Subject: [PATCH 27/81] Add phasespace.auto_ptj_mjj, madevent's merging-cut cut adjustment madevent adjusts the jet cuts whenever xqcut is on, in Template/LO/SubProcesses/setcuts.f: it raises ptj to xqcut, which costs no phase space that survives the merging anyway and buys a lot of integration efficiency, and it drops the jet dR cuts, which xqcut supersedes and which would otherwise carve a hole out of exactly the region the parton shower is meant to fill. Without it the two codes were not being compared on the same phase space: madspace kept ptj at 20 GeV and a jet dR of 0.4 that madevent had already removed. Only the ptj half is applied. mjj has no counterpart here - "mass" is a single-particle observable in madspace, so a jet-mass cut asks each massless parton to be heavier than xqcut and rejects every event. In madevent mjj is an efficiency device rather than a physics cut, so nothing is lost but some sampling speed. Measured on p p > e+ ve + 0..3j, cross section per jet multiplicity in pb against madevent at the same xqcut: xqcut = 15 1j 2j 3j madevent 2941 1682 907 before 1527 544 202 after 2116 844 333 The remaining deficit at high multiplicity is a separate question, as is the 3-jet channel's poor convergence at tight xqcut. Co-Authored-By: Claude Opus 5 --- madgraph/iolibs/template_files/mg7/launch.py | 42 +++++++++++++++++++- madgraph/various/banner.py | 8 ++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index 025a7ec91c..795145b504 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -355,9 +355,48 @@ def parse_observable(self, name: str, order_observable: str) -> dict: name=name, ) + def apply_auto_ptj_mjj(self, cuts: dict) -> None: + """madevent's auto_ptj_mjj, from Template/LO/SubProcesses/setcuts.f. + + A merging cut already bounds every matrix-element jet from below, so + raising the jet pt cut to xqcut removes no phase space that survives + the merging anyway while making the integration much more efficient. + The jet dR cuts go the other way and are dropped: + xqcut supersedes them, and a dR cut would carve a hole out of exactly + the region the parton shower is supposed to fill, leaving a gap in the + jet rates at the merging scale. + + With the option off, madevent still drops the dR cuts, and only zeroes + a pt cut that sits above xqcut - it never tightens. + """ + if self.run_card["beam"]["dynamical_scale_choice"] != "mlm": + return + xqcut = self.run_card["phasespace"]["xqcut"] + if xqcut <= 0: + return + for key in ("jet-delta_r", "jet-lepton-delta_r"): + if cuts.get(key, {}).get("min", 0.0) > 0.0: + cuts[key]["min"] = 0.0 + auto = self.run_card["phasespace"]["auto_ptj_mjj"] + # Only the ptj half of madevent's rule is applied. Its mjj half has no + # counterpart here: "mass" is a single-particle observable in madspace, + # so a jet-mass cut would ask each massless parton to be heavier than + # xqcut and reject everything. mjj is an efficiency device in madevent, + # not a physics cut, so nothing is lost beyond some sampling speed. + if auto: + cuts.setdefault("jet-pt", {})["min"] = xqcut + elif cuts.get("jet-pt", {}).get("min", 0.0) > xqcut: + cuts["jet-pt"]["min"] = 0.0 + def init_cuts(self) -> None: inf = float("inf") order_observable = self.run_card["cuts"].get("order_by", "pt") + cuts = { + key: dict(values) + for key, values in self.run_card["cuts"].items() + if key != "order_by" + } + self.apply_auto_ptj_mjj(cuts) self.cut_data = [ CutItem( observable_kwargs=self.parse_observable(key, order_observable), @@ -365,8 +404,7 @@ def init_cuts(self) -> None: max=values.get("max", inf), mode=values.get("mode", "all"), ) - for key, values in self.run_card["cuts"].items() - if key != "order_by" + for key, values in cuts.items() ] def init_histograms(self) -> None: diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index e487ad423e..6d8047feb5 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6624,6 +6624,14 @@ def default_setup(self): # the parton shower, so the event is dropped. Only used with # dynamical_scale_choice = "mlm"; 0 disables it. self.add_toml_param('phasespace', 'xqcut', 0.0) + # Counterpart of madevent's auto_ptj_mjj. With a merging cut in place + # the matrix-element jets are already bounded from below by xqcut, so + # the jet pt and pair-mass cuts are raised to it - free physically, and + # a large gain in integration efficiency - and the jet dR cuts are + # dropped, since xqcut supersedes them and a dR cut would otherwise + # carve a hole out of the region the shower is meant to fill. + # Only read when dynamical_scale_choice = "mlm" and xqcut > 0. + self.add_toml_param('phasespace', 'auto_ptj_mjj', True) self.add_toml_param('phasespace', 'adaptive_symmetry_sampling', True) # ----------------------------- [madnis] ----------------------- From f5bcf89239de1e1fe474f1597bce9ea050e3a019 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 9 Sep 2026 10:07:10 +0200 Subject: [PATCH 28/81] Complete auto_ptj_mjj with the mjj half, now that a pair cut exists madevent's auto_ptj_mjj sets both ptj and mmjj to xqcut. Only the ptj half was ported, because "mass" was a single-particle observable here and there was no way to ask for the invariant mass of a jet pair. The merge brings in obs_m_inv, which is that cut and which also raises the propagator's e_min, so the jet-pair invariants a composite s-channel node samples are bounded the way madevent bounds them. This matters for more than tidiness: ptj only reaches the phase space for jets that are leaf children of the root. A jet inside a composite node has its pt_min reset to zero in PhaseSpaceMapping, and mjj is what bounds that node instead. It moves the cross section by about 3% at a loose cut (9853 -> 9549 pb for p p > e+ ve + 0..3j at xqcut = 15). It does NOT fix the instability of the highest multiplicity at a tight cut, which I had expected it to. Suppression between xqcut 15 and 60, per multiplicity, against madevent: 0j 1j 2j 3j with mjj 1.1x 10.8x 24.5x 0.8x ptj only 1.0x 10.3x 23.6x 1.0x madevent 1.0x 9.6x 21.2x 47.8x That is a separate defect, and the measurement above says it is not the merging cut missing from the phase space. It is overweight replication: at xqcut = 60 the 3-jet sample was 1635 events drawn from 30 distinct phase-space points, 54.5 copies of each, while every other multiplicity sits at exactly one copy per point. The integral stays right because the replication conserves it; only the event sample is wrong. Co-Authored-By: Claude Opus 5 From b1feff35f124a08e27bfbcda01ac33db9784bfd9 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 9 Sep 2026 11:48:34 +0200 Subject: [PATCH 29/81] Do not replay a starved channel's buffer when combining events Each channel's share of the combined sample comes from its apportioned target, not from how many unweighted events it actually wrote, so a channel whose acceptance collapses can run out. EventFile::read then returns false and leaves the buffer untouched, but read_and_combine ignored that and reset buffer_index to 0 anyway, so the previous buffer was served again and its events were emitted repeatedly. For p p > e+ ve + 0..3j at xqcut = 60 GeV the 3-jet channel starves, and the combined sample contained 1635 three-jet events drawn from 38 distinct records, one of them repeated 120 times, byte for byte. The emission order showed the replay directly: the first 32 were distinct, then it cycled back through what was left in the buffer. Every other multiplicity was unaffected at exactly one event per point. The integral was never wrong - the requested number of events was still written with the right weights - but the multiplicity composition was, so jet rates, DJR and multiplicity fractions taken off the sample were meaningless wherever a channel starved. Now a channel that cannot supply its share is retired from the draw and the event is taken from the others. If nothing is left the batch is short, which ends the combine loop; a sample smaller than requested is the honest outcome when the events do not exist. Two things had to be handled to make that safe. Retiring a channel leaves it with zero width in the cumulative distribution, but random_index == 0 still resolves to the first entry, so a retired channel could still be drawn and decrementing its count wrapped the unsigned counter - which is what made my first two attempts at this spin. And retiring reduces the number of events still available below the batch that was planned, so the draw has to stop rather than sample from an empty distribution. Measured, p p > e+ ve + 0..3j, sigma per multiplicity in pb, and the suppression each multiplicity gets when xqcut goes from 15 to 60 GeV: 0j 1j 2j 3j before 5597.1 215.2 52.6 355.2 (3j suppression 1.9x) after 5942.1 235.1 54.6 11.2 (3j suppression 59.6x) madevent 5925.6 298.6 77.4 18.3 (3j suppression 47.8x) Suppression has to grow with multiplicity, since every extra jet must pass the same cut; before this it did not. No new failures in the madspace suite against main. Co-Authored-By: Claude Opus 5 --- madspace/src/driver/event_generator.cpp | 53 ++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/madspace/src/driver/event_generator.cpp b/madspace/src/driver/event_generator.cpp index 7ca1f2c539..c0b75a52e9 100644 --- a/madspace/src/driver/event_generator.cpp +++ b/madspace/src/driver/event_generator.cpp @@ -889,7 +889,15 @@ void EventGenerator::read_and_combine( bool has_subproc_index = _channels.at(0)->event_layout_extra_flags() & EventRecord::f_subproc_index; + std::size_t empty_draws = 0; for (std::size_t event_index = 0; event_index < event_count; ++event_index) { + // Retiring a starved channel below takes its share out of the draw + // distribution, so the number of events still available can fall short + // of the batch we set out to fill. Stop rather than draw from nothing. + if (channel_data.back().cum_count == 0) { + buffer.resize(event_index); + return; + } std::size_t random_index = rand_gen.generate_int(channel_data.back().cum_count); auto sampled_chan = std::lower_bound( channel_data.begin(), @@ -897,16 +905,43 @@ void EventGenerator::read_and_combine( random_index, [](auto& chan, std::size_t val) { return chan.cum_count < val; } ); + // A channel retired below has zero width in the cumulative + // distribution, but random_index == 0 still resolves to the first + // entry, so it can be picked; decrementing it would wrap the unsigned + // count. Redraw instead, and give up once nothing is left to draw. + std::size_t previous = sampled_chan == channel_data.begin() + ? 0 + : (sampled_chan - 1)->cum_count; + if (sampled_chan->cum_count == previous) { + if (++empty_draws > channel_data.size()) { + buffer.resize(event_index); + return; + } + --event_index; // unsigned wrap is fine: the loop's ++ undoes it + continue; + } + empty_draws = 0; std::for_each(sampled_chan, channel_data.end(), [](auto& chan) { --chan.cum_count; }); auto& channel = _channels.at(sampled_chan - channel_data.begin()); double weight = 0.; + // A channel's share of the sample comes from its apportioned target, + // not from how many events it actually managed to write, so a channel + // whose acceptance collapsed can run out. EventFile::read then returns + // false and leaves the buffer alone; resetting the read position anyway + // would hand back the previous buffer and emit its events a second + // time. Retire the channel instead and draw again. + bool exhausted = false; while (true) { if (sampled_chan->buffer_index == sampled_chan->event_buffer.event_count()) { - channel->event_file().read(sampled_chan->event_buffer, batch_size); + if (!channel->event_file().read(sampled_chan->event_buffer, batch_size) + ) { + exhausted = true; + break; + } channel->weight_file().read(sampled_chan->weight_buffer, batch_size); sampled_chan->buffer_index = 0; } @@ -917,6 +952,22 @@ void EventGenerator::read_and_combine( } ++sampled_chan->buffer_index; } + if (exhausted) { + // Drop whatever this channel still owed from the draw distribution. + // The event it could not supply is redrawn from the others; if none + // are left the sample is simply shorter than asked for, which is + // the honest outcome - the events do not exist. + std::size_t owed = sampled_chan->cum_count - previous; + std::for_each(sampled_chan, channel_data.end(), [owed](auto& chan) { + chan.cum_count -= owed; + }); + if (channel_data.back().cum_count == 0) { + buffer.resize(event_index); + return; + } + --event_index; // unsigned wrap is fine: the loop's ++ undoes it + continue; + } auto event_in = sampled_chan->event_buffer.event(sampled_chan->buffer_index); auto event_out = buffer.event(event_index); From 70f5b6f49a42da5608f155c9a000cd86edc650b3 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 9 Sep 2026 14:32:52 +0200 Subject: [PATCH 30/81] Revert "Do not replay a starved channel's buffer when combining events" This reverts commit b1feff35f124a08e27bfbcda01ac33db9784bfd9. --- madspace/src/driver/event_generator.cpp | 53 +------------------------ 1 file changed, 1 insertion(+), 52 deletions(-) diff --git a/madspace/src/driver/event_generator.cpp b/madspace/src/driver/event_generator.cpp index c0b75a52e9..7ca1f2c539 100644 --- a/madspace/src/driver/event_generator.cpp +++ b/madspace/src/driver/event_generator.cpp @@ -889,15 +889,7 @@ void EventGenerator::read_and_combine( bool has_subproc_index = _channels.at(0)->event_layout_extra_flags() & EventRecord::f_subproc_index; - std::size_t empty_draws = 0; for (std::size_t event_index = 0; event_index < event_count; ++event_index) { - // Retiring a starved channel below takes its share out of the draw - // distribution, so the number of events still available can fall short - // of the batch we set out to fill. Stop rather than draw from nothing. - if (channel_data.back().cum_count == 0) { - buffer.resize(event_index); - return; - } std::size_t random_index = rand_gen.generate_int(channel_data.back().cum_count); auto sampled_chan = std::lower_bound( channel_data.begin(), @@ -905,43 +897,16 @@ void EventGenerator::read_and_combine( random_index, [](auto& chan, std::size_t val) { return chan.cum_count < val; } ); - // A channel retired below has zero width in the cumulative - // distribution, but random_index == 0 still resolves to the first - // entry, so it can be picked; decrementing it would wrap the unsigned - // count. Redraw instead, and give up once nothing is left to draw. - std::size_t previous = sampled_chan == channel_data.begin() - ? 0 - : (sampled_chan - 1)->cum_count; - if (sampled_chan->cum_count == previous) { - if (++empty_draws > channel_data.size()) { - buffer.resize(event_index); - return; - } - --event_index; // unsigned wrap is fine: the loop's ++ undoes it - continue; - } - empty_draws = 0; std::for_each(sampled_chan, channel_data.end(), [](auto& chan) { --chan.cum_count; }); auto& channel = _channels.at(sampled_chan - channel_data.begin()); double weight = 0.; - // A channel's share of the sample comes from its apportioned target, - // not from how many events it actually managed to write, so a channel - // whose acceptance collapsed can run out. EventFile::read then returns - // false and leaves the buffer alone; resetting the read position anyway - // would hand back the previous buffer and emit its events a second - // time. Retire the channel instead and draw again. - bool exhausted = false; while (true) { if (sampled_chan->buffer_index == sampled_chan->event_buffer.event_count()) { - if (!channel->event_file().read(sampled_chan->event_buffer, batch_size) - ) { - exhausted = true; - break; - } + channel->event_file().read(sampled_chan->event_buffer, batch_size); channel->weight_file().read(sampled_chan->weight_buffer, batch_size); sampled_chan->buffer_index = 0; } @@ -952,22 +917,6 @@ void EventGenerator::read_and_combine( } ++sampled_chan->buffer_index; } - if (exhausted) { - // Drop whatever this channel still owed from the draw distribution. - // The event it could not supply is redrawn from the others; if none - // are left the sample is simply shorter than asked for, which is - // the honest outcome - the events do not exist. - std::size_t owed = sampled_chan->cum_count - previous; - std::for_each(sampled_chan, channel_data.end(), [owed](auto& chan) { - chan.cum_count -= owed; - }); - if (channel_data.back().cum_count == 0) { - buffer.resize(event_index); - return; - } - --event_index; // unsigned wrap is fine: the loop's ++ undoes it - continue; - } auto event_in = sampled_chan->event_buffer.event(sampled_chan->buffer_index); auto event_out = buffer.event(event_index); From 5e2bade974817b9b31bf5d3a745f424c04a1ffb7 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 10 Sep 2026 10:04:14 +0200 Subject: [PATCH 31/81] Keep run_all() to the outputs it documents The helper says "as run(), plus the trailing xqcut_weight" but returned whatever the clustering happened to produce, so every caller unpacked a fixed count from a growing tuple. Adding an output to the kernel broke six tests that had nothing to do with it. Co-Authored-By: Claude Opus 5 --- madspace/tests/test_mlm.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 8bedf9915c..69b4904c60 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -119,9 +119,13 @@ def run(clustering, momenta): def run_all(clustering, momenta): - """As run(), plus the trailing xqcut_weight.""" - out = clustering(momenta) - return tuple(np.asarray(v) for v in out) + """As run(), plus the trailing xqcut_weight. + + Deliberately stops there rather than unpacking whatever the clustering + happens to return: an output added to the kernel would otherwise break + every caller here, none of which is about that output. + """ + return tuple(np.asarray(v) for v in clustering(momenta))[:6] def assert_jet_scales_agree(reference, other, max_flip_fraction=0.05): From 0eff5a7734cd969b7833e37deb1ba488554f5964 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 10 Sep 2026 10:06:45 +0200 Subject: [PATCH 32/81] Reweight alpha_s per clustering vertex in a merged sample A merged event is generated at one renormalisation scale, but the ladder it represents was not: each emission belongs at the scale of its own splitting. madevent applies that correction in the rewgt loop of Template/LO/SubProcesses/reweight.f, multiplying the weight by alphas(pt_i)/alphas(mu_R) once per clustering vertex that produced a parton. madspace kept alphas(mu_R)^n, so it was short by one factor per emission and the deficit compounded with multiplicity. The kernel now returns the scale of every clustering vertex, mu_R itself for the vertices it does not reweight so that their ratio is one and the consumer needs no mask, and the integrand turns those into the product. A vertex at or below 2 GeV, where the coupling is not to be trusted, kills the event as it does in reweight.f; that is its own weight rather than part of xqcut_weight, which is the merging cut and nothing else. p p > w+ 0-3j, xqcut 15, 150k events each side, as a ratio to madevent per jet multiplicity: 0j 1j 2j 3j total before 1.000 0.764 0.600 0.461 0.839 after 1.000 0.921 0.864 0.819 0.946 0 jets is untouched, as it must be: there is nothing to cluster. Co-Authored-By: Claude Opus 5 --- madspace/instruction_set.yaml | 14 +++++++ madspace/src/kernels/mlm.hpp | 44 +++++++++++++++++++++- madspace/src/phasespace/integrand.cpp | 30 +++++++++++++++ madspace/src/phasespace/mlm_clustering.cpp | 7 +++- madspace/tests/test_mlm.py | 14 +++++-- 5 files changed, 102 insertions(+), 7 deletions(-) diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index a07c918b85..6ebfe514d3 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -2272,6 +2272,13 @@ mlm_clustering_hadronic: - name: xqcut_weight type: [float] desc: + - name: alphas_scales + type: [float, n-3] + desc: scale of each clustering vertex for the alpha_s reweighting + - name: alphas_weight + type: [float] + desc: zero when a reweighted vertex sits where the coupling is not usable + vectorized: False mlm_clustering_leptonic: @@ -2340,6 +2347,13 @@ mlm_clustering_leptonic: - name: xqcut_weight type: [float] desc: + - name: alphas_scales + type: [float, n-3] + desc: scale of each clustering vertex for the alpha_s reweighting + - name: alphas_weight + type: [float] + desc: zero when a reweighted vertex sits where the coupling is not usable + vectorized: False --- diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 8a94c72fae..cf087d793b 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -232,6 +232,8 @@ KERNELSPEC void mlm_clustering( FOut outgoing_scales, IOut diagram_index, FOut xqcut_weight, + FOut alphas_scales, + FOut alphas_weight, bool hadronic ) { // we do not support SIMD for now, so we can assume simple types @@ -783,6 +785,36 @@ KERNELSPEC void mlm_clustering( fact_scale2 = fac_scale2; // A weight rather than a flag, so that it can simply multiply the event // weight the way every other cut in madspace does. + // Scale of each clustering vertex for the alpha_s reweighting, following + // the loop in Template/LO/SubProcesses/reweight.f: every clustering except + // the last one is reweighted by alphas(pt_clust) / alphas(mu_R), and only + // where a parton is produced. A vertex that is not reweighted is handed + // mu_R itself, so its ratio is exactly one and the consumer needs no mask. + // + // madevent gates this on goodjet / ispartonvx; the QCD flag of the vertex + // is the same statement for every case that arises here - all three lines + // coloured - and is what the rest of this kernel already uses. + // + // reweight.f drops an event outright when a reweighted vertex sits at or + // below 2 GeV, where the coupling is not to be trusted; alphas_weight is + // that veto. + bool alphas_ok = true; + for (int i = 0; i < cluster_max; ++i) { + if ((cluster_history[i] >> 27) & 1) { + FVal scale = cluster_scales[i]; + if (!(scale * scale > 4.0)) { + alphas_ok = false; + } + alphas_scales[i] = scale; + } else { + alphas_scales[i] = ren_scale_val; + } + } + + // Kept apart from xqcut_weight: that one is the merging cut and nothing + // else, and only the reweighting cares where the coupling stops being + // usable. + alphas_weight = alphas_ok ? 1.0 : 0.0; xqcut_weight = passes_xqcut ? 1.0 : 0.0; int diag_count = state_machine[state]; @@ -815,7 +847,9 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( FOut fact_scale2, FOut outgoing_scales, IOut diagram_index, - FOut xqcut_weight + FOut xqcut_weight, + FOut alphas_scales, + FOut alphas_weight ) { mlm_clustering( momenta, @@ -839,6 +873,8 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( outgoing_scales, diagram_index, xqcut_weight, + alphas_scales, + alphas_weight, true ); } @@ -865,7 +901,9 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( FOut fact_scale2, FOut outgoing_scales, IOut diagram_index, - FOut xqcut_weight + FOut xqcut_weight, + FOut alphas_scales, + FOut alphas_weight ) { mlm_clustering( momenta, @@ -889,6 +927,8 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( outgoing_scales, diagram_index, xqcut_weight, + alphas_scales, + alphas_weight, false ); } diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index 9c94a84d25..17bd4439e9 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -584,6 +584,36 @@ NamedVector Integrand::build_channel_part( // factor on the weight rather than as one of the cuts themselves. An // event below xqcut ends up with weight zero and is never unweighted. weights_after_cuts.push_back(scales.at("xqcut_weight")); + + // alpha_s reweighting, the CKKW-style factor madevent applies in + // Template/LO/SubProcesses/reweight.f: every clustering vertex that + // produced a parton is evaluated at its own scale rather than at the + // event's, so the weight carries prod_i alphas(pt_i) instead of + // alphas(mu_R)^n. Without it a merged sample is short by one factor + // per emission, compounding with multiplicity. + // + // The kernel hands back mu_R for any vertex it does not reweight, so + // that vertex's ratio is one and no mask is needed here. + if (_running_coupling) { + auto vertex_scales = scales.at("alphas_scales"); + std::size_t vertex_count = vertex_scales.type.shape.at(0); + if (vertex_count > 0) { + auto reference = _running_coupling.value() + .build_function(fb, {scales.at("ren_scale")}) + .at(0); + Value factor; + for (std::size_t i = 0; i < vertex_count; ++i) { + auto [rest, one_scale] = fb.pop(vertex_scales); + vertex_scales = rest; + auto alpha = + _running_coupling.value().build_function(fb, {one_scale}).at(0); + auto ratio = fb.div(alpha, reference); + factor = factor ? fb.mul(factor, ratio) : ratio; + } + weights_after_cuts.push_back(factor); + weights_after_cuts.push_back(scales.at("alphas_weight")); + } + } } if (_energy_scale && _energy_scale->has_scale_range()) { // Same for the floor on the scales themselves, which applies to every diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index 073bcae2b2..359ea787bc 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -391,7 +391,10 @@ MLMClustering::MLMClustering( {"outgoing_scales", batch_float_array(topologies.at(0).outgoing_masses().size())}, {"diagram_index", batch_int}, - {"xqcut_weight", batch_float}} + {"xqcut_weight", batch_float}, + {"alphas_scales", + batch_float_array(topologies.at(0).outgoing_masses().size() - 1)}, + {"alphas_weight", batch_float}} ), _cm_energy(cm_energy), _jet_scale_scheme(jet_scale_scheme), @@ -646,7 +649,7 @@ MLMClustering::MLMClustering( NamedVector MLMClustering::build_function_impl( FunctionBuilder& fb, const NamedVector& args ) const { - std::array mlm_out; + std::array mlm_out; Value random = fb.squeeze(fb.random(fb.batch_size(args.values()), 1)); if (_hadronic) { mlm_out = fb.mlm_clustering_hadronic( diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 69b4904c60..4a62fa7e31 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -121,13 +121,21 @@ def run(clustering, momenta): def run_all(clustering, momenta): """As run(), plus the trailing xqcut_weight. - Deliberately stops there rather than unpacking whatever the clustering - happens to return: an output added to the kernel would otherwise break - every caller here, none of which is about that output. + Deliberately stops there: the clustering also returns the per-vertex + alpha_s scales and their weight, which these tests are not about, and + unpacking everything would break every caller each time an output is + added. Use alphas_outputs() for those. """ return tuple(np.asarray(v) for v in clustering(momenta))[:6] +def alphas_outputs(clustering, momenta): + """The alpha_s reweighting outputs: per-vertex scales, and the weight that + drops an event whose vertices sit where the coupling is not usable.""" + out = clustering(momenta) + return np.asarray(out[6]), np.asarray(out[7]) + + def assert_jet_scales_agree(reference, other, max_flip_fraction=0.05): """Compare two sets of per-jet clustering scales that should be identical. From 0c3e67b38c70eaa44988da9f7eeddffd84c51df6 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 10 Sep 2026 10:08:47 +0200 Subject: [PATCH 33/81] Add beam.alphas_reweighting to pick the alpha_s scheme Three ways to evaluate the coupling of a merged event, since the per-vertex one is a choice and not the only defensible one: per_vertex alphas(pt_i) at each clustering vertex, what madevent does geometric_mean one coupling at the geometric mean of the pt_i, raised to the number of vertices: one scale for the whole ladder rather than one per rung none a single coupling at mu_R, the behaviour before it existed Default per_vertex, and only read under dynamical_scale_choice = "mlm". "none" is there to measure what the reweighting is worth, which is how the numbers in the previous commit were obtained. The two reweighting schemes are not distinguishable in practice: for p p > w+ 0-3j at xqcut 15, 150k events each, they give 10746.9 and 10760.4 pb, 0.1% apart, and their per-multiplicity ratios to madevent agree to within half a percent at every multiplicity: 0j 1j 2j 3j total geometric_mean 1.000 0.921 0.860 0.811 0.944 per_vertex 1.000 0.921 0.864 0.819 0.946 That is worth knowing rather than assuming: the ladder is short enough here that the spread of the individual pt_i does not matter, so the cheaper scheme loses nothing. It may well not hold once the ladder is longer. Co-Authored-By: Claude Opus 5 --- madgraph/iolibs/template_files/mg7/launch.py | 4 +++ madgraph/various/banner.py | 13 +++++++ madspace/generate_pyi.py | 1 + .../madspace/phasespace/mlm_clustering.hpp | 18 +++++++++- .../include/madspace/phasespace/scale.hpp | 4 +++ madspace/instruction_set.yaml | 6 ++++ madspace/src/kernels/mlm.hpp | 34 +++++++++++++++++-- madspace/src/phasespace/integrand.cpp | 2 +- madspace/src/phasespace/mlm_clustering.cpp | 10 ++++-- madspace/src/python/madspace.cpp | 15 ++++++-- 10 files changed, 97 insertions(+), 10 deletions(-) diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index f402503821..566bb025c2 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -1642,6 +1642,10 @@ def __init__( ms.MLMClustering.PartonLineScheme, self.process.run_card["beam"]["parton_line_scheme"], ), + alphas_scheme=getattr( + ms.MLMClustering.AlphasScheme, + self.process.run_card["beam"]["alphas_reweighting"], + ), ), min_scale=self.process.run_card["beam"]["min_scale"], max_scale=max_scale, diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index 6ed150443a..a5c6754e8f 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6568,6 +6568,19 @@ def default_setup(self): # would have stopped them. self.add_toml_param('beam', 'parton_line_scheme', "goodjet", allowed=['goodjet', 'flavor']) + # Evaluate alpha_s at the scale of each clustering vertex instead of + # once at the event scale, which is what madevent does for a merged + # sample (the rewgt loop in Template/LO/SubProcesses/reweight.f). A + # merged sample without it is short by roughly one factor of + # alphas(pt_emission)/alphas(mu_R) per extra jet, compounding with + # multiplicity. + # "per_vertex": alphas(pt_i) at each vertex, what madevent does + # "geometric_mean": one coupling at the geometric mean of the pt_i, + # raised to the number of vertices + # "none": a single coupling at mu_R for the whole event + # Only read when dynamical_scale_choice = "mlm". + self.add_toml_param('beam', 'alphas_reweighting', "per_vertex", + allowed=['per_vertex', 'geometric_mean', 'none']) # Floor on mu_R and mu_F, whatever the dynamical scale choice. Below # the lowest Q of a PDF grid the densities are undefined, so an event # whose scales fall under this is dropped. madevent applies the same diff --git a/madspace/generate_pyi.py b/madspace/generate_pyi.py index 6177733ded..660eaf753e 100644 --- a/madspace/generate_pyi.py +++ b/madspace/generate_pyi.py @@ -60,6 +60,7 @@ def main(): "--enum-class-locations=JetScaleScheme:MLMClustering.JetScaleScheme", "--enum-class-locations=ScaleScheme:MLMClustering.ScaleScheme", "--enum-class-locations=PartonLineScheme:MLMClustering.PartonLineScheme", + "--enum-class-locations=AlphasScheme:MLMClustering.AlphasScheme", # Fail the build on unresolvable names/expressions (e.g. an enum # default value needing its own --enum-class-locations entry) # instead of silently emitting a stub with holes in it. diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp index 6417573538..b1b1d895a1 100644 --- a/madspace/include/madspace/phasespace/mlm_clustering.hpp +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -52,6 +52,19 @@ enum class PartonLineScheme { goodjet = 1, }; +// How alpha_s is evaluated for a merged event. +enum class AlphasScheme { + // One coupling at the event's renormalisation scale, for the whole event. + none = 0, + // alphas(pt_i) at each clustering vertex where a parton is produced, which + // is what madevent does (the rewgt loop in reweight.f). A merged sample + // without it is short by one factor per emission. + per_vertex = 1, + // One coupling at the geometric mean of those vertex scales, raised to the + // number of them: the same idea with a single scale for the whole ladder. + geometric_mean = 2, +}; + class MLMClustering : public FunctionGenerator { public: MLMClustering( @@ -82,7 +95,8 @@ class MLMClustering : public FunctionGenerator { // clustering is assumed to be a QCD splitting between jets. std::vector external_pdg_ids = {}, int max_jet_flavor = 4, - PartonLineScheme parton_line_scheme = PartonLineScheme::goodjet + PartonLineScheme parton_line_scheme = PartonLineScheme::goodjet, + AlphasScheme alphas_scheme = AlphasScheme::per_vertex ); // The compiled clustering state machine, in the flat encoding the kernel @@ -93,6 +107,7 @@ class MLMClustering : public FunctionGenerator { const std::vector& external_masses() const { return _external_masses; } const std::vector& bw_masses() const { return _bw_masses; } const std::vector& bw_widths() const { return _bw_widths; } + AlphasScheme alphas_scheme() const { return _alphas_scheme; } private: NamedVector build_function_impl( @@ -107,6 +122,7 @@ class MLMClustering : public FunctionGenerator { JetScaleScheme _jet_scale_scheme; ScaleScheme _scale_scheme; PartonLineScheme _parton_line_scheme; + AlphasScheme _alphas_scheme; int _beam_flags; int _jet_leg_mask; double _xqcut; diff --git a/madspace/include/madspace/phasespace/scale.hpp b/madspace/include/madspace/phasespace/scale.hpp index f5a5e6e09d..74a621e9e8 100644 --- a/madspace/include/madspace/phasespace/scale.hpp +++ b/madspace/include/madspace/phasespace/scale.hpp @@ -65,6 +65,10 @@ class EnergyScale : public FunctionGenerator { ); bool is_mlm() const { return _clustering.has_value(); } + bool mlm_alphas_reweighting() const { + return _clustering && + _clustering->alphas_scheme() != AlphasScheme::none; + } bool has_scale_range() const { return _min_scale > 0. || _max_scale > 0.; diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index 6ebfe514d3..18fabf42ff 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -2253,6 +2253,9 @@ mlm_clustering_hadronic: - name: parton_line_scheme type: [int, single] desc: + - name: alphas_scheme + type: [int, single] + desc: outputs: - name: ren_scale type: [float] @@ -2328,6 +2331,9 @@ mlm_clustering_leptonic: - name: parton_line_scheme type: [int, single] desc: + - name: alphas_scheme + type: [int, single] + desc: outputs: - name: ren_scale type: [float] diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index cf087d793b..b4cc38bd24 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -31,6 +31,10 @@ constexpr int SCALES_MADEVENT = 1; // daughter's own flavour, or from the propagated goodjet flag of reweight.f. constexpr int LINE_FLAVOR = 0; constexpr int LINE_GOODJET = 1; +// How alpha_s is evaluated for a merged event. +constexpr int ALPHAS_NONE = 0; // one coupling at mu_R, as before +constexpr int ALPHAS_PER_VERTEX = 1; // alphas(pt_i) at each vertex, as madevent +constexpr int ALPHAS_GEOMETRIC = 2; // alphas at the geometric mean of the pt_i // trace_data bits above the 2-bit trace mode: the flavour of the mother. constexpr int TRACE_IS_JET_IN = 1 << 2; constexpr int TRACE_IS_COLORED_IN = 1 << 3; @@ -226,6 +230,7 @@ KERNELSPEC void mlm_clustering( IIn beam_flags, IIn jet_leg_mask, IIn parton_line_scheme, + IIn alphas_scheme, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -798,23 +803,42 @@ KERNELSPEC void mlm_clustering( // reweight.f drops an event outright when a reweighted vertex sits at or // below 2 GeV, where the coupling is not to be trusted; alphas_weight is // that veto. + // + // ALPHAS_GEOMETRIC hands every reweighted vertex the same scale, the + // geometric mean of the individual ones, so the product below turns into + // alphas()^n. That is the cheaper approximation of the same idea: one + // coupling for the whole ladder rather than one per rung. bool alphas_ok = true; + FVal log_sum = 0.0; + int reweighted = 0; for (int i = 0; i < cluster_max; ++i) { if ((cluster_history[i] >> 27) & 1) { FVal scale = cluster_scales[i]; if (!(scale * scale > 4.0)) { alphas_ok = false; + } else { + log_sum += log(scale); + ++reweighted; } - alphas_scales[i] = scale; - } else { + } + } + FVal mean_scale = + reweighted > 0 ? exp(log_sum / reweighted) : ren_scale_val; + for (int i = 0; i < cluster_max; ++i) { + bool is_qcd_vertex = (cluster_history[i] >> 27) & 1; + if (alphas_scheme == ALPHAS_NONE || !is_qcd_vertex) { alphas_scales[i] = ren_scale_val; + } else if (alphas_scheme == ALPHAS_GEOMETRIC) { + alphas_scales[i] = mean_scale; + } else { + alphas_scales[i] = cluster_scales[i]; } } // Kept apart from xqcut_weight: that one is the merging cut and nothing // else, and only the reweighting cares where the coupling stops being // usable. - alphas_weight = alphas_ok ? 1.0 : 0.0; + alphas_weight = (alphas_scheme == ALPHAS_NONE || alphas_ok) ? 1.0 : 0.0; xqcut_weight = passes_xqcut ? 1.0 : 0.0; int diag_count = state_machine[state]; @@ -842,6 +866,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( IIn beam_flags, IIn jet_leg_mask, IIn parton_line_scheme, + IIn alphas_scheme, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -867,6 +892,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( beam_flags, jet_leg_mask, parton_line_scheme, + alphas_scheme, ren_scale, fact_scale1, fact_scale2, @@ -896,6 +922,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( IIn beam_flags, IIn jet_leg_mask, IIn parton_line_scheme, + IIn alphas_scheme, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -921,6 +948,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( beam_flags, jet_leg_mask, parton_line_scheme, + alphas_scheme, ren_scale, fact_scale1, fact_scale2, diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index 17bd4439e9..e003d895af 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -594,7 +594,7 @@ NamedVector Integrand::build_channel_part( // // The kernel hands back mu_R for any vertex it does not reweight, so // that vertex's ratio is one and no mask is needed here. - if (_running_coupling) { + if (_energy_scale->mlm_alphas_reweighting() && _running_coupling) { auto vertex_scales = scales.at("alphas_scales"); std::size_t vertex_count = vertex_scales.type.shape.at(0); if (vertex_count > 0) { diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index 359ea787bc..778311a181 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -379,7 +379,8 @@ MLMClustering::MLMClustering( bool hadronic, std::vector external_pdg_ids, int max_jet_flavor, - PartonLineScheme parton_line_scheme + PartonLineScheme parton_line_scheme, + AlphasScheme alphas_scheme ) : FunctionGenerator( "MLMClustering", @@ -400,6 +401,7 @@ MLMClustering::MLMClustering( _jet_scale_scheme(jet_scale_scheme), _scale_scheme(scale_scheme), _parton_line_scheme(parton_line_scheme), + _alphas_scheme(alphas_scheme), _beam_flags(0), _jet_leg_mask(0), _xqcut(xqcut), @@ -667,7 +669,8 @@ NamedVector MLMClustering::build_function_impl( static_cast(_scale_scheme), static_cast(_beam_flags), static_cast(_jet_leg_mask), - static_cast(_parton_line_scheme) + static_cast(_parton_line_scheme), + static_cast(_alphas_scheme) ); } else { mlm_out = fb.mlm_clustering_leptonic( @@ -685,7 +688,8 @@ NamedVector MLMClustering::build_function_impl( static_cast(_scale_scheme), static_cast(_beam_flags), static_cast(_jet_leg_mask), - static_cast(_parton_line_scheme) + static_cast(_parton_line_scheme), + static_cast(_alphas_scheme) ); } return {return_types().keys(), {mlm_out.begin(), mlm_out.end()}}; diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index 763ffaeaaf..89eef8e414 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -1008,6 +1008,15 @@ PYBIND11_MODULE(_madspace_py, m) { {"production", JetScaleScheme::production}, } ); + add_enum( + mlm, + "AlphasScheme", + { + {"none", AlphasScheme::none}, + {"per_vertex", AlphasScheme::per_vertex}, + {"geometric_mean", AlphasScheme::geometric_mean}, + } + ); add_enum( mlm, "PartonLineScheme", @@ -1032,7 +1041,8 @@ PYBIND11_MODULE(_madspace_py, m) { bool, std::vector, int, - PartonLineScheme>(), + PartonLineScheme, + AlphasScheme>(), py::arg("topologies"), py::arg("permutations"), py::arg("diagram_indices"), @@ -1046,7 +1056,8 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("hadronic") = true, py::arg("external_pdg_ids") = std::vector{}, py::arg("max_jet_flavor") = 4, - py::arg("parton_line_scheme") = PartonLineScheme::goodjet + py::arg("parton_line_scheme") = PartonLineScheme::goodjet, + py::arg("alphas_scheme") = AlphasScheme::per_vertex ) .def_property_readonly( "cluster_state_machine", &MLMClustering::cluster_state_machine From 408185e08283c0dc7d4843db44d1e3d604021f3b Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 10 Sep 2026 11:00:54 +0200 Subject: [PATCH 34/81] Write the mg7 run-card parameters banner.py declares The TOML card is rendered from a template of explicit %(section.key)s placeholders, not from the parameter list, so a parameter added with add_toml_param and not added here exists but never appears in a generated card. parton_line_scheme and alphas_reweighting were both in that state: readable if a user typed the line themselves, invisible otherwise. Found while adding a third one and wondering why it was not in the card. Co-Authored-By: Claude Opus 5 --- madgraph/iolibs/template_files/mg7/run_card.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index 1cf95df904..9a1b874d0a 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -53,6 +53,18 @@ jet_scale_scheme = %(beam.jet_scale_scheme)s # options: clustering_mean (geometric mean of every clustering scale), # madevent (follow each beam's parton line, as madevent does) scale_scheme = %(beam.scale_scheme)s +# how a beam's parton line is decided to carry on past a clustering, under +# scale_scheme = "madevent" +# options: goodjet (a line that has been through a non-jet vertex is not a +# parton line any more, as madevent does), flavor (read each +# daughter's own flavour) +parton_line_scheme = %(beam.parton_line_scheme)s +# where alpha_s is evaluated for a merged event, under +# dynamical_scale_choice = "mlm" +# options: per_vertex (at the scale of each clustering that produced a parton, +# as madevent does), geometric_mean (one coupling at the geometric +# mean of those scales), none (a single coupling at mu_R) +alphas_reweighting = %(beam.alphas_reweighting)s # floor on mu_R and mu_F: an event whose scales fall below this is dropped, # since a PDF grid has no densities there (0 disables it) min_scale = %(beam.min_scale)s From c4c2cb8b8eb90a7fded76d9ce518c03ef6ff3fba Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 10 Sep 2026 11:01:11 +0200 Subject: [PATCH 35/81] Reweight the beam densities along the clustering ladder The second thing madevent does to a merged event that a single scale does not describe. A merged event's beam density belongs at the scale of the emission that pulled the parton out of the beam, not at the scale of the hard process, and madevent gets there in two moves (the pdf half of rewgt in Template/LO/SubProcesses/reweight.f, under the hidden pdfwgt flag, which is on by default there): it evaluates the density at min(pt(jfirst), mu_F), low on the clustering ladder, then walks each beam line back up, multiplying by f(x z, Q_i) / f(x z, Q_i-1) at every further clustering that line takes part in, with the momentum fraction rescaled by that clustering's z as it goes. The kernel decides the scales, the momentum fractions and the beam each step sits on, and records the clustering z it did not need before. What it cannot decide is the flavour: one clustering serves every flavour channel of a subprocess, external_pdg_ids is a single representative with the signs stripped, and the flavour is in any case sampled from the very densities this is correcting. So the kernel names a *class* - the gluon, one of the two beams' own flavours, or a flavour the diagram pinned down - and the integrand resolves it once the sampling has happened. Gluon-versus-quark and an unchanged quark line are exact; a line whose flavour changed along the way, which for W+jets means past the W vertex, falls back to the representative. That is an approximation, but only on lines the representative was already guessing at, and a density ratio between two nearby scales is far more sensitive to quark-versus-gluon than to which quark. Under beam.pdf_reweighting, default true to match madevent, and only live under dynamical_scale_choice = "mlm" with scale_scheme = "madevent", since without that scheme there is no beam parton line to walk. p p > w+ 0-3j, xqcut 15, 150k events each side, NNPDF23_lo_as_0130_qed, as a ratio to madevent per jet multiplicity: 0j 1j 2j 3j total madevent pdfwgt on/off, for reference: 1.000 0.929 0.858 0.797 0.9455 madspace off 1.000 0.991 1.005 1.028 1.0001 (vs madevent off) madspace on 1.000 0.999 1.005 0.981 0.9987 (vs madevent on) 10760.0 -> 10761.1 pb with both off, 11380.0 -> 11365.7 pb with both on. The 8-22% deficit that was left after the alpha_s reweighting was all of it this. Co-Authored-By: Claude Opus 5 --- madgraph/iolibs/template_files/mg7/launch.py | 3 + .../iolibs/template_files/mg7/run_card.toml | 4 + madgraph/various/banner.py | 8 + .../include/madspace/phasespace/integrand.hpp | 9 + .../madspace/phasespace/mlm_clustering.hpp | 15 +- .../include/madspace/phasespace/scale.hpp | 9 + madspace/instruction_set.yaml | 54 ++++ madspace/src/kernels/mlm.hpp | 224 ++++++++++++++++- madspace/src/phasespace/integrand.cpp | 140 ++++++++++- madspace/src/phasespace/mlm_clustering.cpp | 135 +++++++++- madspace/src/phasespace/scale.cpp | 12 +- madspace/src/python/madspace.cpp | 9 +- madspace/tests/test_mlm.py | 231 ++++++++++++++++++ 13 files changed, 836 insertions(+), 17 deletions(-) diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index 566bb025c2..0c3d63f5ef 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -1646,6 +1646,9 @@ def __init__( ms.MLMClustering.AlphasScheme, self.process.run_card["beam"]["alphas_reweighting"], ), + pdf_reweighting=self.process.run_card["beam"][ + "pdf_reweighting" + ], ), min_scale=self.process.run_card["beam"]["min_scale"], max_scale=max_scale, diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index 9a1b874d0a..f12b7ab059 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -65,6 +65,10 @@ parton_line_scheme = %(beam.parton_line_scheme)s # as madevent does), geometric_mean (one coupling at the geometric # mean of those scales), none (a single coupling at mu_R) alphas_reweighting = %(beam.alphas_reweighting)s +# re-evaluate the beam densities along the clustering ladder rather than once +# at the factorisation scale, as madevent's pdfwgt does. Only used under +# dynamical_scale_choice = "mlm" and scale_scheme = "madevent" +pdf_reweighting = %(beam.pdf_reweighting)s # floor on mu_R and mu_F: an event whose scales fall below this is dropped, # since a PDF grid has no densities there (0 disables it) min_scale = %(beam.min_scale)s diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index a5c6754e8f..7e73c40227 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6581,6 +6581,14 @@ def default_setup(self): # Only read when dynamical_scale_choice = "mlm". self.add_toml_param('beam', 'alphas_reweighting', "per_vertex", allowed=['per_vertex', 'geometric_mean', 'none']) + # Re-evaluate the beam densities along the clustering ladder instead of + # once at the factorisation scale: the density is taken at the scale of + # the emission that pulled the parton out of the beam and walked back + # up, with the momentum fraction rescaled at each step. This is the + # pdf half of the same rewgt loop, madevent's hidden pdfwgt flag, which + # is on by default there. Only read when dynamical_scale_choice = "mlm" + # and scale_scheme = "madevent". + self.add_toml_param('beam', 'pdf_reweighting', True) # Floor on mu_R and mu_F, whatever the dynamical scale choice. Below # the lowest Q of a PDF grid the densities are undefined, so an event # whose scales fall under this is dropped. madevent applies the same diff --git a/madspace/include/madspace/phasespace/integrand.hpp b/madspace/include/madspace/phasespace/integrand.hpp index 40f9fe8365..0179739b37 100644 --- a/madspace/include/madspace/phasespace/integrand.hpp +++ b/madspace/include/madspace/phasespace/integrand.hpp @@ -127,6 +127,15 @@ class Integrand : public FunctionGenerator { nested_vector2 _pid_options; std::array, 2> _pdfs; std::array, 2> _pdf_indices; + // The pdf reweighting asks for densities the flavour sampling never does: + // the flavour of a beam line partway up the clustering ladder. One density + // over every flavour that can turn up there, picked per event by index. + std::optional _pdf_rw; + // Flavour class (as the mlm kernel emits it) and sampled flavour option to + // an index into that density, flattened as class * option_count + option, + // with the class offsets alongside so that the two can be added as ints. + std::vector _pdf_rw_table; + std::vector _pdf_rw_class_offsets; std::optional _running_coupling; std::optional _energy_scale; std::optional _prop_chan_weights; diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp index b1b1d895a1..7c10c6de63 100644 --- a/madspace/include/madspace/phasespace/mlm_clustering.hpp +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -96,7 +96,11 @@ class MLMClustering : public FunctionGenerator { std::vector external_pdg_ids = {}, int max_jet_flavor = 4, PartonLineScheme parton_line_scheme = PartonLineScheme::goodjet, - AlphasScheme alphas_scheme = AlphasScheme::per_vertex + AlphasScheme alphas_scheme = AlphasScheme::per_vertex, + // Re-evaluate the beam densities along the clustering ladder instead of + // once at the factorisation scale, which is what madevent does for a + // merged sample (pdfwgt in the run card, hidden and on by default). + bool pdf_reweighting = true ); // The compiled clustering state machine, in the flat encoding the kernel @@ -108,6 +112,13 @@ class MLMClustering : public FunctionGenerator { const std::vector& bw_masses() const { return _bw_masses; } const std::vector& bw_widths() const { return _bw_widths; } AlphasScheme alphas_scheme() const { return _alphas_scheme; } + bool pdf_reweighting() const { return _pdf_reweighting; } + // The flavours the pdf reweighting asks for that are neither the gluon nor + // a beam's own, in the order the kernel's flavour classes index them. The + // consumer turns these, the gluon and the per-channel beam flavours into + // the density it evaluates; see the flavour-class comment in + // mlm_clustering.cpp. + const std::vector& pdf_absolute_pdgs() const { return _pdf_absolute_pdgs; } private: NamedVector build_function_impl( @@ -123,6 +134,8 @@ class MLMClustering : public FunctionGenerator { ScaleScheme _scale_scheme; PartonLineScheme _parton_line_scheme; AlphasScheme _alphas_scheme; + bool _pdf_reweighting; + std::vector _pdf_absolute_pdgs; int _beam_flags; int _jet_leg_mask; double _xqcut; diff --git a/madspace/include/madspace/phasespace/scale.hpp b/madspace/include/madspace/phasespace/scale.hpp index 74a621e9e8..f4b2626bec 100644 --- a/madspace/include/madspace/phasespace/scale.hpp +++ b/madspace/include/madspace/phasespace/scale.hpp @@ -69,6 +69,15 @@ class EnergyScale : public FunctionGenerator { return _clustering && _clustering->alphas_scheme() != AlphasScheme::none; } + bool mlm_pdf_reweighting() const { + return _clustering && _clustering->pdf_reweighting(); + } + const std::vector& mlm_pdf_absolute_pdgs() const { + return _clustering.value().pdf_absolute_pdgs(); + } + // The band the pdf grid covers, for whoever has to keep a scale inside it. + double min_scale() const { return _min_scale; } + double max_scale() const { return _max_scale; } bool has_scale_range() const { return _min_scale > 0. || _max_scale > 0.; diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index 18fabf42ff..b164c0eb17 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -2256,6 +2256,9 @@ mlm_clustering_hadronic: - name: alphas_scheme type: [int, single] desc: + - name: pdf_reweighting + type: [int, single] + desc: outputs: - name: ren_scale type: [float] @@ -2281,6 +2284,30 @@ mlm_clustering_hadronic: - name: alphas_weight type: [float] desc: zero when a reweighted vertex sits where the coupling is not usable + - name: pdf_scale1 + type: [float] + desc: scale the beam-1 density is evaluated at, below fact_scale1 when reweighting + - name: pdf_scale2 + type: [float] + desc: scale the beam-2 density is evaluated at, below fact_scale2 when reweighting + - name: pdf_rw_flavor + type: [int, n-1] + desc: flavour class of the beam line at each pdf reweighting step + - name: pdf_rw_x + type: [float, n-1] + desc: momentum fraction of the beam line at each step, relative to the beam + - name: pdf_rw_q_num + type: [float, n-1] + desc: scale the density is reweighted to at each step + - name: pdf_rw_q_den + type: [float, n-1] + desc: scale the density is reweighted from at each step + - name: pdf_rw_active + type: [float, n-1] + desc: one where a step contributes a pdf ratio, zero for an inert slot + - name: pdf_rw_beam + type: [float, n-1] + desc: which beam each step sits on, as 0 or 1 vectorized: False @@ -2334,6 +2361,9 @@ mlm_clustering_leptonic: - name: alphas_scheme type: [int, single] desc: + - name: pdf_reweighting + type: [int, single] + desc: outputs: - name: ren_scale type: [float] @@ -2359,6 +2389,30 @@ mlm_clustering_leptonic: - name: alphas_weight type: [float] desc: zero when a reweighted vertex sits where the coupling is not usable + - name: pdf_scale1 + type: [float] + desc: scale the beam-1 density is evaluated at, below fact_scale1 when reweighting + - name: pdf_scale2 + type: [float] + desc: scale the beam-2 density is evaluated at, below fact_scale2 when reweighting + - name: pdf_rw_flavor + type: [int, n-1] + desc: flavour class of the beam line at each pdf reweighting step + - name: pdf_rw_x + type: [float, n-1] + desc: momentum fraction of the beam line at each step, relative to the beam + - name: pdf_rw_q_num + type: [float, n-1] + desc: scale the density is reweighted to at each step + - name: pdf_rw_q_den + type: [float, n-1] + desc: scale the density is reweighted from at each step + - name: pdf_rw_active + type: [float, n-1] + desc: one where a step contributes a pdf ratio, zero for an inert slot + - name: pdf_rw_beam + type: [float, n-1] + desc: which beam each step sits on, as 0 or 1 vectorized: False diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index b4cc38bd24..344bec75bf 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -38,6 +38,13 @@ constexpr int ALPHAS_GEOMETRIC = 2; // alphas at the geometric mean of the p // trace_data bits above the 2-bit trace mode: the flavour of the mother. constexpr int TRACE_IS_JET_IN = 1 << 2; constexpr int TRACE_IS_COLORED_IN = 1 << 3; +// Bits 4..11 hold the mother's pdf flavour class plus one, so that zero means +// the line is not a parton and the pdf reweighting chain stops there. The +// classes themselves are assigned in mlm_clustering.cpp; the kernel only +// carries them, since which density they stand for is not known until the +// flavour has been sampled. +constexpr int TRACE_FLAVOR_SHIFT = 4; +constexpr int TRACE_FLAVOR_MASK = 0xFF; // mT^2 = E^2 - pz^2 (hadronic) or E^2 (lepton collider). // based on djb_clus from Template/NLO/SubProcesses/cluster.f @@ -231,6 +238,7 @@ KERNELSPEC void mlm_clustering( IIn jet_leg_mask, IIn parton_line_scheme, IIn alphas_scheme, + IIn pdf_reweighting, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -239,6 +247,14 @@ KERNELSPEC void mlm_clustering( FOut xqcut_weight, FOut alphas_scales, FOut alphas_weight, + FOut pdf_scale1, + FOut pdf_scale2, + IOut pdf_rw_flavor, + FOut pdf_rw_x, + FOut pdf_rw_q_num, + FOut pdf_rw_q_den, + FOut pdf_rw_active, + FOut pdf_rw_beam, bool hadronic ) { // we do not support SIMD for now, so we can assume simple types @@ -263,6 +279,10 @@ KERNELSPEC void mlm_clustering( // zero for a final-state one. This is mt2ij in cluster.f, kept // unsquared like every other scale here. FVal cluster_mt[N_EXT_MAX - 3]; + // The Pythia ISR momentum fraction of each initial-state clustering, + // zcl in cluster.f, zero for a final-state one. The pdf reweighting walks + // the beam's momentum fraction down the ladder with it. + FVal cluster_z[N_EXT_MAX - 3]; for (int i = 0; i < n_part; ++i) { for (int j = 0; j < 4; ++j) { @@ -350,6 +370,31 @@ KERNELSPEC void mlm_clustering( cluster_mt[cluster_count] = p1_win < 2 ? sqrt(djb_clus(momenta_tmp[p2_win], hadronic)) : FVal(0.0); + // zclus() of Template/LO/Source/kin_functions.f: the ratio of the + // partonic invariants before and after the emission is taken back + // out of the beam, both measured against the other beam. Taken + // here, before update_momenta, because that is where cluster.f + // takes it; the boosts it applies preserve the dot products + // anyway. + cluster_z[cluster_count] = 0.0; + if (p1_win < 2) { + int other_beam = 1 - p1_win; + FourMom sum_prev, sum_red; + for (int k = 0; k < 4; ++k) { + sum_prev[k] = + momenta_tmp[p1_win][k] + momenta_tmp[other_beam][k]; + sum_red[k] = momenta_tmp[p1_win][k] - + momenta_tmp[p2_win][k] + momenta_tmp[other_beam][k]; + } + FVal s_prev = lsquare(sum_prev); + FVal s_red = lsquare(sum_red); + // The Fortran gives up below 1 GeV^2 rather than dividing; + // a z of zero then fails the 0 < z < 1 test downstream and the + // momentum fraction is left alone. + if (s_red >= 1.0 && s_prev > 0.0) { + cluster_z[cluster_count] = s_red / s_prev; + } + } // The mother keeps slot p1_win. A final-state clustering merges the // two daughters; an initial-state one takes the emission back out // of the beam, as pcl(imo) = pcl(ida1) - pcl(ida2) does in @@ -536,6 +581,30 @@ KERNELSPEC void mlm_clustering( } FVal fac_scale1 = fac_scale, fac_scale2 = fac_scale; + // One pdf reweighting slot per clustering that can sit on a beam line, + // plus one per beam for the 2 -> 1 root, which sits on both. A slot that + // no step claims stays inert: its two scales are equal, so its ratio is + // one, and its weight is one whatever the density comes out as. + int pdf_slot_count = n_part - 1; + int rw_flavor[N_EXT_MAX - 1]; + FVal rw_x[N_EXT_MAX - 1]; + FVal rw_q_num[N_EXT_MAX - 1]; + FVal rw_q_den[N_EXT_MAX - 1]; + FVal rw_active[N_EXT_MAX - 1]; + // Which beam a slot sits on. Only the two root slots know that statically; + // for the rest it depends on which clustering won, so it has to travel + // with the slot. + FVal rw_beam[N_EXT_MAX - 1]; + for (int i = 0; i < pdf_slot_count; ++i) { + rw_flavor[i] = 0; + rw_x[i] = 1.0; + rw_q_num[i] = 1.0; + rw_q_den[i] = 1.0; + rw_active[i] = 0.0; + rw_beam[i] = 0.0; + } + FVal pdf_scale_val[2] = {-1.0, -1.0}; + if (scale_scheme == SCALES_MADEVENT) { // madevent clusters one step further than this kernel does: its loop // runs all the way to the 2 -> 1 configuration, so it has one more @@ -783,6 +852,96 @@ KERNELSPEC void mlm_clustering( if (!(fac_scale2 > 0.0) || !(fac_scale2 < SCALE_MAX)) { fac_scale2 = ren_scale_val; } + + // The pdf half of rewgt in Template/LO/SubProcesses/reweight.f, the + // other thing madevent does to a merged event that a single density at + // the factorisation scale does not. + // + // A merged event's beam density belongs at the scale of the emission + // that took the parton out of the beam, not at the scale of the hard + // process. madevent gets there in two moves: it evaluates the density + // low on the clustering ladder, at min(pt(jfirst), mu_F), and then + // walks each beam line back up, multiplying by f(x z, Q_i) / f(x z, + // Q_i-1) at every further clustering the line takes part in, with the + // momentum fraction rescaled by that clustering's z as it goes. The + // last step lands on mu_F itself, so the chain ends where the density + // would have been evaluated in the first place - except when the line + // has only one clustering on it, where the lowered scale stands with + // nothing to correct it. That asymmetry is madevent's, and it is a + // good part of why turning pdfwgt off moves the cross section at all. + // + // Only the scales, the momentum fractions and the flavour classes are + // decided here. Which density a class stands for is not knowable yet: + // the flavour is sampled from the very densities this is correcting. + bool reweight_pdf = + pdf_reweighting != 0 && jcentral[0] >= 0 && jcentral[1] >= 0; + pdf_scale_val[0] = fac_scale1; + pdf_scale_val[1] = fac_scale2; + if (reweight_pdf) { + FVal q_central[2] = {fac_scale1, fac_scale2}; + for (int j = 0; j < 2; ++j) { + if (jlast[j] >= 0 && jfirst[j] >= 0 && jfirst[j] <= jlast[j]) { + pdf_scale_val[j] = min(pt_step[jfirst[j]], q_central[j]); + } + } + // Where each beam's line has got to: its flavour class, the + // product of the z it has picked up, and the scale its density + // was last evaluated at. reweight.f keeps these as ibeam(j), + // xnow(j) and pt2pdf(ibeam(j)). + int line_class[2]; + FVal x_frac[2] = {1.0, 1.0}; + FVal pt_pdf[2] = {0.0, 0.0}; + for (int j = 0; j < 2; ++j) { + line_class[j] = + ((beam_flags >> (8 * (j + 1))) & TRACE_FLAVOR_MASK) - 1; + } + for (int i = 0; i < steps; ++i) { + for (int j = 0; j < 2; ++j) { + // A recorded clustering sits on the beam whose slot is its + // first daughter, and on no beam at all if that daughter is + // a final-state one. The root sits on both. + bool at_root = i >= cluster_max; + if (!at_root && (cluster_history[i] & 0xFF) != j) { + continue; + } + // Once a line stops being a parton reweight.f stops + // advancing ibeam(j), so no later clustering can match it + // again and the chain is over for good. + if (line_class[j] < 0) { + continue; + } + int slot = at_root ? cluster_max + j : i; + // zcl is 1 at the root, so the momentum fraction is only + // ever rescaled by the recorded clusterings. + FVal z = at_root ? FVal(1.0) : cluster_z[i]; + if (z > 0.0 && z < 1.0) { + x_frac[j] *= z; + } + FVal q_now = i == jlast[j] ? q_central[j] + : min(pt_step[i], q_central[j]); + if (!(pt_pdf[j] > 0.0)) { + // the first clustering on the line only records where + // the density it already has was evaluated + pt_pdf[j] = q_now; + } else if (pt_pdf[j] < q_now && i <= jlast[j]) { + rw_flavor[slot] = line_class[j]; + rw_x[slot] = x_frac[j]; + rw_q_num[slot] = q_now; + rw_q_den[slot] = pt_pdf[j]; + rw_active[slot] = 1.0; + rw_beam[slot] = j; + pt_pdf[j] = q_now; + } + // and the line becomes the mother; at the root that is the + // s-channel object, which ends the chain. + line_class[j] = at_root + ? -1 + : ((cluster_trace[i] >> TRACE_FLAVOR_SHIFT) & + TRACE_FLAVOR_MASK) - + 1; + } + } + } } ren_scale = ren_scale_val; @@ -841,6 +1000,31 @@ KERNELSPEC void mlm_clustering( alphas_weight = (alphas_scheme == ALPHAS_NONE || alphas_ok) ? 1.0 : 0.0; xqcut_weight = passes_xqcut ? 1.0 : 0.0; + // Any scale scheme other than madevent's has no clustering ladder to walk, + // so the density stays where the factorisation scale put it. + if (!(pdf_scale_val[0] > 0.0)) { + pdf_scale_val[0] = fac_scale1; + } + if (!(pdf_scale_val[1] > 0.0)) { + pdf_scale_val[1] = fac_scale2; + } + pdf_scale1 = pdf_scale_val[0]; + pdf_scale2 = pdf_scale_val[1]; + for (int i = 0; i < pdf_slot_count; ++i) { + if (rw_active[i] == 0.0) { + // Somewhere the density is defined, so that an inert slot cannot + // trip the low-density veto the consumer applies. + rw_q_num[i] = pdf_scale_val[0]; + rw_q_den[i] = pdf_scale_val[0]; + } + pdf_rw_flavor[i] = rw_flavor[i]; + pdf_rw_x[i] = rw_x[i]; + pdf_rw_q_num[i] = rw_q_num[i]; + pdf_rw_q_den[i] = rw_q_den[i]; + pdf_rw_active[i] = rw_active[i]; + pdf_rw_beam[i] = rw_beam[i]; + } + int diag_count = state_machine[state]; int rand_index = static_cast(FVal(random) * diag_count); if (rand_index >= diag_count) { @@ -867,6 +1051,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( IIn jet_leg_mask, IIn parton_line_scheme, IIn alphas_scheme, + IIn pdf_reweighting, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -874,7 +1059,15 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( IOut diagram_index, FOut xqcut_weight, FOut alphas_scales, - FOut alphas_weight + FOut alphas_weight, + FOut pdf_scale1, + FOut pdf_scale2, + IOut pdf_rw_flavor, + FOut pdf_rw_x, + FOut pdf_rw_q_num, + FOut pdf_rw_q_den, + FOut pdf_rw_active, + FOut pdf_rw_beam ) { mlm_clustering( momenta, @@ -893,6 +1086,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( jet_leg_mask, parton_line_scheme, alphas_scheme, + pdf_reweighting, ren_scale, fact_scale1, fact_scale2, @@ -901,6 +1095,14 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( xqcut_weight, alphas_scales, alphas_weight, + pdf_scale1, + pdf_scale2, + pdf_rw_flavor, + pdf_rw_x, + pdf_rw_q_num, + pdf_rw_q_den, + pdf_rw_active, + pdf_rw_beam, true ); } @@ -923,6 +1125,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( IIn jet_leg_mask, IIn parton_line_scheme, IIn alphas_scheme, + IIn pdf_reweighting, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -930,7 +1133,15 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( IOut diagram_index, FOut xqcut_weight, FOut alphas_scales, - FOut alphas_weight + FOut alphas_weight, + FOut pdf_scale1, + FOut pdf_scale2, + IOut pdf_rw_flavor, + FOut pdf_rw_x, + FOut pdf_rw_q_num, + FOut pdf_rw_q_den, + FOut pdf_rw_active, + FOut pdf_rw_beam ) { mlm_clustering( momenta, @@ -949,6 +1160,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( jet_leg_mask, parton_line_scheme, alphas_scheme, + pdf_reweighting, ren_scale, fact_scale1, fact_scale2, @@ -957,6 +1169,14 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( xqcut_weight, alphas_scales, alphas_weight, + pdf_scale1, + pdf_scale2, + pdf_rw_flavor, + pdf_rw_x, + pdf_rw_q_num, + pdf_rw_q_den, + pdf_rw_active, + pdf_rw_beam, false ); } diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index e003d895af..a0b10141f3 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -222,6 +222,44 @@ Integrand::Integrand( } _pdfs.at(i) = PartonDensity(pdf_grid.value(), {pids.begin(), pids.end()}); } + if (energy_scale && energy_scale->mlm_pdf_reweighting()) { + // Everything the reweighting can ask for: the gluon, whatever the + // beams can be, and the flavours the clustering pinned down along + // the way. The kernel names these by class, not by pdg, because + // one clustering serves every flavour channel; the table turns + // (class, sampled option) into an index into this one density. + auto& absolute = energy_scale->mlm_pdf_absolute_pdgs(); + std::vector rw_pids{21}; + auto index_of = [&](int pid) { + auto found = std::find(rw_pids.begin(), rw_pids.end(), pid); + if (found == rw_pids.end()) { + rw_pids.push_back(pid); + return static_cast(rw_pids.size() - 1); + } + return static_cast(found - rw_pids.begin()); + }; + std::size_t class_count = 3 + absolute.size(); + std::size_t option_count = std::max(pid_options.size(), 1); + _pdf_rw_table.resize(class_count * option_count); + for (std::size_t f = 0; f < option_count; ++f) { + _pdf_rw_table.at(0 * option_count + f) = index_of(21); + for (std::size_t beam = 0; beam < 2; ++beam) { + _pdf_rw_table.at((1 + beam) * option_count + f) = index_of( + pid_options.size() > 0 ? pid_options.at(f).at(beam) : 21 + ); + } + for (std::size_t c = 0; c < absolute.size(); ++c) { + _pdf_rw_table.at((3 + c) * option_count + f) = + index_of(absolute.at(c)); + } + } + for (std::size_t c = 0; c < class_count; ++c) { + _pdf_rw_class_offsets.push_back( + static_cast(c * option_count) + ); + } + _pdf_rw = PartonDensity(pdf_grid.value(), rw_pids, true); + } } if (active_flavors.size() > 0) { @@ -473,11 +511,19 @@ NamedVector Integrand::build_channel_part( ValueVec pdf_priors; for (std::size_t i = 0; i < 2; ++i) { if (_diff_xs.at(0).has_pdf(i)) { - auto pdf = - _pdfs.at(i) - .value() - .build_function(fb, {x_acc.at(i), scales.at(i + 1)}) - .at(0); + // Under pdf reweighting the density is asked for low on the + // clustering ladder rather than at the factorisation scale, + // and walked back up by the ratios applied further down. This + // is madevent setting q2fact below q2bck before calling DSIG, + // and it is what the flavour prior should see too, since that + // is the density the event is actually generated with. + auto& pdf_scale = _energy_scale->mlm_pdf_reweighting() + ? scales.at(std::format("pdf_scale{}", i + 1)) + : scales.at(i + 1); + auto pdf = _pdfs.at(i) + .value() + .build_function(fb, {x_acc.at(i), pdf_scale}) + .at(0); pdf_results.at(i) = pdf; pdf_priors.push_back(fb.select(pdf, _pdf_indices.at(i))); } @@ -614,6 +660,90 @@ NamedVector Integrand::build_channel_part( weights_after_cuts.push_back(scales.at("alphas_weight")); } } + + // pdf reweighting: the beam density, evaluated above at the bottom of + // the clustering ladder, walked back up one clustering at a time. Each + // slot the kernel marked active contributes f(x, Q_i) / f(x, Q_i-1) + // for the flavour its beam line carried at that point. + // + // The flavour is only knowable here, after the sampling: the kernel + // hands out a class - the gluon, one of the two beams' own flavours, or + // a flavour the diagram pinned down - and the table turns that plus the + // sampled option into an index into the reweighting density. + if (_pdf_rw && _pid_options.size() > 0) { + auto flavor_classes = scales.at("pdf_rw_flavor"); + std::size_t slot_count = flavor_classes.type.shape.at(0); + double low = _energy_scale->min_scale(); + double high = _energy_scale->max_scale() > 0. + ? _energy_scale->max_scale() + : 1e30; + auto ones = fb.full({1., batch_size_acc}); + Value factor, veto; + for (std::size_t i = 0; i < slot_count; ++i) { + std::vector column{static_cast(i)}; + auto pick = [&](const char* name) { + return fb.squeeze(fb.select(scales.at(name), column)); + }; + auto active = pick("pdf_rw_active"); + auto beam = pick("pdf_rw_beam"); + // x of the beam line: the beam's own momentum fraction times + // everything the clustering z has taken off it since. + auto x_beam = fb.add( + x_acc.at(0), + fb.mul(beam, fb.sub(x_acc.at(1), x_acc.at(0))) + ); + auto x = fb.mul(x_beam, pick("pdf_rw_x")); + auto scale_of = [&](const char* name) { + auto q = pick(name); + if (low > 0.) { + q = fb.max(q, fb.full({low, batch_size_acc})); + } + return fb.min(q, fb.full({high, batch_size_acc})); + }; + auto index = fb.gather_int( + fb.add_int( + fb.gather_int( + fb.squeeze(fb.select_int(flavor_classes, column)), + _pdf_rw_class_offsets + ), + flavor_id + ), + _pdf_rw_table + ); + auto density = [&](const char* name) { + return _pdf_rw.value() + .build_function(fb, {x, scale_of(name), index}) + .at(0); + }; + auto numerator = density("pdf_rw_q_num"); + auto denominator = density("pdf_rw_q_den"); + // madevent drops the event outright when the density it is + // dividing by falls under 1e-10, where the grid is no longer + // saying anything. Same threshold, as a ramp rather than a + // branch, and the floor under the division keeps the term + // finite so that a vetoed slot multiplies to zero and not to + // a NaN. + auto floor = fb.full({1e-10, batch_size_acc}); + auto usable = fb.min( + fb.max( + fb.mul(denominator, fb.full({1e10, batch_size_acc})), + fb.full({0., batch_size_acc}) + ), + ones + ); + auto ratio = fb.div(numerator, fb.max(denominator, floor)); + // An inert slot contributes exactly one, whatever its density + // came out as. + auto term = fb.add(ones, fb.mul(active, fb.sub(ratio, ones))); + auto pass = fb.add(ones, fb.mul(active, fb.sub(usable, ones))); + factor = factor ? fb.mul(factor, term) : term; + veto = veto ? fb.mul(veto, pass) : pass; + } + if (factor) { + weights_after_cuts.push_back(factor); + weights_after_cuts.push_back(veto); + } + } } if (_energy_scale && _energy_scale->has_scale_range()) { // Same for the floor on the scales themselves, which applies to every diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index 778311a181..7056a02174 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -33,6 +33,42 @@ bool is_jet_pdg(int pdg_id, int max_jet_flavor) { return (a >= 1 && a <= max_jet_flavor) || a == 21 || a == 81; } +// Mirrors isparton() in Template/LO/SubProcesses/reweight.f: a line that can +// be a beam parton, i.e. one the pdf reweighting can follow back into a beam. +// Wider than is_jet_pdg: madevent tests against max(asrwgtflavor, maxjetflavor) +// and asrwgtflavor defaults to 5, so the b is a parton even when it is not a +// jet. +bool is_parton_pdg(int pdg_id) { + int a = std::abs(pdg_id); + return (a >= 1 && a <= 5) || a == 21 || a == 81; +} + +// Which density the pdf reweighting should ask for on a given line. +// +// The flavour cannot simply be baked in. One MLMClustering serves every flavour +// channel of a subprocess, and external_pdg_ids is a single representative +// assignment with the signs stripped (clean_pids in launch.py), so "the beam is +// a u" is only true of one channel out of many. What is stable across the +// channels is the *structure*: whether a line is a gluon, and whether it still +// carries the flavour the beam came in with. Those two are baked here as +// classes; the integrand resolves them per event once the flavour has been +// sampled. +// +// Anything else - a quark line whose flavour changed along the way, which for +// W+jets means past the W vertex - falls back to an absolute pdg taken from the +// representative assignment. That is a real approximation, but it is confined +// to lines whose flavour the representative was already guessing at, and a pdf +// ratio between two nearby scales is far more sensitive to quark-versus-gluon +// than to which quark. +constexpr int flavor_class_none = -1; +constexpr int flavor_class_gluon = 0; +constexpr int flavor_class_beam1 = 1; +constexpr int flavor_class_beam2 = 2; +constexpr int flavor_class_first_absolute = 3; +// _MERGED_PID_REPRESENTATIVE in launch.py, which is what external_pdg_ids has +// already been mapped through by the time it gets here. +constexpr int merged_quark_representative = 1; + // Color representation of a line, as the signed color of the model (1 for a // singlet, +-3 for a (anti)triplet, 8 for an octet, +-6 for a sextet). The // merged-flavor placeholders that survive in propagator pdg ids stand for the @@ -123,8 +159,52 @@ struct CompileContext { const std::unordered_map& pdg_color_types; bool have_pdg_ids; int max_jet_flavor; + // |pdg| of each beam, to recognise a line that still carries the flavour + // the beam came in with. + int beam_pdg[2]; + // The absolute flavours needed beyond gluon and the two beams, in the + // order the classes above index them. + std::vector& absolute_pdgs; }; +// The flavour class of one line, allocating a new absolute class if it needs +// one. flavor_class_none for anything the reweighting cannot follow. +// +// on_beam is the beam a line sits on, or -1 for one that sits on neither. It +// only matters for the flavour-merged placeholder 81, which says "a light +// quark" without saying which: on a quark beam that is the beam's own flavour +// and resolves exactly, and anywhere else it falls back to the representative +// quark that clean_pids in launch.py would have picked. +int flavor_class(CompileContext& ctx, int pdg_id, int on_beam = -1) { + if (!ctx.have_pdg_ids || !is_parton_pdg(pdg_id)) { + return flavor_class_none; + } + int a = std::abs(pdg_id); + if (a == 21) { + return flavor_class_gluon; + } + if (a == 81) { + if (on_beam >= 0 && std::abs(ctx.beam_pdg[on_beam]) != 21 && + is_parton_pdg(ctx.beam_pdg[on_beam])) { + return flavor_class_beam1 + on_beam; + } + a = merged_quark_representative; + } + for (int beam = 0; beam < 2; ++beam) { + if (a == std::abs(ctx.beam_pdg[beam])) { + return flavor_class_beam1 + beam; + } + } + auto found = std::find(ctx.absolute_pdgs.begin(), ctx.absolute_pdgs.end(), a); + if (found == ctx.absolute_pdgs.end()) { + ctx.absolute_pdgs.push_back(a); + return flavor_class_first_absolute + + static_cast(ctx.absolute_pdgs.size()) - 1; + } + return flavor_class_first_absolute + + static_cast(found - ctx.absolute_pdgs.begin()); +} + using StateKey = std::pair, std::vector>; struct StateItem { int next_state; @@ -142,6 +222,10 @@ struct StateItem { // a beam's parton line stops being a jet and where it stops being coloured. bool is_jet_in; bool is_colored_in; + // Flavour class of the mother, i.e. of the beam line as it stands after + // this clustering. flavor_class_none ends the pdf reweighting chain, + // which is also what stops madevent following ibeam(j) any further. + int flavor_class_in; }; // 1-based index into the Breit-Wigner tables handed to the kernel, or 0 for @@ -237,6 +321,8 @@ StateItem make_state_item( .is_jet_in = !ctx.have_pdg_ids || is_jet_pdg(meta_in.pdg_id, ctx.max_jet_flavor), .is_colored_in = !ctx.have_pdg_ids || color_in != 1, + .flavor_class_in = + flavor_class(ctx, meta_in.pdg_id, is_initial ? particle1 : -1), }; } @@ -327,6 +413,7 @@ void find_clusterings( .trace_mode = trace_first, .is_jet_in = false, .is_colored_in = false, + .flavor_class_in = flavor_class_none, }); } } else { @@ -380,7 +467,8 @@ MLMClustering::MLMClustering( std::vector external_pdg_ids, int max_jet_flavor, PartonLineScheme parton_line_scheme, - AlphasScheme alphas_scheme + AlphasScheme alphas_scheme, + bool pdf_reweighting ) : FunctionGenerator( "MLMClustering", @@ -395,13 +483,34 @@ MLMClustering::MLMClustering( {"xqcut_weight", batch_float}, {"alphas_scales", batch_float_array(topologies.at(0).outgoing_masses().size() - 1)}, - {"alphas_weight", batch_float}} + {"alphas_weight", batch_float}, + // The scale the beam density itself is evaluated at. Not the same as + // fact_scale under pdf reweighting: madevent drops the density low on + // the clustering ladder and walks it back up, so the reported + // factorisation scale and the one the pdf is asked for differ. + {"pdf_scale1", batch_float}, + {"pdf_scale2", batch_float}, + // One slot per clustering that can put a pdf ratio on a beam line, + // plus one per beam for the 2 -> 1 root, which lies on both. + {"pdf_rw_flavor", + batch_int_array(topologies.at(0).outgoing_masses().size() + 1)}, + {"pdf_rw_x", + batch_float_array(topologies.at(0).outgoing_masses().size() + 1)}, + {"pdf_rw_q_num", + batch_float_array(topologies.at(0).outgoing_masses().size() + 1)}, + {"pdf_rw_q_den", + batch_float_array(topologies.at(0).outgoing_masses().size() + 1)}, + {"pdf_rw_active", + batch_float_array(topologies.at(0).outgoing_masses().size() + 1)}, + {"pdf_rw_beam", + batch_float_array(topologies.at(0).outgoing_masses().size() + 1)}} ), _cm_energy(cm_energy), _jet_scale_scheme(jet_scale_scheme), _scale_scheme(scale_scheme), _parton_line_scheme(parton_line_scheme), _alphas_scheme(alphas_scheme), + _pdf_reweighting(pdf_reweighting), _beam_flags(0), _jet_leg_mask(0), _xqcut(xqcut), @@ -434,6 +543,14 @@ MLMClustering::MLMClustering( if (is_jet_pdg(pdg, max_jet_flavor)) { _beam_flags |= 1 << (2 * beam + 1); } + // The flavour class the pdf reweighting starts each beam line at, + // stored as class + 1 so that 0 means "not a parton" and the chain + // never starts. A beam that is a parton is by construction either the + // gluon or its own class, so this needs no lookup. + int beam_class = !have_pdg_ids || !is_parton_pdg(pdg) ? flavor_class_none + : std::abs(pdg) == 21 ? flavor_class_gluon + : flavor_class_beam1 + beam; + _beam_flags |= (beam_class + 1) << (8 * (beam + 1)); } for (std::size_t leg = 0; leg < n_ext; ++leg) { @@ -585,6 +702,9 @@ MLMClustering::MLMClustering( .pdg_color_types = pdg_color_types, .have_pdg_ids = have_pdg_ids, .max_jet_flavor = max_jet_flavor, + .beam_pdg = {have_pdg_ids ? external_pdg_ids.at(0) : 21, + have_pdg_ids ? external_pdg_ids.at(1) : 21}, + .absolute_pdgs = _pdf_absolute_pdgs, }; std::set dead_states; find_clusterings(ctx, masks, all_diags, states, state_map, dead_states, 0); @@ -641,7 +761,8 @@ MLMClustering::MLMClustering( _cluster_state_machine.push_back(first_indices.at(item.next_state)); _cluster_state_machine.push_back( static_cast(item.trace_mode) + (item.is_jet_in << 2) + - (item.is_colored_in << 3) + (item.is_colored_in << 3) + + ((item.flavor_class_in + 1) << 4) ); } } @@ -651,7 +772,7 @@ MLMClustering::MLMClustering( NamedVector MLMClustering::build_function_impl( FunctionBuilder& fb, const NamedVector& args ) const { - std::array mlm_out; + std::array mlm_out; Value random = fb.squeeze(fb.random(fb.batch_size(args.values()), 1)); if (_hadronic) { mlm_out = fb.mlm_clustering_hadronic( @@ -670,7 +791,8 @@ NamedVector MLMClustering::build_function_impl( static_cast(_beam_flags), static_cast(_jet_leg_mask), static_cast(_parton_line_scheme), - static_cast(_alphas_scheme) + static_cast(_alphas_scheme), + static_cast(_pdf_reweighting) ); } else { mlm_out = fb.mlm_clustering_leptonic( @@ -689,7 +811,8 @@ NamedVector MLMClustering::build_function_impl( static_cast(_beam_flags), static_cast(_jet_leg_mask), static_cast(_parton_line_scheme), - static_cast(_alphas_scheme) + static_cast(_alphas_scheme), + static_cast(_pdf_reweighting) ); } return {return_types().keys(), {mlm_out.begin(), mlm_out.end()}}; diff --git a/madspace/src/phasespace/scale.cpp b/madspace/src/phasespace/scale.cpp index 09b79d470d..ab480d0436 100644 --- a/madspace/src/phasespace/scale.cpp +++ b/madspace/src/phasespace/scale.cpp @@ -73,7 +73,17 @@ NamedVector EnergyScale::apply_scale_range( double low = _min_scale > 0. ? _min_scale : 0.; double high = _max_scale > 0. ? _max_scale : 1e30; Value weight; - for (auto name : {"fact_scale1", "fact_scale2", "ren_scale"}) { + std::vector names{"fact_scale1", "fact_scale2", "ren_scale"}; + if (_clustering && _clustering->pdf_reweighting()) { + // The scale the density is actually asked for under pdf reweighting. + // It sits below the factorisation scale, so clamping that one is not + // enough to keep the density inside the grid, and madevent applies its + // own 2 GeV floor to the lowered scale too rather than to the central + // one. + names.push_back("pdf_scale1"); + names.push_back("pdf_scale2"); + } + for (auto name : names) { auto& scale = scales.at(name); auto pass = fb.cut_one(scale, low, high); weight = weight ? fb.mul(weight, pass) : pass; diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index 89eef8e414..fa769cf42b 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -1042,7 +1042,8 @@ PYBIND11_MODULE(_madspace_py, m) { std::vector, int, PartonLineScheme, - AlphasScheme>(), + AlphasScheme, + bool>(), py::arg("topologies"), py::arg("permutations"), py::arg("diagram_indices"), @@ -1057,7 +1058,11 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("external_pdg_ids") = std::vector{}, py::arg("max_jet_flavor") = 4, py::arg("parton_line_scheme") = PartonLineScheme::goodjet, - py::arg("alphas_scheme") = AlphasScheme::per_vertex + py::arg("alphas_scheme") = AlphasScheme::per_vertex, + py::arg("pdf_reweighting") = true + ) + .def_property_readonly( + "pdf_absolute_pdgs", &MLMClustering::pdf_absolute_pdgs ) .def_property_readonly( "cluster_state_machine", &MLMClustering::cluster_state_machine diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 4a62fa7e31..49e6a70708 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -136,6 +136,31 @@ def alphas_outputs(clustering, momenta): return np.asarray(out[6]), np.asarray(out[7]) +PDF_OUTPUTS = [ + "pdf_scale1", + "pdf_scale2", + "pdf_rw_flavor", + "pdf_rw_x", + "pdf_rw_q_num", + "pdf_rw_q_den", + "pdf_rw_active", + "pdf_rw_beam", +] + + +def pdf_outputs(clustering, momenta): + """The pdf reweighting outputs, by name: the scale each beam density is to + be evaluated at, and one slot per step of the ladder it is walked back up. + Named rather than positional because there are eight of them and their + order is not something a test should be pinning down.""" + out = clustering(momenta) + first = len(out) - len(PDF_OUTPUTS) + return { + name: np.asarray(value) + for name, value in zip(PDF_OUTPUTS, list(out)[first:]) + } + + def assert_jet_scales_agree(reference, other, max_flip_fraction=0.05): """Compare two sets of per-jet clustering scales that should be identical. @@ -1331,3 +1356,209 @@ def test_xqcut_does_not_depend_on_the_jet_scale_scheme(process): diagrams, pdg_ids, momenta, 40.0, jet_scale_scheme=PRODUCTION ) assert np.array_equal(emission, production) + + +# -------------------------------------------------------------------------- +# the alpha_s and pdf reweighting a merged event carries +# -------------------------------------------------------------------------- +# +# Both are corrections madevent applies to a merged event that a single scale +# does not describe: the coupling belongs at the scale of each emission, and +# the beam density belongs at the scale that took the parton out of the beam. +# The reference is the rewgt loop of Template/LO/SubProcesses/reweight.f. +# +# What is checkable here is the bookkeeping, not the densities: which one is +# asked for is only settled once the flavour has been sampled, which happens in +# the integrand and not in the clustering. + + +def t_channel_diagram(): + """u u~ > b b~ g g through a t-channel quark line, with the propagator + flavours filled in. + + The pdf reweighting follows a beam's parton line through the clustering, so + a test of it needs a process where a beam emits more than once and stays a + parton in between. Here beam 0 emits both gluons before the Z attaches: the + line is a u throughout, which is also the case the "same flavour as the + beam" class exists for. + + The propagator pdg ids matter and the json fixtures do not carry them. A + line whose flavour is unknown is not a parton as far as the reweighting is + concerned, so on those fixtures the chain stops at the first clustering and + nothing is exercised. + """ + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [M_B, M_B, 0.0, 0.0], + # p0 = Z (b b~), p1/p2 = the t-channel u line + "propagators": [(M_Z, W_Z, 23), (0.0, 0.0, 2), (0.0, 0.0, 2)], + "vertices": [ + ["o0", "o1", "p0"], + ["i0", "o2", "p1"], + ["p1", "o3", "p2"], + ["p2", "p0", "i1"], + ], + "permutations": [[0, 1, 2, 3, 4, 5]], + } + ] + + +T_CHANNEL_PDGS = [2, -2, 5, -5, 21, 21] + + +def t_channel_clustering(**kwargs): + kwargs.setdefault("scale_scheme", ms.MLMClustering.ScaleScheme.madevent) + diagrams = t_channel_diagram() + return ms.MLMClustering( + [ + ms.Topology( + ms.Diagram( + d["incoming_masses"], + d["outgoing_masses"], + [ms.Propagator(mass=m, width=w, pdg_id=i) + for m, w, i in d["propagators"]], + d["vertices"], + ) + ) + for d in diagrams + ], + [d["permutations"] for d in diagrams], + make_diagram_indices(diagrams), + cm_energy=CM_ENERGY, + external_pdg_ids=T_CHANNEL_PDGS, + **kwargs, + ) + + +@pytest.fixture(scope="module") +def t_channel_momenta(): + return sample_momenta(t_channel_diagram(), batch_size=2000) + + +def test_alphas_scales_are_clustering_scales_or_the_renormalisation_scale( + process, +): + """Every vertex is handed either its own scale or mu_R, the latter meaning + "not reweighted": its ratio against mu_R is then exactly one and the + consumer needs no mask.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + ren_scale = run(clustering, momenta)[0] + scales, weight = alphas_outputs(clustering, momenta) + assert np.all(np.isfinite(scales)) + assert np.all(scales > 0.0) + assert np.all((weight == 0.0) | (weight == 1.0)) + # gluon-only processes: every vertex is a QCD one, so few of them should + # have been left at mu_R + assert np.mean(np.isclose(scales, ren_scale[:, None])) < 0.5 + + +def test_pdf_reweighting_off_leaves_the_density_where_the_scale_is( + t_channel_momenta, +): + clustering = t_channel_clustering(pdf_reweighting=False) + _, fact1, fact2, _, _ = run(clustering, t_channel_momenta) + out = pdf_outputs(clustering, t_channel_momenta) + assert out["pdf_scale1"] == pytest.approx(fact1) + assert out["pdf_scale2"] == pytest.approx(fact2) + assert np.all(out["pdf_rw_active"] == 0.0) + + +def test_pdf_reweighting_drops_the_density_down_the_ladder(t_channel_momenta): + """The point of the whole thing: with it on, the density is asked for at or + below the factorisation scale, and for a real part of the sample strictly + below it. If it were never below there would be nothing to walk back up.""" + clustering = t_channel_clustering() + _, fact1, fact2, _, _ = run(clustering, t_channel_momenta) + out = pdf_outputs(clustering, t_channel_momenta) + for scale, fact in [(out["pdf_scale1"], fact1), (out["pdf_scale2"], fact2)]: + assert np.all(np.isfinite(scale)) + assert np.all(scale > 0.0) + assert np.all(scale <= fact * (1.0 + 1e-9)) + assert np.mean(scale < fact * (1.0 - 1e-9)) > 0.1 + + +def test_pdf_reweighting_steps_only_ever_raise_the_scale(t_channel_momenta): + """A step exists precisely to move the density from where it was last + evaluated up to this clustering, so its two scales are ordered. reweight.f + reaches the same place through its pt2pdf(ida) < q2now test.""" + out = pdf_outputs(t_channel_clustering(), t_channel_momenta) + active = out["pdf_rw_active"] == 1.0 + assert active.any(), "no step fired; this test would prove nothing" + assert np.all(out["pdf_rw_q_num"][active] > out["pdf_rw_q_den"][active]) + + +def test_pdf_reweighting_ends_at_the_factorisation_scale(t_channel_momenta): + """A chain that fired at all has to arrive back at the scale the event is + reported at, otherwise the lowering it started from is never undone.""" + clustering = t_channel_clustering() + _, fact1, fact2, _, _ = run(clustering, t_channel_momenta) + out = pdf_outputs(clustering, t_channel_momenta) + fact = np.stack([fact1, fact2], axis=1) + for beam in (0, 1): + on_beam = (out["pdf_rw_active"] == 1.0) & (out["pdf_rw_beam"] == beam) + fired = on_beam.any(axis=1) + if not fired.any(): + continue + top = np.max(np.where(on_beam, out["pdf_rw_q_num"], 0.0), axis=1) + assert top[fired] == pytest.approx(fact[fired, beam], rel=1e-9) + + +def test_inert_pdf_slots_cannot_change_the_weight(t_channel_momenta): + """A slot no step claimed still costs two density evaluations, because the + graph is the same for every event. Its two scales are equal so that its + ratio is exactly one whatever those come out as.""" + out = pdf_outputs(t_channel_clustering(), t_channel_momenta) + inert = out["pdf_rw_active"] == 0.0 + assert inert.any() + assert out["pdf_rw_q_num"][inert] == pytest.approx(out["pdf_rw_q_den"][inert]) + assert np.all(out["pdf_rw_q_den"][inert] > 0.0) + + +def test_pdf_reweighting_only_ever_takes_momentum_off_the_beam( + t_channel_momenta, +): + """The fraction travels down the ladder as a product of the clustering z, + each of which reweight.f only applies when it lies in (0, 1).""" + x = pdf_outputs(t_channel_clustering(), t_channel_momenta)["pdf_rw_x"] + assert np.all(x > 0.0) + assert np.all(x <= 1.0) + # and it does move: a chain that never rescaled x would be asking for the + # density of the beam parton at every rung + assert np.mean(x < 1.0) > 0.05 + + +def test_pdf_flavor_classes_stay_inside_the_table(t_channel_momenta): + """The class is an index the consumer looks up, so an out-of-range one + would read off the end of a table rather than fail visibly.""" + clustering = t_channel_clustering() + class_count = 3 + len(clustering.pdf_absolute_pdgs) + out = pdf_outputs(clustering, t_channel_momenta) + assert np.all(out["pdf_rw_flavor"] >= 0) + assert np.all(out["pdf_rw_flavor"] < class_count) + # the t-channel line is a u throughout, which is beam 0's own flavour, so + # no absolute flavour is needed and none should have been allocated + assert list(clustering.pdf_absolute_pdgs) == [] + active = out["pdf_rw_active"] == 1.0 + assert np.all(out["pdf_rw_flavor"][active] == 1) + + +def test_pdf_steps_name_a_real_beam(t_channel_momenta): + beam = pdf_outputs(t_channel_clustering(), t_channel_momenta)["pdf_rw_beam"] + assert np.all((beam == 0.0) | (beam == 1.0)) + + +def test_pdf_reweighting_is_off_under_the_other_scale_scheme(t_channel_momenta): + """There is no beam parton line to walk without madevent's scale scheme, so + asking for the reweighting there must do nothing rather than something + arbitrary.""" + clustering = t_channel_clustering( + scale_scheme=ms.MLMClustering.ScaleScheme.clustering_mean + ) + _, fact1, fact2, _, _ = run(clustering, t_channel_momenta) + out = pdf_outputs(clustering, t_channel_momenta) + assert np.all(out["pdf_rw_active"] == 0.0) + assert out["pdf_scale1"] == pytest.approx(fact1) + assert out["pdf_scale2"] == pytest.approx(fact2) From 75ae904c2dd73bb7d48dafd3cac71895c8529d78 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 10 Sep 2026 11:52:44 +0200 Subject: [PATCH 36/81] Write phasespace.auto_ptj_mjj to the run card too Same template gap as the previous commit, spotted while checking whether it was on during a madevent comparison: declared in banner.py, read by launch.py, absent from the rendered card. Its default is what a merged run wants, so nothing was behaving wrongly - it just could not be seen or turned off. Co-Authored-By: Claude Opus 5 --- madgraph/iolibs/template_files/mg7/run_card.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index f12b7ab059..541b73fe50 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -118,6 +118,10 @@ bw_cutoff = %(phasespace.bw_cutoff)s # counterpart of xqcut in the legacy run_card (0 disables it; only used # with dynamical_scale_choice = "mlm") xqcut = %(phasespace.xqcut)s +# tighten ptj and the jet-pair invariant mass to xqcut when it is set, so that +# the phase space is not sampled where the merging cut would throw it away. +# madevent's auto_ptj_mjj, from Template/LO/SubProcesses/setcuts.f +auto_ptj_mjj = %(phasespace.auto_ptj_mjj)s adaptive_symmetry_sampling = %(phasespace.adaptive_symmetry_sampling)s [multiparticles] From 5c80cb5de24970be9528e0efd25b72afdb3e2ad7 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 10 Sep 2026 23:56:24 +0200 Subject: [PATCH 37/81] Only apply the merging cut at a vertex that made a jet A merging cut exists to separate matrix-element radiation from what the parton shower would have produced. It has no business rejecting an event whose jet is already above it - and that is what this was doing, to 19% of a one-jet sample at xqcut = 60. The route in is a vertex that produced no radiation at all. In a quark-jet topology there is a diagram in which the outgoing quark and the quark line combine into the W: g u > e+ ve d [['i1','i0','p0'], ['o0','o1','p1'], ['p0','o2','p1']] p1 = W so once the clustering has formed the W from the leptons, (W, jet) is a valid clustering. Its measure is a kt between the W and the jet, which has nothing to do with the jet's own transverse momentum and is frequently below xqcut, and the jet was rejected there. A gluon has no W-gluon vertex, can only ever pair with a beam, and its measure is then mT = pt, so gluon jets never saw this. madevent gates the same cut on iqjets, which is only ever set for a leg emitted at a jet vertex; a leg it does not set is exempt. The comment this replaces said the cut "does not ask whether the vertex was a QCD one", which read reweight.f's xqcut block without following iqjets back to where it is filled. p p > e+ ve + 0-3j, 150k events each side, as a ratio to madevent: xqcut 0j 1j 2j 3j total before 15 1.001 1.001 0.999 0.978 0.9988 after 15 1.000 0.997 1.001 0.978 0.9976 before 30 0.999 0.963 0.920 0.916 0.9882 after 30 1.000 0.961 0.996 0.971 0.9938 before 60 0.998 0.801 0.616 0.696 0.9833 after 60 1.000 0.972 1.005 1.085 0.9993 and on the isolated case, g u > e+ ve d with ptj = 60, the cut goes from removing 19% to removing nothing: 54.903 -> 68.328 against 67.751 with the cut switched off. This also supplies the demotion madspace was missing - madevent's iqjets is worth 6.2% at xqcut = 15 - which is why the loose merging cut does not move while the tight one does. Dropping the resonance preference instead fixes the same veto but overshoots to 1.0168 at xqcut = 15, because it removes only one half of that cancellation. What is left is the 1-jet rate, 3-4% low at xqcut 30 and 60 and correct at 15. Co-Authored-By: Claude Opus 5 --- madspace/src/kernels/mlm.hpp | 8 ++- madspace/tests/test_mlm.py | 109 +++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 344bec75bf..97c64d90d3 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -482,7 +482,13 @@ KERNELSPEC void mlm_clustering( bool is_qcd = (data >> 27) & 1; bool is_jet1 = (data >> 28) & 1; bool is_jet2 = (data >> 29) & 1; - if (FVal(xqcut) > 0.0 && scale < FVal(xqcut) && + // The vertex has to be a QCD one. madevent gates this on iqjets, which + // is only ever set for a leg emitted at a jet vertex, and a leg it does + // not set is exempt from the merging cut. Without that test a jet can be + // rejected at a vertex that produced no radiation at all - a quark + // pairing into the W, say, whose measure is a lepton-side kt with + // nothing to do with the jet's own transverse momentum. + if (FVal(xqcut) > 0.0 && is_qcd && scale < FVal(xqcut) && ((is_jet1 && (is_last_cluster & (1 << particle1))) || (is_jet2 && (is_last_cluster & (1 << particle2))))) { passes_xqcut = false; diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 49e6a70708..bd96c2891d 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -1562,3 +1562,112 @@ def test_pdf_reweighting_is_off_under_the_other_scale_scheme(t_channel_momenta): assert np.all(out["pdf_rw_active"] == 0.0) assert out["pdf_scale1"] == pytest.approx(fact1) assert out["pdf_scale2"] == pytest.approx(fact2) + + +# -------------------------------------------------------------------------- +# the merging cut must not fire at a vertex that produced no radiation +# -------------------------------------------------------------------------- + + +def w_plus_jet_diagram(): + """g u > e+ ve d, the diagram in which the jet pairs into the W. + + Vertices as mg7 builds them: the two beams give the t-channel quark p0, the + leptons give the W p1, and then p0 and the jet give the W again - so once + the W exists, (W, jet) is a valid clustering. Its measure is a kt between + the W and the jet and has nothing to do with the jet's own transverse + momentum, so a jet well above the merging cut can be clustered there at a + scale well below it. + + That vertex is not a QCD splitting - the W carries no color - and madevent + never applies the merging cut to a leg that was not emitted at a jet vertex + (its iqjets gate). Reading the cut off any vertex instead removed 19% of + this process at xqcut = 60, all of it jets that were already above the cut. + """ + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0, 0.0], + # p0 = t-channel quark, p1 = W + "propagators": [(0.0, 0.0, 1), (M_W, W_W, 24)], + "vertices": [ + ["i1", "i0", "p0"], + ["o0", "o1", "p1"], + ["p0", "o2", "p1"], + ], + "permutations": [[0, 1, 2, 3, 4]], + } + ] + + +M_W, W_W = 80.419002, 2.0476 +W_PLUS_JET_PDGS = [21, 2, -11, 12, 1] + + +def w_plus_jet_clustering(xqcut): + diagrams = w_plus_jet_diagram() + return ms.MLMClustering( + [ + ms.Topology( + ms.Diagram( + d["incoming_masses"], + d["outgoing_masses"], + [ms.Propagator(mass=m, width=w, pdg_id=i) + for m, w, i in d["propagators"]], + d["vertices"], + ) + ) + for d in diagrams + ], + [d["permutations"] for d in diagrams], + make_diagram_indices(diagrams), + cm_energy=CM_ENERGY, + external_pdg_ids=W_PLUS_JET_PDGS, + scale_scheme=ms.MLMClustering.ScaleScheme.madevent, + xqcut=xqcut, + ) + + +def test_a_jet_above_the_merging_cut_is_never_vetoed(): + """The property the whole merging rests on: an event whose only jet is + already above xqcut belongs to the matrix element, and the merging cut must + leave it alone whatever the clustering history turns out to be.""" + xqcut = 60.0 + diagrams = w_plus_jet_diagram() + momenta = sample_momenta(diagrams, batch_size=4000) + clustering = w_plus_jet_clustering(xqcut) + weight = run_all(clustering, momenta)[5] + + # mT of the one final-state parton, which is its pt here since it is + # massless, and which is the measure of every clustering that can take it + # back into a beam + jet = momenta[:, 4, :] + mt = np.sqrt(np.maximum(jet[:, 0] ** 2 - jet[:, 3] ** 2, 0.0)) + above = mt > xqcut * (1.0 + 1e-9) + + assert above.sum() > 200, "too few jets above the cut to prove anything" + assert np.all(weight[above] == 1.0), ( + "%d of %d events with the jet above the merging cut were vetoed" + % (int((weight[above] != 1.0).sum()), int(above.sum())) + ) + + +def test_a_jet_emitted_at_the_w_vertex_is_not_a_merging_jet(): + """The other half of the same rule, and the reason the test above is not + vacuous: in this one diagram the jet is produced at the W vertex, so it is + an electroweak emission and not radiation the shower would have made. Nothing + is vetoed here at any merging cut - which is madevent's behaviour, since its + iqjets is only ever set for a leg emitted at a jet vertex. + + That the cut does still remove soft QCD emissions is covered by + test_xqcut_rejects_exactly_the_soft_jet_emissions and its neighbours, which + run on a process whose jets do sit on QCD vertices. + """ + diagrams = w_plus_jet_diagram() + momenta = sample_momenta(diagrams, batch_size=4000) + for xqcut in (60.0, 500.0): + weight = run_all(w_plus_jet_clustering(xqcut), momenta)[5] + assert np.all(weight == 1.0), ( + "xqcut = %g vetoed %d events whose jet is not a QCD emission" + % (xqcut, int((weight != 1.0).sum())) + ) From faea625f2e57e72cfa221138615566fe3973cf81 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Fri, 11 Sep 2026 10:19:20 +0200 Subject: [PATCH 38/81] Decide which legs are merging jets the way madevent does The previous commit gated the merging cut on the vertex being a QCD one, which is a proxy for what madevent actually asks. This is the thing itself: iqjets, built in the same walk that already produces jfirst/jlast/jcentral. A leg is tagged 1 where it was emitted while the beam line was still a parton line, or where it is a gluon - a gluon emission has a soft singularity the shower has to be left free to fill. Otherwise it is tagged with jcode, a count of how many times the walk has crossed between jet and non-jet vertices, and resolved at the end: once either beam line has stopped being a parton line, everything tagged only provisionally and before the last crossing was not a merging emission after all. Final-state vertices tag their bare jet daughters through isjetvx, and a vertex that made no jet demotes the legs whose line runs into it. Only a leg this leaves above zero is checked against xqcut. Three flavour facts the state machine did not carry are needed for that and are added: whether the mother is an octet, whether it repeats either daughter's flavour (isjetvx reads that as "emitted off a line" rather than "split into something else"), and whether all three lines are colourless, which is what keeps a W W Z or h h h vertex out of the demotion. Octet-ness per external leg rides in the spare high half of jet_leg_mask. Against the proxy it replaces, the two agree within statistics everywhere, so this changes no number here - it is the rule that will still be right away from W+jets, where "QCD vertex" and "vertex that made a jet" part company. madspace / madevent 0j 1j 2j 3j total xqcut 15 proxy 1.000 0.997 1.001 0.978 0.9976 xqcut 15 iqjets 0.999 0.999 1.003 0.981 0.9981 xqcut 30 proxy 1.000 0.961 0.996 0.971 0.9938 xqcut 30 iqjets 1.001 0.956 0.994 0.981 0.9936 xqcut 60 proxy 1.000 0.972 1.005 1.085 0.9993 xqcut 60 iqjets 0.999 0.965 1.012 1.110 0.9983 and on g u > e+ ve d with ptj = 60 the merging cut still removes nothing: 68.328 against 67.751 with it switched off. What is left is the 1-jet rate, 3-4% low at xqcut 30 and 60 and correct at 15. Co-Authored-By: Claude Opus 5 --- madspace/src/kernels/mlm.hpp | 227 +++++++++++++++++++-- madspace/src/phasespace/mlm_clustering.cpp | 43 +++- 2 files changed, 247 insertions(+), 23 deletions(-) diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 97c64d90d3..3414584911 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -45,6 +45,14 @@ constexpr int TRACE_IS_COLORED_IN = 1 << 3; // flavour has been sampled. constexpr int TRACE_FLAVOR_SHIFT = 4; constexpr int TRACE_FLAVOR_MASK = 0xFF; +// More of the mother's flavour, for isjetvx and the iqjets demotion. +constexpr int TRACE_IS_OCTET_IN = 1 << 12; +constexpr int TRACE_MOTHER_IS_DAU1 = 1 << 13; +constexpr int TRACE_MOTHER_IS_DAU2 = 1 << 14; +constexpr int TRACE_ALL_COLORLESS = 1 << 15; +// jet_leg_mask carries is_jet per leg in the low half and is_octet in the high +// half; n_ext_max is 12, so one word holds both. +constexpr int LEG_OCTET_SHIFT = 16; // mT^2 = E^2 - pz^2 (hadronic) or E^2 (lepton collider). // based on djb_clus from Template/NLO/SubProcesses/cluster.f @@ -453,12 +461,23 @@ KERNELSPEC void mlm_clustering( // Colour of the line currently in each slot, tracked the same way. Only // the two beam slots are ever read back, at the 2 -> 1 root. bool slot_is_colored[N_EXT_MAX]; + // and whether it is an octet, which decides whether an emission counts as + // a jet unconditionally: a gluon has a soft singularity the shower has to + // be left free to fill. + bool slot_is_octet[N_EXT_MAX]; for (int i = 0; i < n_part; ++i) { rep1[i] = i; rep2[i] = -1; slot_is_jet[i] = (jet_leg_mask >> i) & 1; slot_is_colored[i] = i < 2 ? (((beam_flags >> (2 * i)) & 1) != 0) : true; + slot_is_octet[i] = (jet_leg_mask >> (i + LEG_OCTET_SHIFT)) & 1; } + // What the iqjets bookkeeping below needs from each step but cannot + // reconstruct afterwards: which slots were still bare external legs, and + // where the mother's line ended up. Recorded here rather than walked a + // second time. + int step_bare[N_EXT_MAX - 3]; + int step_mother_leg1[N_EXT_MAX - 3], step_mother_leg2[N_EXT_MAX - 3]; bool by_production = jet_scale_scheme == SCHEME_PRODUCTION; FVal ren_scale_val = 1.0; @@ -472,7 +491,6 @@ KERNELSPEC void mlm_clustering( // // is_last_cluster is exactly the "still a bare external leg" bookkeeping // the emission scheme already needs, so the two share it. - bool passes_xqcut = true; int is_last_cluster = 0b11111111'11111111'11111100; for (int i = 0; i < cluster_max; ++i) { FVal scale = cluster_scales[i]; @@ -482,17 +500,7 @@ KERNELSPEC void mlm_clustering( bool is_qcd = (data >> 27) & 1; bool is_jet1 = (data >> 28) & 1; bool is_jet2 = (data >> 29) & 1; - // The vertex has to be a QCD one. madevent gates this on iqjets, which - // is only ever set for a leg emitted at a jet vertex, and a leg it does - // not set is exempt from the merging cut. Without that test a jet can be - // rejected at a vertex that produced no radiation at all - a quark - // pairing into the W, say, whose measure is a lepton-side kt with - // nothing to do with the jet's own transverse momentum. - if (FVal(xqcut) > 0.0 && is_qcd && scale < FVal(xqcut) && - ((is_jet1 && (is_last_cluster & (1 << particle1))) || - (is_jet2 && (is_last_cluster & (1 << particle2))))) { - passes_xqcut = false; - } + step_bare[i] = is_last_cluster; if (is_qcd) { if (by_production) { // Book this vertex onto every external leg the two daughters' @@ -566,6 +574,9 @@ KERNELSPEC void mlm_clustering( // the merged line takes the mother's flavour slot_is_jet[particle1] = (cluster_trace[i] & TRACE_IS_JET_IN) != 0; slot_is_colored[particle1] = (cluster_trace[i] & TRACE_IS_COLORED_IN) != 0; + slot_is_octet[particle1] = (cluster_trace[i] & TRACE_IS_OCTET_IN) != 0; + step_mother_leg1[i] = rep1[particle1]; + step_mother_leg2[i] = rep2[particle1]; } // Any outgoing leg that no QCD clustering booked a scale onto keeps @@ -591,6 +602,15 @@ KERNELSPEC void mlm_clustering( // plus one per beam for the 2 -> 1 root, which sits on both. A slot that // no step claims stays inert: its two scales are equal, so its ratio is // one, and its weight is one whatever the density comes out as. + // Which outgoing legs the merging cut applies to. Filled by madevent's + // iqjets walk below where that scale scheme is in use; without it the only + // thing available is the leg's own flavour and the vertex's. + bool merging_jet[N_EXT_MAX]; + bool have_merging_jets = false; + for (int i = 0; i < n_part; ++i) { + merging_jet[i] = false; + } + int pdf_slot_count = n_part - 1; int rw_flavor[N_EXT_MAX - 1]; FVal rw_x[N_EXT_MAX - 1]; @@ -664,10 +684,44 @@ KERNELSPEC void mlm_clustering( } bool use_goodjet = parton_line_scheme == LINE_GOODJET; + // iqjets of reweight.f: which outgoing legs count as merging jets. + // This is the gate on the xqcut check, and the reason a jet emitted + // where no radiation happened is exempt from it. + // + // 1 certainly a jet: emitted while the beam line was still a + // parton line, or a gluon, which has a soft singularity the + // shower must be left free to fill + // jcode possibly a jet, resolved at the end + // 0 not a merging jet + // + // jcode counts how many times the walk has crossed between QCD and + // non-QCD vertices, so that an emission can be placed relative to those + // crossings; increasecode makes the first jet vertex after a run of + // non-jet ones count as another crossing. + int iqjets[N_EXT_MAX]; + for (int i = 0; i < n_part; ++i) { + iqjets[i] = 0; + } + int jcode = 1; + bool increasecode = false; + // isjet of the beam line itself as it stands, which is ipdgcl(ida(i)) + // in the jcode test - the daughter on the beam side, before the vertex + // turns it into the mother. + bool line_is_jet[2]; + for (int j = 0; j < 2; ++j) { + line_is_jet[j] = ((beam_flags >> (2 * j + 1)) & 1) != 0; + } + // One step of the beam-line bookkeeping, for beam j at step i. - // is_emitted_jet is goodjet(ida(3-i)), the emitted object; is_jet_in - // and is_colored_in are the mother's flavour. - auto walk_line = [&](int j, int i, bool is_emitted_jet, bool is_jet_in, + // emitted_leg is ipart(1, ida(3-i)), the first external leg of the + // emitted line, or -1 where there is none; is_jet_in and is_colored_in + // are the mother's flavour. + auto walk_line = [&](int j, + int i, + bool is_emitted_jet, + bool emitted_is_octet, + int emitted_leg, + bool is_jet_in, bool is_colored_in) { if (partonline[j]) { if (jfirst[j] < 0) { @@ -683,6 +737,18 @@ KERNELSPEC void mlm_clustering( // it becomes is not a good jet either goodjet[j] = false; } + if (!is_emitted_jet || !line_is_jet[j] || !is_jet_in) { + ++jcode; + increasecode = true; + } else if (increasecode) { + ++jcode; + increasecode = false; + } + if (is_emitted_jet && emitted_leg >= 2) { + iqjets[emitted_leg] = + (partonline[j] || emitted_is_octet) ? 1 : jcode; + } + line_is_jet[j] = is_jet_in; if (qcdline[j]) { jcentral[j] = i; qcdline[j] = is_colored_in; @@ -697,18 +763,81 @@ KERNELSPEC void mlm_clustering( int trace = cluster_trace[i]; bool is_jet_in = (trace & TRACE_IS_JET_IN) != 0; if (particle1 >= 2) { - // final-state clustering: no beam line, but the mother's - // goodjet has to be worked out for whoever clusters with it - // later - including the root, which reads the leftover's. - bool is_jet_vertex = (data >> 27) & 1; - goodjet[particle1] = is_jet_vertex && is_jet_in && - goodjet[particle1] && goodjet[particle2]; + // Final-state clustering. isjetvx() of reweight.f: a QCD + // vertex, and one where a jet actually came out - which it did + // if a jet daughter sits opposite either a jet mother or a + // mother repeating the other daughter's flavour, i.e. an + // emission off a line rather than a splitting into something + // else. + bool is_qcd_vertex = (data >> 27) & 1; + bool is_jet1 = (data >> 28) & 1; + bool is_jet2 = (data >> 29) & 1; + bool mother_is_dau1 = (trace & TRACE_MOTHER_IS_DAU1) != 0; + bool mother_is_dau2 = (trace & TRACE_MOTHER_IS_DAU2) != 0; + bool is_octet_in = (trace & TRACE_IS_OCTET_IN) != 0; + bool is_jet_vertex = is_qcd_vertex && + ((is_jet1 && (is_jet_in || mother_is_dau2)) || + (is_jet2 && (is_jet_in || mother_is_dau1))); + int mother_leg1 = step_mother_leg1[i]; + int mother_leg2 = step_mother_leg2[i]; + if (!is_jet_vertex) { + // "Remove non-gluon jets that lead up to non-jet + // vertices": a quark that was counted as a jet earlier but + // whose line runs into a vertex that made no jet was not a + // merging emission after all. A vertex whose three lines + // all carry colour, or none of them do, is left alone. + if (!is_qcd_vertex && mother_leg1 >= 2) { + bool leg1_octet = + ((jet_leg_mask >> (mother_leg1 + LEG_OCTET_SHIFT)) & 1) != 0; + if (!leg1_octet) { + if ((trace & TRACE_ALL_COLORLESS) != 0) { + // a W W Z or h h h vertex: nothing to demote + } else if (mother_leg2 < 0) { + iqjets[mother_leg1] = 0; + } else if (iqjets[mother_leg1] > 0 && + mother_leg2 >= 2 && + iqjets[mother_leg2] > 0) { + // both halves of an octet's line are tagged, + // so one of them can go + iqjets[mother_leg1] = 0; + } + } else if (is_octet_in) { + iqjets[mother_leg1] = 0; + } + } + if (mother_leg2 >= 2 && !is_octet_in) { + bool leg2_octet = + ((jet_leg_mask >> (mother_leg2 + LEG_OCTET_SHIFT)) & 1) != 0; + if (!leg2_octet) { + iqjets[mother_leg2] = 0; + } + } + goodjet[particle1] = false; + continue; + } + // a jet vertex: every daughter that is still a bare external + // jet was emitted here + if (is_jet1 && particle1 >= 2 && + (step_bare[i] & (1 << particle1))) { + iqjets[particle1] = 1; + } + if (is_jet2 && particle2 >= 2 && + (step_bare[i] & (1 << particle2))) { + iqjets[particle2] = 1; + } + goodjet[particle1] = is_jet_in && goodjet[particle1] && + goodjet[particle2]; continue; } bool emitted = use_goodjet ? goodjet[particle2] : ((data >> 29) & 1) != 0; walk_line( - particle1, i, emitted, is_jet_in, + particle1, + i, + emitted, + slot_is_octet[particle2], + rep1[particle2], + is_jet_in, (trace & TRACE_IS_COLORED_IN) != 0 ); } @@ -728,11 +857,31 @@ KERNELSPEC void mlm_clustering( j, cluster_max, leftover_jet, + slot_is_octet[leftover], + rep1[leftover], slot_is_jet[1 - j], slot_is_colored[1 - j] ); } } + // "Emissions with code 1 are always jets; now take care of possible + // jets". Once a beam line has stopped being a parton line, everything + // tagged only provisionally and before the last crossing was not a + // merging emission. + if (!partonline[0] || !partonline[1]) { + if (partonline[0] || partonline[1]) { + --jcode; + } + for (int leg = 2; leg < n_part; ++leg) { + if (iqjets[leg] > 1 && iqjets[leg] <= jcode) { + iqjets[leg] = 0; + } + } + } + for (int leg = 0; leg < n_part; ++leg) { + merging_jet[leg] = leg >= 2 && iqjets[leg] > 0; + } + have_merging_jets = true; for (int j = 0; j < 2; ++j) { if (jfirst[j] < 0) { jfirst[j] = jlast[j]; @@ -950,6 +1099,40 @@ KERNELSPEC void mlm_clustering( } } + // The merging cut. A matrix-element jet below xqcut is radiation the parton + // shower is meant to produce, so the event is dropped - but only for a leg + // that is a merging jet in the first place. madevent decides that with + // iqjets, which is only ever set for a leg emitted at a jet vertex; a leg + // it leaves at zero is exempt however soft the vertex it sits on. Without + // that gate a jet is rejected at vertices that produced no radiation at + // all, most visibly a quark pairing into a W, whose measure is a + // lepton-side kt with nothing to do with the jet's own transverse + // momentum. + bool passes_xqcut = true; + if (FVal(xqcut) > 0.0) { + for (int i = 0; i < cluster_max; ++i) { + if (!(cluster_scales[i] < FVal(xqcut))) { + continue; + } + int data = cluster_history[i]; + int particle1 = data & 0xFF; + int particle2 = (data >> 8) & 0xFF; + bool is_qcd = (data >> 27) & 1; + for (int k = 0; k < 2; ++k) { + int slot = k == 0 ? particle1 : particle2; + if (slot < 2 || !(step_bare[i] & (1 << slot))) { + continue; + } + bool counts = have_merging_jets + ? merging_jet[slot] + : (is_qcd && ((data >> (28 + k)) & 1) != 0); + if (counts) { + passes_xqcut = false; + } + } + } + } + ren_scale = ren_scale_val; fact_scale1 = fac_scale1; fact_scale2 = fac_scale2; diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index 7056a02174..3920dd2059 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -33,6 +33,13 @@ bool is_jet_pdg(int pdg_id, int max_jet_flavor) { return (a >= 1 && a <= max_jet_flavor) || a == 21 || a == 81; } +// Mirrors is_octet() in Template/LO/SubProcesses/reweight.f: a colour octet, +// which in the QCD sector means the gluon. The iqjets bookkeeping treats an +// emitted gluon as a jet unconditionally - it has a soft singularity the shower +// must be allowed to fill - and uses octet-ness again to decide which leg of a +// non-jet vertex to demote. +bool is_octet_pdg(int pdg_id, const std::unordered_map& pdg_color_types); + // Mirrors isparton() in Template/LO/SubProcesses/reweight.f: a line that can // be a beam parton, i.e. one the pdf reweighting can follow back into a beam. // Wider than is_jet_pdg: madevent tests against max(asrwgtflavor, maxjetflavor) @@ -94,6 +101,10 @@ bool is_colored_pdg(int pdg_id, const std::unordered_map& pdg_color_ty return color_rep(pdg_id, pdg_color_types) != 1; } +bool is_octet_pdg(int pdg_id, const std::unordered_map& pdg_color_types) { + return color_rep(pdg_id, pdg_color_types) == 8; +} + // How the parton line of the mother of a clustering continues into its two // daughters, so that a clustering scale can be booked onto the external legs // the line ends at. Mirrors ipartupdate() in @@ -222,6 +233,16 @@ struct StateItem { // a beam's parton line stops being a jet and where it stops being coloured. bool is_jet_in; bool is_colored_in; + // isjetvx() needs to know whether the mother repeats one daughter's + // flavour - a gluon emission off a quark line, say - and the iqjets + // demotion needs to know whether the mother is an octet. + bool is_octet_in; + bool mother_is_daughter1; + bool mother_is_daughter2; + // None of the three lines carries colour - a W W Z or h h h vertex. The + // demotion leaves those alone rather than treating them as a QCD vertex + // that failed the jet test. + bool all_colorless; // Flavour class of the mother, i.e. of the beam line as it stands after // this clustering. flavor_class_none ends the pdf reweighting chain, // which is also what stops madevent following ibeam(j) any further. @@ -321,6 +342,14 @@ StateItem make_state_item( .is_jet_in = !ctx.have_pdg_ids || is_jet_pdg(meta_in.pdg_id, ctx.max_jet_flavor), .is_colored_in = !ctx.have_pdg_ids || color_in != 1, + .is_octet_in = !ctx.have_pdg_ids || + is_octet_pdg(meta_in.pdg_id, ctx.pdg_color_types), + .mother_is_daughter1 = + meta_in.pdg_id != 0 && meta_in.pdg_id == meta_1.pdg_id, + .mother_is_daughter2 = + meta_in.pdg_id != 0 && meta_in.pdg_id == meta_2.pdg_id, + .all_colorless = ctx.have_pdg_ids && color_in == 1 && color_1 == 1 && + color_2 == 1, .flavor_class_in = flavor_class(ctx, meta_in.pdg_id, is_initial ? particle1 : -1), }; @@ -413,6 +442,10 @@ void find_clusterings( .trace_mode = trace_first, .is_jet_in = false, .is_colored_in = false, + .is_octet_in = false, + .mother_is_daughter1 = false, + .mother_is_daughter2 = false, + .all_colorless = false, .flavor_class_in = flavor_class_none, }); } @@ -558,6 +591,12 @@ MLMClustering::MLMClustering( if (is_jet_pdg(pdg, max_jet_flavor)) { _jet_leg_mask |= 1 << leg; } + // The high half carries octet-ness for the same leg. The kernel needs + // both and n_ext_max is 12, so one word holds them without another + // kernel argument. + if (is_octet_pdg(pdg, pdg_color_types)) { + _jet_leg_mask |= 1 << (leg + 16); + } } nested_vector2 valid_diags(1 << n_ext); @@ -762,7 +801,9 @@ MLMClustering::MLMClustering( _cluster_state_machine.push_back( static_cast(item.trace_mode) + (item.is_jet_in << 2) + (item.is_colored_in << 3) + - ((item.flavor_class_in + 1) << 4) + ((item.flavor_class_in + 1) << 4) + + (item.is_octet_in << 12) + (item.mother_is_daughter1 << 13) + + (item.mother_is_daughter2 << 14) + (item.all_colorless << 15) ); } } From 1755aded6251c67309041c3b0d90fbdfbb9639d9 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sat, 12 Sep 2026 00:08:19 +0200 Subject: [PATCH 39/81] Reweight alpha_s at the scale setclscales left behind rewgt reads pt2ijcl(n) after setclscales has already overwritten it - at the central vertex with the emitted object's transverse mass, and at the last one to keep it above the first - so the coupling never sees the raw clustering measure. madspace was reweighting at the raw measure. Measured on p p > e+ ve + 0-3j and p p > t t~ + 0-3j, this changes nothing at all: byte-identical at xqcut 15 and 30 for W+jets, and 2904.6 -> 2903.2 pb and 1742.6 -> 1743.4 pb for t t~ at xqcut 20 and 30, both well inside the errors. The reason is that the two overrides land on jcentral and jlast, which for these processes are the 2 -> 1 root, and the alpha_s loop already skips the root. Committed anyway because it is what reweight.f reads, and a process whose central or last vertex is not the root would see it. It costs one array of cluster_max doubles. Co-Authored-By: Claude Opus 5 --- madspace/src/kernels/mlm.hpp | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 3414584911..6faf8e47f0 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -605,6 +605,16 @@ KERNELSPEC void mlm_clustering( // Which outgoing legs the merging cut applies to. Filled by madevent's // iqjets walk below where that scale scheme is in use; without it the only // thing available is the leg's own flavour and the vertex's. + // The scale each clustering is reweighted at. rewgt reads pt2ijcl *after* + // setclscales has overwritten it - at the central vertex with the emitted + // object's transverse mass, and at the last one to keep it above the first + // - so the coupling does not see the raw clustering measure. Defaults to + // that measure for the scheme that has no such table. + FVal alphas_step[N_EXT_MAX - 3]; + for (int i = 0; i < cluster_max; ++i) { + alphas_step[i] = cluster_scales[i]; + } + bool merging_jet[N_EXT_MAX]; bool have_merging_jets = false; for (int i = 0; i < n_part; ++i) { @@ -927,6 +937,10 @@ KERNELSPEC void mlm_clustering( pt_step[jlast[j]] = pt_step[jfirst[j]]; } } + for (int i = 0; i < cluster_max; ++i) { + alphas_step[i] = pt_step[i]; + } + FVal s_last[2], s_central[2]; for (int j = 0; j < 2; ++j) { s_last[j] = jlast[j] < 0 ? 0.0 : pt_step[jlast[j]]; @@ -1161,7 +1175,7 @@ KERNELSPEC void mlm_clustering( int reweighted = 0; for (int i = 0; i < cluster_max; ++i) { if ((cluster_history[i] >> 27) & 1) { - FVal scale = cluster_scales[i]; + FVal scale = alphas_step[i]; if (!(scale * scale > 4.0)) { alphas_ok = false; } else { @@ -1179,7 +1193,7 @@ KERNELSPEC void mlm_clustering( } else if (alphas_scheme == ALPHAS_GEOMETRIC) { alphas_scales[i] = mean_scale; } else { - alphas_scales[i] = cluster_scales[i]; + alphas_scales[i] = alphas_step[i]; } } From 57f50f1ef5697d8009c75f5a264b681cb393f94b Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Mon, 14 Sep 2026 23:38:33 +0200 Subject: [PATCH 40/81] Use mt2last at an s-channel QCD root, as setclscales does When the last clustering joins two coloured final-state lines into a coloured one - q q~ > g > t t~ is the case that matters - and both beam lines are still parton lines at the 2 -> 1 root, setclscales replaces the root's transverse mass by mt2last = sqrt(djb(d1) djb(d2)), the geometric mean of the pair's transverse masses (cluster.f sets it, reweight.f uses it). madspace had no counterpart, so the root kept the mT of the whole final state, which for q q~ > t t~ is sqrt(shat) - 2.3 times mT(t) on average. The kernel now records that mean at the walk's last clustering when it is a final-state one, and uses it as the root's mT under madevent's conditions: above 2 GeV (mt2last > 4 GeV^2), both jlast at the root, and the last clustering a QCD vertex, which is exactly what bit 27 already encodes (both daughters and the mother coloured). p p > t t~ alone, 100k events per arm, MLM scale without reweighting: madevent madspace before madspace after gg > t t~ 448.0 448.3 448.3 qq~ > t t~ 65.23 53.46 65.13 total 513.20 501.72 513.41 (ratio 1.0004) The qq~ deficit was closed beforehand by moving madspace's own events from sqrt(shat) to mT(t) (53.46 -> 64.87 pb), and the scale is now mT(t) on every event in both channels in both codes. W and Z + 0 jets never needed it: there the root pair is colourless and sqrt(shat) equals the boson's mT exactly. Co-Authored-By: Claude Opus 5 --- madspace/src/kernels/mlm.hpp | 37 ++++++++++++++- madspace/tests/test_mlm.py | 88 ++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 6faf8e47f0..8f6957270b 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -291,6 +291,12 @@ KERNELSPEC void mlm_clustering( // zcl in cluster.f, zero for a final-state one. The pdf reweighting walks // the beam's momentum fraction down the ladder with it. FVal cluster_z[N_EXT_MAX - 3]; + // sqrt(mT(d1) mT(d2)) of the last clustering if that one is a final-state + // clustering, zero otherwise. mt2last in cluster.f, kept unsquared like + // every other scale here: "If last clustering is FS, store also average + // transverse mass of the particles combined (for use if QCD vertex, e.g. + // tt~ or qq~)". + FVal mt_last = 0.0; for (int i = 0; i < n_part; ++i) { for (int j = 0; j < 4; ++j) { @@ -378,6 +384,16 @@ KERNELSPEC void mlm_clustering( cluster_mt[cluster_count] = p1_win < 2 ? sqrt(djb_clus(momenta_tmp[p2_win], hadronic)) : FVal(0.0); + // cluster.f sets mt2last when the clustering leaves only the two + // beams and one object (nleft <= 3) and was a final-state one + // (iwin > 2), from pcl as it stands then: after any earlier + // initial-state boosts, before this clustering merges the pair. + if (cluster_count == cluster_max - 1 && p1_win >= 2) { + mt_last = sqrt(sqrt( + djb_clus(momenta_tmp[p1_win], hadronic) * + djb_clus(momenta_tmp[p2_win], hadronic) + )); + } // zclus() of Template/LO/Source/kin_functions.f: the ratio of the // partonic invariants before and after the emission is taken back // out of the beam, both measured against the other beam. Taken @@ -906,6 +922,23 @@ KERNELSPEC void mlm_clustering( pt_step[i] = i == cluster_max ? extra_scale : cluster_scales[i]; } + // "If last clustering is s-channel QCD (e.g. ttbar) use mt2last + // instead" (setclscales in reweight.f): when the last recorded + // clustering joined two coloured final-state lines into a coloured + // one, and both beam lines are still parton lines at the root, the + // root's transverse mass becomes that average mT of the pair. Without + // it the root keeps the mT of the whole final state, which for + // q q~ > t t~ is sqrt(shat) - on average 2.3 times mT(t). reweight.f + // also writes it onto mt2ij of the last recorded clustering, but that + // one is final-state and jcentral only ever lands on an initial-state + // clustering or the root, so the root is the only place it is read. + FVal root_mt = 0.0; + if (mt_last > 2.0 && cluster_max >= 1 && jlast[0] == cluster_max && + jlast[1] == cluster_max && + ((cluster_history[cluster_max - 1] >> 27) & 1) != 0) { + root_mt = mt_last; + } + // "Set central scale to mT2": at an initial-state clustering the // relevant scale is not the clustering measure but the transverse mass // of what was emitted there. For Drell-Yan the central vertex is where @@ -917,8 +950,8 @@ KERNELSPEC void mlm_clustering( } // At the root cluster.f takes mt2ij from daughter 2, which there // is the second beam's parton: mT of a beam parton is zero, so - // the root vertex never gets overridden. - FVal mt = jcentral[j] == cluster_max ? FVal(0.0) + // the root is only overridden through mt2last above. + FVal mt = jcentral[j] == cluster_max ? root_mt : cluster_mt[jcentral[j]]; if (mt > 0.0) { pt_step[jcentral[j]] = mt; diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index bd96c2891d..84a0ea340d 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -1671,3 +1671,91 @@ def test_a_jet_emitted_at_the_w_vertex_is_not_a_merging_jet(): "xqcut = %g vetoed %d events whose jet is not a QCD emission" % (xqcut, int((weight != 1.0).sum())) ) + + +# -------------------------------------------------------------------------- +# mt2last: an s-channel QCD root takes the mean transverse mass of the pair +# -------------------------------------------------------------------------- + + +def s_channel_ttbar_diagram(propagator): + """u u~ > t t~ through one s-channel propagator: a gluon, which is the only + diagram q q~ > t t~ has, or a Z as the colourless control. + + The last recorded clustering is then the final-state (t, t~) pair and the + root is the only other step, so this is exactly the case setclscales' + mt2last rule exists for.""" + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [M_TOP, M_TOP], + "propagators": [propagator], + "vertices": [["i0", "i1", "p0"], ["o0", "o1", "p0"]], + "permutations": [[0, 1, 2, 3]], + } + ] + + +S_CHANNEL_TTBAR_PDGS = [2, -2, 6, -6] + + +def s_channel_ttbar_clustering(propagator): + diagrams = s_channel_ttbar_diagram(propagator) + return ms.MLMClustering( + [ + ms.Topology( + ms.Diagram( + d["incoming_masses"], + d["outgoing_masses"], + [ms.Propagator(mass=m, width=w, pdg_id=i) + for m, w, i in d["propagators"]], + d["vertices"], + ) + ) + for d in diagrams + ], + [d["permutations"] for d in diagrams], + make_diagram_indices(diagrams), + cm_energy=CM_ENERGY, + external_pdg_ids=S_CHANNEL_TTBAR_PDGS, + scale_scheme=ms.MLMClustering.ScaleScheme.madevent, + ) + + +def transverse_mass(p): + return np.sqrt(np.maximum(p[..., 0] ** 2 - p[..., 3] ** 2, 0.0)) + + +def test_an_s_channel_qcd_root_uses_the_mean_transverse_mass_of_the_pair(): + """q q~ > g > t t~ is reported at sqrt(mT(t) mT(t~)), madevent's mt2last, + for both scales. Before this rule the root kept the mT of the whole final + state, sqrt(shat), which made q q~ > t t~ 18% low against madevent at + 0 jets.""" + gluon = (0.0, 0.0, 21) + momenta = sample_momenta(s_channel_ttbar_diagram(gluon), batch_size=500) + ren, fac1, fac2, _, _ = run(s_channel_ttbar_clustering(gluon), momenta) + + expected = np.sqrt( + transverse_mass(momenta[:, 2]) * transverse_mass(momenta[:, 3]) + ) + shat_mt = transverse_mass(momenta[:, 2] + momenta[:, 3]) + assert ren == pytest.approx(expected, rel=1e-9) + assert fac1 == pytest.approx(expected, rel=1e-9) + assert fac2 == pytest.approx(expected, rel=1e-9) + # and it is a different answer from the one the root would otherwise give + assert np.all(expected <= 0.5 * shat_mt * (1.0 + 1e-9)) + + +def test_a_colourless_s_channel_root_keeps_the_transverse_mass_of_the_final_state(): + """The rule needs the last clustering to be a QCD one. Through a Z the pair + joins into a colourless line, so the root keeps mT of the final state - the + Drell-Yan-like answer, and the reason W and Z + 0 jets never needed it.""" + z = (M_Z, W_Z, 23) + momenta = sample_momenta(s_channel_ttbar_diagram(z), batch_size=500) + ren, fac1, fac2, _, _ = run(s_channel_ttbar_clustering(z), momenta) + + shat_mt = transverse_mass(momenta[:, 2] + momenta[:, 3]) + assert ren == pytest.approx(shat_mt, rel=1e-9) + assert fac1 == pytest.approx(shat_mt, rel=1e-9) + assert fac2 == pytest.approx(shat_mt, rel=1e-9) + From d228b234735559c47d87b3788958c8f3818dd70c Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Mon, 14 Sep 2026 23:38:33 +0200 Subject: [PATCH 41/81] Give each end of a t-channel chain its own flavour and mass find_t_vertices collects the t-channel propagators on its way back from beam 1, so t_propagator_masses and t_propagator_pdg_ids start at the propagator next to that beam. The state-machine compiler read them the other way round: the mask built from beam 1 took at(t_count - 1 - k) and the one from beam 2 at(k). For a chain with a single propagator the two coincide, which is why W + 1 jet and every 0-jet process never showed it. With two or more the flavour and mass of the two ends were swapped. In g g > t t~ g, beam 0 emitting the jet leaves a gluon line, and the compiled transition said massive and not a jet - the top's properties - while beam 1 taking back the t~ said massless jet. So the beam's parton line stopped at the jet, jlast landed on the jet's own vertex, and mu_R became (pt(jet) mu_F^3)^(1/4) instead of the central scale: in t t~ + 1j at xqcut 30 that was 76% of madspace's events, against mu_R == mu_F in 94.8% of madevent's. The same swap also fed the wrong colour and pdf-reweighting flavour class into every such initial-state clustering. The new test decodes the compiled transitions of that chain; it fails on the old ordering with (massive, not a jet) for beam 0 + jet. Co-Authored-By: Claude Opus 5 --- madspace/src/phasespace/mlm_clustering.cpp | 22 +++++-- madspace/tests/test_mlm.py | 77 ++++++++++++++++++++++ 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index 3920dd2059..eee26c4840 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -682,9 +682,17 @@ MLMClustering::MLMClustering( } // for the t-channel part, one of the initial state particles has to be - // involved in the clustering. The k-th mask accumulated from either - // beam is the k-th t-channel propagator along the chain, which is - // walked starting from beam 2. + // involved in the clustering. find_t_vertices collects the t-channel + // propagators on the way back from beam 1 (index 0 of the incoming + // legs), so t_propagator_masses/pdg_ids start at the propagator next + // to that beam. The k-th mask accumulated from beam 1 (mask 1) is + // therefore propagator k, and the k-th from beam 2 (mask 2), walking + // the children in reverse, is propagator t_count - 1 - k. Getting + // this backwards swaps the flavour and mass of the two ends of every + // chain with two or more t-channel propagators: in g g > t t~ g the + // gluon line left after the beam emits the jet was read as the top, + // so the beam's parton line stopped at the jet and mu_R picked up + // the jet's own scale. auto& t_masses = topo.t_propagator_masses(); auto& t_pdg_ids = topo.t_propagator_pdg_ids(); std::size_t t_count = t_masses.size(); @@ -698,9 +706,9 @@ MLMClustering::MLMClustering( set_mask_meta( mask_meta, mask, - {.mass = t_masses.at(t_count - 1 - k), + {.mass = t_masses.at(k), .width = 0., - .pdg_id = t_pdg_ids.at(t_count - 1 - k)} + .pdg_id = t_pdg_ids.at(k)} ); } ++k; @@ -713,9 +721,9 @@ MLMClustering::MLMClustering( set_mask_meta( mask_meta, mask, - {.mass = t_masses.at(k), + {.mass = t_masses.at(t_count - 1 - k), .width = 0., - .pdg_id = t_pdg_ids.at(k)} + .pdg_id = t_pdg_ids.at(t_count - 1 - k)} ); } ++k; diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 84a0ea340d..bc4c2b2cc9 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -1759,3 +1759,80 @@ def test_a_colourless_s_channel_root_keeps_the_transverse_mass_of_the_final_stat assert fac1 == pytest.approx(shat_mt, rel=1e-9) assert fac2 == pytest.approx(shat_mt, rel=1e-9) + +# -------------------------------------------------------------------------- +# t-channel propagator flavours along the chain +# -------------------------------------------------------------------------- + + +def gg_ttbar_g_chain_diagram(): + """g g > t t~ g through a t-channel chain whose two propagators differ: + + beam 0 --emits the jet--> gluon --emits t--> top --meets beam 1--> t~ + + A chain with a single propagator reads the same from both ends, so only a + chain like this one can tell whether each end of it got its own flavour and + mass. Legs: (g, g, t, t~, g).""" + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [M_TOP, M_TOP, 0.0], + "propagators": [(0.0, 0.0, 21), (M_TOP, 0.0, 6)], + "vertices": [["i0", "o2", "p0"], ["p0", "o0", "p1"], ["p1", "o1", "i1"]], + "permutations": [[0, 1, 2, 3, 4]], + } + ] + + +GG_TTBAR_G_PDGS = [21, 21, 6, -6, 21] + + +def gg_ttbar_g_chain_clustering(): + diagrams = gg_ttbar_g_chain_diagram() + return ms.MLMClustering( + [ + ms.Topology( + ms.Diagram( + d["incoming_masses"], + d["outgoing_masses"], + [ms.Propagator(mass=m, width=w, pdg_id=i) + for m, w, i in d["propagators"]], + d["vertices"], + ) + ) + for d in diagrams + ], + [d["permutations"] for d in diagrams], + make_diagram_indices(diagrams), + cm_energy=CM_ENERGY, + external_pdg_ids=GG_TTBAR_G_PDGS, + scale_scheme=ms.MLMClustering.ScaleScheme.madevent, + ) + + +def first_initial_state_transitions(clustering, n_ext): + """{(beam, leg): (massive_in, is_jet_in)} for the clusterings the walk can + take first, which are the ones whose mother is the propagator next to a + beam.""" + machine = np.asarray(clustering.cluster_state_machine) + out = {} + for data, _, trace in decode_transitions(machine, 0): + p1, p2 = field(data, BIT_PARTICLE1), field(data, BIT_PARTICLE2) + if p1 < 2: + out[(p1, p2)] = ( + field(data, BIT_MASSIVE_IN), + int(bool(trace & TRACE_IS_JET_IN)), + ) + return out + + +def test_each_end_of_a_t_channel_chain_gets_its_own_flavour(): + """Beam 0 emitting the jet leaves the gluon propagator, massless and a jet; + beam 1 taking back the t~ leaves the top propagator, massive and not a jet. + With the two ends swapped the gluon was read as the top, the beam's parton + line stopped at the jet, and mu_R picked up the jet's own scale - 3 to 12% + too much t t~ + jets against madevent.""" + first = first_initial_state_transitions(gg_ttbar_g_chain_clustering(), 5) + assert first[(0, 4)] == (0, 1), "beam 0 + jet should leave a massless jet line" + assert first[(1, 3)] == (1, 0), "beam 1 + t~ should leave a massive, non-jet line" + From 0f54d5fd1129d445366698350e1b40dc259a21ac Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Mon, 14 Sep 2026 23:38:33 +0200 Subject: [PATCH 42/81] Measure the MLM root in the frame cluster.f leaves it in cluster.f boosts after an initial-state clustering only when nleft > 4 ("Don't boost if boost vector too lightlike or last vertex"), i.e. never at the last recorded clustering, and takes the 2 -> 1 root's scale as djb of what is left in whatever frame that leaves: the lab after a final-state last clustering, which it boosts back first, and the current frame after an initial-state one. madspace boosted after every initial-state clustering and always took the root in the lab. In t t~ + 1j with the jet taken into a beam first, the frame madevent ends in is the one where t and t~ balance, so the top's vertex and the root carry the same scale and mu_R == mu_F. Taking the root in the lab left madspace's mu_R a few per cent below mu_F even with the chain flavours right. W + 1 jet is unchanged by construction: there is no earlier boost, and madevent's mu_F equals the lab mT(e nu) in 84.8% of its events. After a final-state last clustering cluster.f undoes only the most recent boost; the lab copy undoes all of them, which is the same as long as at most one boosting initial-state clustering came before. The new test checks mu_R == mu_F on the jet-into-beam histories of g g > t t~ g; it passed on no point with either this or the chain ordering wrong. Together with the previous commit, p p > t t~ + 0-3j against madevent, 50k events each, madevent samples unchanged, ratio before -> after: xqcut 0j 1j 2j 3j total 20 0.990->0.990 1.029->0.984 1.033->0.976 1.103->1.041 1.039->0.995 30 0.995->0.995 1.047->1.008 1.056->1.005 1.111->1.062 1.041->1.010 45 0.995->0.997 1.028->0.996 1.073->1.025 1.124->1.078 1.024->1.005 60 0.994->0.994 1.039->1.015 1.046->1.005 1.041->1.009 1.014->1.002 and p p > e+ ve + 0-3j moves only within statistics (totals within 0.1%, 0j/1j unchanged). What is left in t t~ is the 3-jet rate, 4-8% high at xqcut 20-45. Co-Authored-By: Claude Opus 5 --- madspace/src/kernels/mlm.hpp | 42 ++++++++++++++++++++++++++++++------ madspace/tests/test_mlm.py | 25 +++++++++++++++++++++ 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 8f6957270b..bae09b6d7b 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -183,7 +183,8 @@ KERNELSPEC void update_momenta( int& alive, int i_remove, int i_keep, - bool resonant + bool resonant, + bool allow_boost ) { alive &= ~(1 << i_remove); @@ -203,7 +204,10 @@ KERNELSPEC void update_momenta( -(momenta[i_keep][2] + momenta[j_other][2]), -(momenta[i_keep][3] + momenta[j_other][3]), }; - if (lsquare(com_boost_vector) > 100.0) { + // cluster.f skips the boost at the last clustering ("Don't boost if boost + // vector too lightlike or last vertex", nleft > 4), so the frame the + // 2 -> 1 root is measured in is the one left by the previous step. + if (allow_boost && lsquare(com_boost_vector) > 100.0) { // boost j_keep to COM frame to define the rotation axis, then apply to all // alive particles auto jkeep_cm = boost(momenta[i_keep], com_boost_vector, 1.0); @@ -274,10 +278,16 @@ KERNELSPEC void mlm_clustering( int n_part = momenta.size(); FourMom momenta_tmp[N_EXT_MAX]; // The same clustering tracked in the lab frame, without the boosts and - // rotations update_momenta applies. Only the 2 -> 1 root needs it: cluster.f - // boosts and rotates back before taking that vertex's scale, so it is a - // transverse mass in the lab and not in whatever frame the walk ended up in. + // rotations update_momenta applies. Only the 2 -> 1 root needs it, and only + // when the last clustering was a final-state one: cluster.f then boosts and + // rotates the remaining object back before taking the root's transverse + // mass. It undoes only the most recent boost; this copy undoes all of them, + // which is the same thing whenever at most one boosting initial-state + // clustering came before. After an initial-state last clustering cluster.f + // takes the root in the current frame instead, see last_is_initial. FourMom momenta_lab[N_EXT_MAX]; + // Whether the last recorded clustering was an initial-state one. + bool last_is_initial = false; FVal masses_tmp[N_EXT_MAX]; int alive = (1 << n_part) - 1; int cluster_history[N_EXT_MAX - 3]; @@ -428,8 +438,18 @@ KERNELSPEC void mlm_clustering( ? momenta_lab[p1_win][k] - momenta_lab[p2_win][k] : momenta_lab[p1_win][k] + momenta_lab[p2_win][k]; } + if (cluster_count == cluster_max - 1) { + last_is_initial = p1_win < 2; + } update_momenta( - n_part, momenta_tmp, masses_tmp, alive, p2_win, p1_win, win_resonant + n_part, + momenta_tmp, + masses_tmp, + alive, + p2_win, + p1_win, + win_resonant, + cluster_count < cluster_max - 1 ); state = win_next_state; cluster_history[cluster_count] = win_data; @@ -676,9 +696,17 @@ KERNELSPEC void mlm_clustering( break; } } + // cluster.f's pt2ijcl(n+1) = djb(pcl(imap(3,2))): the lab frame after a + // final-state last clustering (it boosts back first), the current frame + // after an initial-state one. In t t~ + jet with the jet taken into a + // beam first, that current frame is the one where t and t~ balance, so + // the root and the top's vertex carry the same scale and mu_R == mu_F. FVal extra_scale = leftover < 0 ? FVal(0.0) - : sqrt(djb_clus(momenta_lab[leftover], hadronic)); + : sqrt(djb_clus( + last_is_initial ? momenta_tmp[leftover] : momenta_lab[leftover], + hadronic + )); // Follow each beam's parton line through the clustering, exactly as // setclscales does in Template/LO/SubProcesses/reweight.f. // diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index bc4c2b2cc9..e28017d4a2 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -1836,3 +1836,28 @@ def test_each_end_of_a_t_channel_chain_gets_its_own_flavour(): assert first[(0, 4)] == (0, 1), "beam 0 + jet should leave a massless jet line" assert first[(1, 3)] == (1, 0), "beam 1 + t~ should leave a massive, non-jet line" + +def test_a_jet_emitted_before_the_tops_does_not_set_the_renormalisation_scale(): + """setclscales carries a beam's parton line on past a jet emission to the + vertex where the top attaches, so when the jet is clustered into a beam and + the tops make up the rest, all four scales in the mu_R average are central + ones and mu_R equals mu_F. The jet's own scale entering mu_R is exactly the + symptom of the swapped chain, whose kernel gave mu_R == mu_F on no point. + It also needs the root measured where cluster.f measures it: not boosted + after the last clustering, and in the current frame after an initial-state + one. Taking it in the lab left mu_R a few per cent below mu_F.""" + clustering = gg_ttbar_g_chain_clustering() + momenta = sample_momenta(gg_ttbar_g_chain_diagram(), batch_size=4000) + ren, fac1, fac2, outgoing, _ = run(clustering, momenta) + jet = momenta[:, 4, :] + jet_pt = np.sqrt(jet[:, 1] ** 2 + jet[:, 2] ** 2) + # the jet went into a beam: its clustering scale is its transverse momentum + jet_into_beam = np.abs(outgoing[:, 2] / jet_pt - 1.0) < 1e-9 + assert jet_into_beam.sum() > 500, "too few jet-into-beam histories to test" + # the root is measured in the frame the boost after the jet left behind, + # where t and t~ balance, so the equality holds up to rounding + same = np.abs(ren / fac1 - 1.0) < 1e-6 + assert np.mean(same[jet_into_beam]) > 0.5, ( + "mu_R == mu_F on only %.1f%% of jet-into-beam points" + % (100 * np.mean(same[jet_into_beam])) + ) From 87c49679cedf3ddf4748a910029429e834169797 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Tue, 15 Sep 2026 22:05:00 +0200 Subject: [PATCH 43/81] Add a switch for the MLM clustering measure: FxFx or madevent's DJ beam.clustering_measure picks how a candidate final-state clustering is scored under dynamical_scale_choice = "mlm". "fxfx" (default, unchanged behaviour) keeps the NLO cluster_scale; "madevent" uses the LO DJ of kin_functions.f, where a massless-massive pair (q* > q W, g* > g h) scores the massless leg's transverse mass times 1 + 1e-6, and settles the exact tie with an initial-state clustering the way cluster.f does. The FxFx score of those pairs is often below the jet's pt, which is what left W + 1 jet and HEFT h + 1 jet low against madevent. Co-Authored-By: Claude Opus 5 --- madgraph/iolibs/template_files/mg7/launch.py | 4 + .../iolibs/template_files/mg7/run_card.toml | 5 + madgraph/various/banner.py | 12 +++ madspace/generate_pyi.py | 1 + .../madspace/phasespace/mlm_clustering.hpp | 22 ++++- madspace/instruction_set.yaml | 6 ++ madspace/src/kernels/mlm.hpp | 51 +++++++++- madspace/src/phasespace/mlm_clustering.cpp | 10 +- madspace/src/python/madspace.cpp | 17 +++- madspace/tests/test_mlm.py | 98 +++++++++++++++++++ 10 files changed, 217 insertions(+), 9 deletions(-) diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index 0c3d63f5ef..1481194583 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -1649,6 +1649,10 @@ def __init__( pdf_reweighting=self.process.run_card["beam"][ "pdf_reweighting" ], + clustering_measure=getattr( + ms.MLMClustering.ClusteringMeasure, + self.process.run_card["beam"]["clustering_measure"], + ), ), min_scale=self.process.run_card["beam"]["min_scale"], max_scale=max_scale, diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index 541b73fe50..7868d9bbff 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -69,6 +69,11 @@ alphas_reweighting = %(beam.alphas_reweighting)s # at the factorisation scale, as madevent's pdfwgt does. Only used under # dynamical_scale_choice = "mlm" and scale_scheme = "madevent" pdf_reweighting = %(beam.pdf_reweighting)s +# measure scoring a candidate final-state clustering (dynamical_scale_choice = +# "mlm"). options: fxfx (NLO cluster_scale), madevent (madevent's LO DJ: a +# massless-massive pair scores the massless one's transverse mass). They only +# differ for e.g. q* > q W and g* > g h; madevent matches madevent's MLM +clustering_measure = %(beam.clustering_measure)s # floor on mu_R and mu_F: an event whose scales fall below this is dropped, # since a PDF grid has no densities there (0 disables it) min_scale = %(beam.min_scale)s diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index 7e73c40227..d3d61325b7 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6589,6 +6589,18 @@ def default_setup(self): # is on by default there. Only read when dynamical_scale_choice = "mlm" # and scale_scheme = "madevent". self.add_toml_param('beam', 'pdf_reweighting', True) + # Which measure scores a candidate final-state clustering. The two only + # differ for a non-resonant pair with a massless mother and one massive + # and one massless daughter (q* > q W, g* > g h), or a massive mother + # with two massless daughters: + # "fxfx": cluster_scale of Template/NLO/SubProcesses/cluster.f, + # sqrt(|p_j.(p_i+p_j)|)/2 and the invariant mass + # "madevent": DJ of Template/LO/Source/kin_functions.f, as madevent's + # LO clustering does: the massless one's transverse mass + # for a massless-massive pair, the kt measure otherwise + # Only read when dynamical_scale_choice = "mlm". + self.add_toml_param('beam', 'clustering_measure', "fxfx", + allowed=['fxfx', 'madevent']) # Floor on mu_R and mu_F, whatever the dynamical scale choice. Below # the lowest Q of a PDF grid the densities are undefined, so an event # whose scales fall under this is dropped. madevent applies the same diff --git a/madspace/generate_pyi.py b/madspace/generate_pyi.py index 660eaf753e..90801f5cb4 100644 --- a/madspace/generate_pyi.py +++ b/madspace/generate_pyi.py @@ -61,6 +61,7 @@ def main(): "--enum-class-locations=ScaleScheme:MLMClustering.ScaleScheme", "--enum-class-locations=PartonLineScheme:MLMClustering.PartonLineScheme", "--enum-class-locations=AlphasScheme:MLMClustering.AlphasScheme", + "--enum-class-locations=ClusteringMeasure:MLMClustering.ClusteringMeasure", # Fail the build on unresolvable names/expressions (e.g. an enum # default value needing its own --enum-class-locations entry) # instead of silently emitting a stub with holes in it. diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp index 7c10c6de63..a154bf49e6 100644 --- a/madspace/include/madspace/phasespace/mlm_clustering.hpp +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -65,6 +65,23 @@ enum class AlphasScheme { geometric_mean = 2, }; +// Which clustering measure scores a candidate final-state pair. Only the pairs +// the two definitions disagree on are affected: a non-resonant final-state +// clustering whose mother is massless with one massive and one massless +// daughter (q* > q W, g* > g h), or whose mother is massive with two massless +// daughters. Initial-state and resonant clusterings are the same in both. +enum class ClusteringMeasure { + // cluster_scale of Template/NLO/SubProcesses/cluster.f, the FxFx + // definition: sqrt(|p_j . (p_i + p_j)|) / 2 for the massless-mother case and + // the invariant mass for the massive-mother one. + fxfx = 0, + // DJ of Template/LO/Source/kin_functions.f, which madevent's LO cluster.f + // uses for every final-state pair: a massless-massive pair scores the + // massless one's transverse mass (times 1 + 1e-6), and anything else the + // kt measure with the larger mass squared added. + madevent = 1, +}; + class MLMClustering : public FunctionGenerator { public: MLMClustering( @@ -100,7 +117,8 @@ class MLMClustering : public FunctionGenerator { // Re-evaluate the beam densities along the clustering ladder instead of // once at the factorisation scale, which is what madevent does for a // merged sample (pdfwgt in the run card, hidden and on by default). - bool pdf_reweighting = true + bool pdf_reweighting = true, + ClusteringMeasure clustering_measure = ClusteringMeasure::fxfx ); // The compiled clustering state machine, in the flat encoding the kernel @@ -113,6 +131,7 @@ class MLMClustering : public FunctionGenerator { const std::vector& bw_widths() const { return _bw_widths; } AlphasScheme alphas_scheme() const { return _alphas_scheme; } bool pdf_reweighting() const { return _pdf_reweighting; } + ClusteringMeasure clustering_measure() const { return _clustering_measure; } // The flavours the pdf reweighting asks for that are neither the gluon nor // a beam's own, in the order the kernel's flavour classes index them. The // consumer turns these, the gluon and the per-channel beam flavours into @@ -135,6 +154,7 @@ class MLMClustering : public FunctionGenerator { PartonLineScheme _parton_line_scheme; AlphasScheme _alphas_scheme; bool _pdf_reweighting; + ClusteringMeasure _clustering_measure; std::vector _pdf_absolute_pdgs; int _beam_flags; int _jet_leg_mask; diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index b164c0eb17..3225e1d61a 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -2259,6 +2259,9 @@ mlm_clustering_hadronic: - name: pdf_reweighting type: [int, single] desc: + - name: clustering_measure + type: [int, single] + desc: outputs: - name: ren_scale type: [float] @@ -2364,6 +2367,9 @@ mlm_clustering_leptonic: - name: pdf_reweighting type: [int, single] desc: + - name: clustering_measure + type: [int, single] + desc: outputs: - name: ren_scale type: [float] diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index bae09b6d7b..4f26e717e9 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -31,6 +31,10 @@ constexpr int SCALES_MADEVENT = 1; // daughter's own flavour, or from the propagated goodjet flag of reweight.f. constexpr int LINE_FLAVOR = 0; constexpr int LINE_GOODJET = 1; +// ClusteringMeasure in mlm_clustering.hpp: which definition scores a +// non-resonant final-state clustering. +constexpr int MEASURE_FXFX = 0; +constexpr int MEASURE_MADEVENT = 1; // How alpha_s is evaluated for a merged event. constexpr int ALPHAS_NONE = 0; // one coupling at mu_R, as before constexpr int ALPHAS_PER_VERTEX = 1; // alphas(pt_i) at each vertex, as madevent @@ -147,11 +151,24 @@ KERNELSPEC FVal compute_scale( bool massive_out1, bool massive_out2, bool hadronic, - FVal jet_radius + FVal jet_radius, + int measure ) { if (is_initial) { // scale = mT of the final-state parton // small penalty when it goes against the beam + if (measure == MEASURE_MADEVENT) { + // LO cluster.f multiplies pt2ij, the squared measure, by 1 + 1e-6, + // which is exactly the factor DJ gives a massless-massive + // final-state pair: the two tie, and madevent keeps the + // initial-state candidate it evaluated first. Taking the square + // root of the same product keeps the tie bit-for-bit here. + auto djb = djb_clus(momentum2, hadronic); + if ((momentum1[3] < 0.0) != (momentum2[3] < 0.0)) { + djb = djb * ONE_PLUS_TINY; + } + return sqrt(djb); + } auto scale = sqrt(djb_clus(momentum2, hadronic)); if ((momentum1[3] < 0.0) != (momentum2[3] < 0.0)) { scale = scale * ONE_PLUS_TINY; @@ -161,6 +178,17 @@ KERNELSPEC FVal compute_scale( if (resonant) { return sqrt(max(lsquare(momentum_sum), 0.0)); } + if (measure == MEASURE_MADEVENT) { + // DJ of Template/LO/Source/kin_functions.f, which madevent's LO + // cluster.f takes for every final-state pair: dj_clus already scores a + // massless-massive pair by the massless one's transverse mass (times + // 1 + 1e-6) and anything else by the kt measure with the larger mass + // squared added. The branches below are the NLO cluster_scale's, which + // score q* > q W and g* > g h by sqrt(|p_j.(p_i+p_j)|)/2 instead - often + // below the jet's pt, so the jet pairs with the boson where madevent + // takes it into a beam. + return sqrt(dj_clus(momentum1, momentum2, mass1, mass2, hadronic, jet_radius)); + } if (!massive_in && massive_out1 && !massive_out2) { return sqrt(fabs(ldot(momentum2, momentum_sum))) / 2.0; } @@ -251,6 +279,7 @@ KERNELSPEC void mlm_clustering( IIn parton_line_scheme, IIn alphas_scheme, IIn pdf_reweighting, + IIn clustering_measure, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -318,6 +347,7 @@ KERNELSPEC void mlm_clustering( int win_next_state = -1, win_data = 0, win_trace = TRACE_FIRST; bool win_resonant = false; + bool win_initial = false; FVal win_scale = SCALE_MAX * 10.; while (cluster_count < cluster_max) { int data = state_machine[state]; @@ -362,7 +392,8 @@ KERNELSPEC void mlm_clustering( massive_out1, massive_out2, hadronic, - jet_radius + jet_radius, + clustering_measure ); // An exactly collinear pair - which the boost and rotation applied after @@ -377,13 +408,22 @@ KERNELSPEC void mlm_clustering( // The MG5 fortran code extracted the resonance structure from the integration // channel. This is not always possible in MG7, so prefer resonant configs // over non-resonant ones + // madevent's cluster.f keeps the first of two equal candidates, and it + // always tries a leg against the beams before the final-state legs. Under + // the madevent measure an initial-state clustering and a massless-massive + // final-state one can tie exactly, so settle that tie the same way + // whatever order the state machine lists them in. + bool beats_on_tie = clustering_measure == MEASURE_MADEVENT && + is_initial && !win_initial && + !(scale > win_scale * (1.0 + 1e-12)); if (win_next_state == -1 || (!win_resonant && resonant) || - (win_resonant == resonant && scale < win_scale)) { + (win_resonant == resonant && (scale < win_scale || beats_on_tie))) { win_next_state = next_state; win_scale = scale; win_data = data; win_trace = trace_data; win_resonant = resonant; + win_initial = is_initial; } if (is_last) { int p1_win = win_data & 0xFF; @@ -464,6 +504,7 @@ KERNELSPEC void mlm_clustering( win_data = 0; win_trace = TRACE_FIRST; win_resonant = false; + win_initial = false; win_scale = SCALE_MAX * 10.; } else { state += STATE_ITEM_SIZE; @@ -1316,6 +1357,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( IIn parton_line_scheme, IIn alphas_scheme, IIn pdf_reweighting, + IIn clustering_measure, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -1351,6 +1393,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( parton_line_scheme, alphas_scheme, pdf_reweighting, + clustering_measure, ren_scale, fact_scale1, fact_scale2, @@ -1390,6 +1433,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( IIn parton_line_scheme, IIn alphas_scheme, IIn pdf_reweighting, + IIn clustering_measure, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -1425,6 +1469,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( parton_line_scheme, alphas_scheme, pdf_reweighting, + clustering_measure, ren_scale, fact_scale1, fact_scale2, diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index eee26c4840..d112f3a95f 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -501,7 +501,8 @@ MLMClustering::MLMClustering( int max_jet_flavor, PartonLineScheme parton_line_scheme, AlphasScheme alphas_scheme, - bool pdf_reweighting + bool pdf_reweighting, + ClusteringMeasure clustering_measure ) : FunctionGenerator( "MLMClustering", @@ -544,6 +545,7 @@ MLMClustering::MLMClustering( _parton_line_scheme(parton_line_scheme), _alphas_scheme(alphas_scheme), _pdf_reweighting(pdf_reweighting), + _clustering_measure(clustering_measure), _beam_flags(0), _jet_leg_mask(0), _xqcut(xqcut), @@ -841,7 +843,8 @@ NamedVector MLMClustering::build_function_impl( static_cast(_jet_leg_mask), static_cast(_parton_line_scheme), static_cast(_alphas_scheme), - static_cast(_pdf_reweighting) + static_cast(_pdf_reweighting), + static_cast(_clustering_measure) ); } else { mlm_out = fb.mlm_clustering_leptonic( @@ -861,7 +864,8 @@ NamedVector MLMClustering::build_function_impl( static_cast(_jet_leg_mask), static_cast(_parton_line_scheme), static_cast(_alphas_scheme), - static_cast(_pdf_reweighting) + static_cast(_pdf_reweighting), + static_cast(_clustering_measure) ); } return {return_types().keys(), {mlm_out.begin(), mlm_out.end()}}; diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index fa769cf42b..77f4099f6c 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -1017,6 +1017,14 @@ PYBIND11_MODULE(_madspace_py, m) { {"geometric_mean", AlphasScheme::geometric_mean}, } ); + add_enum( + mlm, + "ClusteringMeasure", + { + {"fxfx", ClusteringMeasure::fxfx}, + {"madevent", ClusteringMeasure::madevent}, + } + ); add_enum( mlm, "PartonLineScheme", @@ -1043,7 +1051,8 @@ PYBIND11_MODULE(_madspace_py, m) { int, PartonLineScheme, AlphasScheme, - bool>(), + bool, + ClusteringMeasure>(), py::arg("topologies"), py::arg("permutations"), py::arg("diagram_indices"), @@ -1059,11 +1068,15 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("max_jet_flavor") = 4, py::arg("parton_line_scheme") = PartonLineScheme::goodjet, py::arg("alphas_scheme") = AlphasScheme::per_vertex, - py::arg("pdf_reweighting") = true + py::arg("pdf_reweighting") = true, + py::arg("clustering_measure") = ClusteringMeasure::fxfx ) .def_property_readonly( "pdf_absolute_pdgs", &MLMClustering::pdf_absolute_pdgs ) + .def_property_readonly( + "clustering_measure", &MLMClustering::clustering_measure + ) .def_property_readonly( "cluster_state_machine", &MLMClustering::cluster_state_machine ) diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index e28017d4a2..6729e8d3a8 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -1861,3 +1861,101 @@ def test_a_jet_emitted_before_the_tops_does_not_set_the_renormalisation_scale(): "mu_R == mu_F on only %.1f%% of jet-into-beam points" % (100 * np.mean(same[jet_into_beam])) ) + + +# -------------------------------------------------------------------------- +# clustering_measure: FxFx (NLO cluster_scale) against madevent's LO DJ +# -------------------------------------------------------------------------- + + +def w_plus_jet_two_diagrams(): + """g u > e+ ve d with both ways the jet can be clustered: + + s-channel: g u > u*, u* > d W - the jet pairs with the W (massless mother, + massive and massless daughters, the case the measures disagree + on) + t-channel: u emits the W and becomes d*, d* g > d - the jet goes into + beam 0 + + madevent scores the (W, d) pair by the d's transverse mass times 1 + 1e-6, + which never beats taking the d into a beam; the FxFx measure scores it by + sqrt(|p_d.(p_d+p_W)|)/2, which often does.""" + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0, 0.0], + "propagators": [(0.0, 0.0, 2), (M_W, W_W, 24)], + "vertices": [["i1", "i0", "p0"], ["o0", "o1", "p1"], ["p0", "o2", "p1"]], + "permutations": [[0, 1, 2, 3, 4]], + }, + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0, 0.0], + "propagators": [(0.0, 0.0, 1), (M_W, W_W, 24)], + "vertices": [["o0", "o1", "p1"], ["i1", "p1", "p0"], ["p0", "i0", "o2"]], + "permutations": [[0, 1, 2, 3, 4]], + }, + ] + + +def w_plus_jet_two_diagram_clustering(**kwargs): + diagrams = w_plus_jet_two_diagrams() + return ms.MLMClustering( + [ + ms.Topology( + ms.Diagram( + d["incoming_masses"], + d["outgoing_masses"], + [ms.Propagator(mass=m, width=w, pdg_id=i) + for m, w, i in d["propagators"]], + d["vertices"], + ) + ) + for d in diagrams + ], + [d["permutations"] for d in diagrams], + make_diagram_indices(diagrams), + cm_energy=CM_ENERGY, + external_pdg_ids=W_PLUS_JET_PDGS, + scale_scheme=ms.MLMClustering.ScaleScheme.madevent, + **kwargs, + ) + + +def jet_into_beam_fraction(clustering, momenta): + outgoing = run(clustering, momenta)[3] + jet = momenta[:, 4, :] + jet_pt = np.sqrt(jet[:, 1] ** 2 + jet[:, 2] ** 2) + return np.mean(np.abs(outgoing[:, 2] / jet_pt - 1.0) < 1e-5) + + +def test_fxfx_is_the_default_clustering_measure(): + clustering = w_plus_jet_two_diagram_clustering() + assert clustering.clustering_measure == ms.MLMClustering.ClusteringMeasure.fxfx + + +def test_the_madevent_measure_takes_the_jet_into_the_beam(): + """Under madevent's measure the jet never pairs with the W: its scale is its + transverse momentum on every point, as in madevent (99% there, the rest + being histories without this choice). Half of these jets go against the + beam, where only the LO ordering of the 1 + 1e-6 factors and madevent's + keep-the-first tie-break decide it.""" + momenta = sample_momenta(w_plus_jet_two_diagrams(), batch_size=4000) + clustering = w_plus_jet_two_diagram_clustering( + clustering_measure=ms.MLMClustering.ClusteringMeasure.madevent + ) + assert jet_into_beam_fraction(clustering, momenta) == 1.0 + + +def test_the_fxfx_measure_lets_the_jet_pair_with_the_w(): + """The FxFx score of the (W, d) pair is often below the d's pt, so some jets + pair with the W instead - which is exactly what made W + 1 jet (quark jets) + and h + 1 jet (gluon jets) low against madevent.""" + momenta = sample_momenta(w_plus_jet_two_diagrams(), batch_size=4000) + fxfx = jet_into_beam_fraction( + w_plus_jet_two_diagram_clustering( + clustering_measure=ms.MLMClustering.ClusteringMeasure.fxfx + ), + momenta, + ) + assert fxfx < 0.95, "the FxFx measure should pair a visible fraction with the W" From 62897e4eeedd7b10467a061aa203f892c6b02266 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 16 Sep 2026 01:44:52 +0200 Subject: [PATCH 44/81] auto_ptj_mjj: ask for the jet pair mass by its current name The merging-cut adjustment still tightened a cut called jet-m_inv, the name the observable had when auto_ptj_mjj was written. main has since renamed it to pair_mass, so every mg7 run with phasespace.xqcut > 0 died building the cut: ms.Observable rejects observable='m_inv'. Co-Authored-By: Claude Opus 5 --- madgraph/iolibs/template_files/mg7/launch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index 9798366b2e..d8a6b90d73 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -468,7 +468,7 @@ def apply_auto_ptj_mjj(self, cuts: dict) -> None: # Leaving mjj out is what makes the highest multiplicity misbehave at a # tight xqcut: almost every point it proposes is vetoed, and the few # survivors sit against a boundary it cannot see. - for key in ("jet-pt", "jet-m_inv"): + for key in ("jet-pt", "jet-pair_mass"): if auto: cuts.setdefault(key, {})["min"] = xqcut elif cuts.get(key, {}).get("min", 0.0) > xqcut: From c52e841877941883ad8581f0540fb5d316986a9b Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Tue, 15 Sep 2026 22:20:49 +0200 Subject: [PATCH 45/81] Compile one MLM clustering state machine per diagram MLMClustering takes a clustering_history. Under all_diagrams (the default) nothing changes. Otherwise every diagram also gets a state machine of its own, built from that diagram's lines only and appended after the one over every diagram. The union keeps the first flavour it meets on a mask, so the W/Z of a VBF diagram can end up read as gluon fusion's gluon; the per-diagram machines keep each diagram's own. The kernel takes a start state per event, and a history_mode. Under madevent it walks the diagram and the union, and keeps the union unless the two call a different number of legs merging jets, as setclscales does when it reclusters along the channel. MLMClusteringAlongDiagram exposes the walk from Python. Co-Authored-By: Claude Opus 5 --- madspace/generate_pyi.py | 1 + .../madspace/phasespace/mlm_clustering.hpp | 61 +++- madspace/instruction_set.yaml | 12 + madspace/src/kernels/mlm.hpp | 151 +++++++++- madspace/src/phasespace/mlm_clustering.cpp | 285 ++++++++++++++---- madspace/src/python/madspace.cpp | 26 +- madspace/tests/test_mlm.py | 127 ++++++++ 7 files changed, 591 insertions(+), 72 deletions(-) diff --git a/madspace/generate_pyi.py b/madspace/generate_pyi.py index 90801f5cb4..248cedbf65 100644 --- a/madspace/generate_pyi.py +++ b/madspace/generate_pyi.py @@ -62,6 +62,7 @@ def main(): "--enum-class-locations=PartonLineScheme:MLMClustering.PartonLineScheme", "--enum-class-locations=AlphasScheme:MLMClustering.AlphasScheme", "--enum-class-locations=ClusteringMeasure:MLMClustering.ClusteringMeasure", + "--enum-class-locations=ClusteringHistory:MLMClustering.ClusteringHistory", # Fail the build on unresolvable names/expressions (e.g. an enum # default value needing its own --enum-class-locations entry) # instead of silently emitting a stub with holes in it. diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp index a154bf49e6..b4596bb0dc 100644 --- a/madspace/include/madspace/phasespace/mlm_clustering.hpp +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -82,6 +82,23 @@ enum class ClusteringMeasure { madevent = 1, }; +// Which diagrams a clustering history may follow. +enum class ClusteringHistory { + // The smallest-measure history over the union of every diagram, with the + // properties of each line taken from the first diagram that has it. + all_diagrams = 0, + // Pick one diagram per event, with probability proportional to its + // single-diagram weight |A_i|^2, and cluster along that diagram alone: its + // own line flavours, vertices and merging jets. What an integration + // channel is to madevent's reclustering, without its first-event + // bookkeeping. + diagram = 1, + // setclscales of Template/LO/SubProcesses/reweight.f: keep the history + // over every diagram unless it calls a different number of legs merging + // jets than the picked diagram does, and fall back to that diagram then. + madevent = 2, +}; + class MLMClustering : public FunctionGenerator { public: MLMClustering( @@ -118,9 +135,24 @@ class MLMClustering : public FunctionGenerator { // once at the factorisation scale, which is what madevent does for a // merged sample (pdfwgt in the run card, hidden and on by default). bool pdf_reweighting = true, - ClusteringMeasure clustering_measure = ClusteringMeasure::fxfx + ClusteringMeasure clustering_measure = ClusteringMeasure::fxfx, + // Anything but all_diagrams also compiles one state machine per + // diagram, which build_along_diagram walks. + ClusteringHistory clustering_history = ClusteringHistory::all_diagrams ); + // The same outputs as the function itself, for a history restricted to + // the given diagram index (as the matrix element numbers diagrams). A + // diagram no topology was supplied for falls back to the history over + // every diagram. + NamedVector + build_along_diagram(FunctionBuilder& fb, Value momenta, Value diagram) const; + // The same from an offset into cluster_state_machine, as + // diagram_start_states lists them, for a caller that keeps its own table. + NamedVector build_from_start_state( + FunctionBuilder& fb, Value momenta, Value start_state + ) const; + // The compiled clustering state machine, in the flat encoding the kernel // walks. Exposed so that its structure can be checked directly. const std::vector& cluster_state_machine() const { @@ -132,6 +164,13 @@ class MLMClustering : public FunctionGenerator { AlphasScheme alphas_scheme() const { return _alphas_scheme; } bool pdf_reweighting() const { return _pdf_reweighting; } ClusteringMeasure clustering_measure() const { return _clustering_measure; } + ClusteringHistory clustering_history() const { return _clustering_history; } + // Offset of each diagram's own state machine in cluster_state_machine, + // indexed by diagram index, or 0 for a diagram that has none. Empty under + // ClusteringHistory::all_diagrams. + const std::vector& diagram_start_states() const { + return _diagram_start_states; + } // The flavours the pdf reweighting asks for that are neither the gluon nor // a beam's own, in the order the kernel's flavour classes index them. The // consumer turns these, the gluon and the per-channel beam flavours into @@ -143,8 +182,12 @@ class MLMClustering : public FunctionGenerator { NamedVector build_function_impl( FunctionBuilder& fb, const NamedVector& args ) const override; + NamedVector build_kernel( + FunctionBuilder& fb, Value momenta, Value start_state, me_int_t history_mode + ) const; std::vector _cluster_state_machine; + std::vector _diagram_start_states; std::vector _external_masses; std::vector _bw_masses; std::vector _bw_widths; @@ -155,6 +198,7 @@ class MLMClustering : public FunctionGenerator { AlphasScheme _alphas_scheme; bool _pdf_reweighting; ClusteringMeasure _clustering_measure; + ClusteringHistory _clustering_history; std::vector _pdf_absolute_pdgs; int _beam_flags; int _jet_leg_mask; @@ -164,4 +208,19 @@ class MLMClustering : public FunctionGenerator { bool _hadronic; }; +// The clustering along one diagram as a function of its own, taking the +// diagram index as a second argument. The integrand calls build_along_diagram +// directly; this is how that path is reached from Python. +class MLMClusteringAlongDiagram : public FunctionGenerator { +public: + MLMClusteringAlongDiagram(const MLMClustering& clustering); + +private: + NamedVector build_function_impl( + FunctionBuilder& fb, const NamedVector& args + ) const override; + + MLMClustering _clustering; +}; + } // namespace madspace diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index a076c5b645..431a4b9d47 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -2214,6 +2214,9 @@ mlm_clustering_hadronic: - name: random type: [float] desc: + - name: start_state + type: [int] + desc: state machine offset the walk starts at, 0 for the history over every diagram - name: state_machine type: [int, single, m] desc: @@ -2262,6 +2265,9 @@ mlm_clustering_hadronic: - name: clustering_measure type: [int, single] desc: + - name: history_mode + type: [int, single] + desc: ClusteringHistory, how the start state and the history over every diagram are combined outputs: - name: ren_scale type: [float] @@ -2322,6 +2328,9 @@ mlm_clustering_leptonic: - name: random type: [float] desc: + - name: start_state + type: [int] + desc: state machine offset the walk starts at, 0 for the history over every diagram - name: state_machine type: [int, single, m] desc: @@ -2370,6 +2379,9 @@ mlm_clustering_leptonic: - name: clustering_measure type: [int, single] desc: + - name: history_mode + type: [int, single] + desc: ClusteringHistory, how the start state and the history over every diagram are combined outputs: - name: ren_scale type: [float] diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 4f26e717e9..715c8c2449 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -260,8 +260,12 @@ KERNELSPEC void update_momenta( } } +// One clustering history, walked from start_state: 0 is the root of the +// state machine over every diagram, anything else the root of the one compiled +// for a single diagram. Writes every output and returns the number of outgoing +// legs the history calls merging jets. template -KERNELSPEC void mlm_clustering( +KERNELSPEC int mlm_clustering_walk( FIn momenta, FIn random, IIn state_machine, @@ -296,13 +300,14 @@ KERNELSPEC void mlm_clustering( FOut pdf_rw_q_den, FOut pdf_rw_active, FOut pdf_rw_beam, + int start_state, bool hadronic ) { // we do not support SIMD for now, so we can assume simple types static_assert(std::is_same_v, int>); static_assert(std::is_same_v, double>); - int state = 0, cluster_count = 0; + int state = start_state, cluster_count = 0; int cluster_max = momenta.size() - 3; int n_part = momenta.size(); FourMom momenta_tmp[N_EXT_MAX]; @@ -1336,12 +1341,147 @@ KERNELSPEC void mlm_clustering( rand_index = diag_count - 1; } diagram_index = state_machine[state + rand_index + 1]; + + // How many outgoing legs this history calls merging jets: iqjets where the + // madevent scale scheme has filled it, a jet emitted at a QCD vertex + // otherwise - the same test the merging cut above falls back to. This is + // njets of setclscales, which HISTORY_MADEVENT compares between histories. + int merging_jet_count = 0; + if (have_merging_jets) { + for (int leg = 2; leg < n_part; ++leg) { + if (merging_jet[leg]) { + ++merging_jet_count; + } + } + } else { + int emitted = 0; + for (int i = 0; i < cluster_max; ++i) { + int data = cluster_history[i]; + if (!((data >> 27) & 1)) { + continue; + } + for (int k = 0; k < 2; ++k) { + int slot = k == 0 ? (data & 0xFF) : ((data >> 8) & 0xFF); + if (slot >= 2 && (step_bare[i] & (1 << slot)) && + ((data >> (28 + k)) & 1) != 0) { + emitted |= 1 << slot; + } + } + } + for (int leg = 2; leg < n_part; ++leg) { + if (emitted & (1 << leg)) { + ++merging_jet_count; + } + } + } + return merging_jet_count; +} + +// ClusteringHistory in mlm_clustering.hpp. +constexpr int HISTORY_ALL_DIAGRAMS = 0; +constexpr int HISTORY_DIAGRAM = 1; +constexpr int HISTORY_MADEVENT = 2; + +template +KERNELSPEC void mlm_clustering( + FIn momenta, + FIn random, + IIn start_state, + IIn state_machine, + FIn external_masses, + FIn bw_masses, + FIn bw_widths, + FIn bw_cutoff, + FIn jet_radius, + FIn cm_energy, + IIn jet_scale_scheme, + FIn xqcut, + IIn scale_scheme, + IIn beam_flags, + IIn jet_leg_mask, + IIn parton_line_scheme, + IIn alphas_scheme, + IIn pdf_reweighting, + IIn clustering_measure, + IIn history_mode, + FOut ren_scale, + FOut fact_scale1, + FOut fact_scale2, + FOut outgoing_scales, + IOut diagram_index, + FOut xqcut_weight, + FOut alphas_scales, + FOut alphas_weight, + FOut pdf_scale1, + FOut pdf_scale2, + IOut pdf_rw_flavor, + FOut pdf_rw_x, + FOut pdf_rw_q_num, + FOut pdf_rw_q_den, + FOut pdf_rw_active, + FOut pdf_rw_beam, + bool hadronic +) { + auto walk = [&](int start) { + return mlm_clustering_walk( + momenta, + random, + state_machine, + external_masses, + bw_masses, + bw_widths, + bw_cutoff, + jet_radius, + cm_energy, + jet_scale_scheme, + xqcut, + scale_scheme, + beam_flags, + jet_leg_mask, + parton_line_scheme, + alphas_scheme, + pdf_reweighting, + clustering_measure, + ren_scale, + fact_scale1, + fact_scale2, + outgoing_scales, + diagram_index, + xqcut_weight, + alphas_scales, + alphas_weight, + pdf_scale1, + pdf_scale2, + pdf_rw_flavor, + pdf_rw_x, + pdf_rw_q_num, + pdf_rw_q_den, + pdf_rw_active, + pdf_rw_beam, + start, + hadronic + ); + }; + int start = start_state; + if (history_mode == HISTORY_MADEVENT && start != 0) { + // setclscales clusters over every graph first and keeps that history + // unless its jet count differs from the one stored for the channel, + // which was itself clustered along the channel's graph. Every walk + // writes all outputs, so the one that ran last is the one that stands. + int diagram_jets = walk(start); + if (walk(0) != diagram_jets) { + walk(start); + } + } else { + walk(start); + } } template KERNELSPEC void kernel_mlm_clustering_hadronic( FIn momenta, FIn random, + IIn start_state, IIn state_machine, FIn external_masses, FIn bw_masses, @@ -1358,6 +1498,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( IIn alphas_scheme, IIn pdf_reweighting, IIn clustering_measure, + IIn history_mode, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -1378,6 +1519,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( mlm_clustering( momenta, random, + start_state, state_machine, external_masses, bw_masses, @@ -1394,6 +1536,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( alphas_scheme, pdf_reweighting, clustering_measure, + history_mode, ren_scale, fact_scale1, fact_scale2, @@ -1418,6 +1561,7 @@ template KERNELSPEC void kernel_mlm_clustering_leptonic( FIn momenta, FIn random, + IIn start_state, IIn state_machine, FIn external_masses, FIn bw_masses, @@ -1434,6 +1578,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( IIn alphas_scheme, IIn pdf_reweighting, IIn clustering_measure, + IIn history_mode, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -1454,6 +1599,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( mlm_clustering( momenta, random, + start_state, state_machine, external_masses, bw_masses, @@ -1470,6 +1616,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( alphas_scheme, pdf_reweighting, clustering_measure, + history_mode, ren_scale, fact_scale1, fact_scale2, diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index d112f3a95f..094c438655 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -483,6 +483,57 @@ void set_mask_meta(std::vector& mask_meta, int mask, LineMeta meta) { } } +// Append compiled states to the flat state machine. A clustering state is a +// run of (data, next_offset, trace_data) triples ending at the triple whose data +// has the is_last bit set; a terminal state is a count followed by that many +// diagram indices. Returns the offset each state landed at. +std::vector append_state_layout( + std::vector& machine, const nested_vector2& states +) { + std::vector first_indices; + first_indices.reserve(states.size()); + for (int offset = static_cast(machine.size()); auto& state : states) { + first_indices.push_back(offset); + if (state.size() == 0) { + // a dropped dead end: nothing points at it, so it takes no space + } else if (state.at(0).particle1 == 0 && state.at(0).particle2 == 0) { + offset += 1 + state.size(); + } else { + offset += state_machine_item_size * state.size(); + } + } + for (auto& state : states) { + if (state.size() == 0) { + continue; + } + if (state.at(0).particle1 == 0 && state.at(0).particle2 == 0) { + machine.push_back(state.size()); + for (auto& item : state) { + machine.push_back(item.next_state); + } + } else { + for (auto& item : state) { + machine.push_back( + (item.particle1 << 0) + (item.particle2 << 8) + + (item.mass_index << 16) + (item.massive_in << 24) + + (item.massive_out1 << 25) + (item.massive_out2 << 26) + + (item.is_qcd << 27) + (item.is_jet1 << 28) + (item.is_jet2 << 29) + + ((&item == &state.back()) << 30) + ); + machine.push_back(first_indices.at(item.next_state)); + machine.push_back( + static_cast(item.trace_mode) + (item.is_jet_in << 2) + + (item.is_colored_in << 3) + + ((item.flavor_class_in + 1) << 4) + + (item.is_octet_in << 12) + (item.mother_is_daughter1 << 13) + + (item.mother_is_daughter2 << 14) + (item.all_colorless << 15) + ); + } + } + } + return first_indices; +} + } // namespace MLMClustering::MLMClustering( @@ -502,7 +553,8 @@ MLMClustering::MLMClustering( PartonLineScheme parton_line_scheme, AlphasScheme alphas_scheme, bool pdf_reweighting, - ClusteringMeasure clustering_measure + ClusteringMeasure clustering_measure, + ClusteringHistory clustering_history ) : FunctionGenerator( "MLMClustering", @@ -546,6 +598,7 @@ MLMClustering::MLMClustering( _alphas_scheme(alphas_scheme), _pdf_reweighting(pdf_reweighting), _clustering_measure(clustering_measure), + _clustering_history(clustering_history), _beam_flags(0), _jet_leg_mask(0), _xqcut(xqcut), @@ -605,6 +658,14 @@ MLMClustering::MLMClustering( std::vector mask_meta(1 << n_ext); std::vector particle_masks; std::vector all_diags; + // The lines of each diagram on its own, as (mask, properties): what the + // union above merges away. Only needed for the per-diagram histories. + std::vector>>> + diagram_lines; + auto add_line = [&](std::size_t diag_index, int mask, const LineMeta& meta) { + valid_diags.at(mask).push_back(diag_index); + diagram_lines.back().second.push_back({mask, meta}); + }; // The kernel indexes momenta and external_masses by external leg, while a // topology's masses are indexed by topology slot, so the permutation has to @@ -620,6 +681,7 @@ MLMClustering::MLMClustering( auto& outgoing_masses = topo.outgoing_masses(); for (auto [permutation, diag_index] : zip(permutations, diag_indices)) { all_diags.push_back(diag_index); + diagram_lines.push_back({diag_index, {}}); particle_masks.assign(topo.decays().size(), 0); for (std::size_t i = 2; i < permutation.size(); ++i) { particle_masks.at(topo.outgoing_indices().at(permutation.at(i) - 2)) = 1 @@ -671,12 +733,11 @@ MLMClustering::MLMClustering( (particle_masks.at(decay.child_indices.at(0)) | particle_masks.at(decay.child_indices.at(1))); particle_masks.at(decay.index) = mask; - valid_diags.at(mask).push_back(diag_index); - set_mask_meta( - mask_meta, - mask, - {.mass = decay.mass, .width = decay.width, .pdg_id = decay.pdg_id} - ); + LineMeta meta{ + .mass = decay.mass, .width = decay.width, .pdg_id = decay.pdg_id + }; + add_line(diag_index, mask, {meta.mass, meta.width, meta.pdg_id, true}); + set_mask_meta(mask_meta, mask, meta); } if (!has_t_channel) { @@ -702,31 +763,31 @@ MLMClustering::MLMClustering( std::size_t k = 0; for (int mask = 1; std::size_t index : t_children) { mask |= particle_masks.at(index); - valid_diags.at(mask).push_back(diag_index); if (k < t_count) { // width stays 0: a spacelike propagator is never resonant - set_mask_meta( - mask_meta, - mask, - {.mass = t_masses.at(k), - .width = 0., - .pdg_id = t_pdg_ids.at(k)} - ); + LineMeta meta{ + .mass = t_masses.at(k), .width = 0., .pdg_id = t_pdg_ids.at(k) + }; + add_line(diag_index, mask, {meta.mass, 0., meta.pdg_id, true}); + set_mask_meta(mask_meta, mask, meta); + } else { + add_line(diag_index, mask, {}); } ++k; } k = 0; for (int mask = 2; std::size_t index : std::views::reverse(t_children)) { mask |= particle_masks.at(index); - valid_diags.at(mask).push_back(diag_index); if (k < t_count) { - set_mask_meta( - mask_meta, - mask, - {.mass = t_masses.at(t_count - 1 - k), - .width = 0., - .pdg_id = t_pdg_ids.at(t_count - 1 - k)} - ); + LineMeta meta{ + .mass = t_masses.at(t_count - 1 - k), + .width = 0., + .pdg_id = t_pdg_ids.at(t_count - 1 - k) + }; + add_line(diag_index, mask, {meta.mass, 0., meta.pdg_id, true}); + set_mask_meta(mask_meta, mask, meta); + } else { + add_line(diag_index, mask, {}); } ++k; } @@ -774,61 +835,130 @@ MLMClustering::MLMClustering( ++i; } - // Layout: a clustering state is a run of (data, next_offset, trace_data) - // triples ending at the triple whose data has the is_last bit set; a - // terminal state is a count followed by that many diagram indices. - std::vector first_indices; - first_indices.reserve(states.size()); - for (int offset = 0; auto& state : states) { - first_indices.push_back(offset); - if (state.size() == 0) { - // a dropped dead end: nothing points at it, so it takes no space - } else if (state.at(0).particle1 == 0 && state.at(0).particle2 == 0) { - offset += 1 + state.size(); - } else { - offset += state_machine_item_size * state.size(); - } + append_state_layout(_cluster_state_machine, states); + + if (clustering_history == ClusteringHistory::all_diagrams) { + return; } - for (auto& state : states) { - if (state.size() == 0) { + + // One state machine per diagram, appended after the one over every + // diagram, so that offset 0 can keep meaning "no diagram of its own". Each + // is compiled exactly as the union is, only from that diagram's lines: + // where the union takes a line's flavour from whichever diagram came first + // - the gluon of gluon fusion on a t-channel line that a VBF diagram puts + // a W on - these see the diagram's own, and with it its own QCD vertices, + // parton lines and merging jets. + std::size_t diagram_table_size = 0; + for (auto& [diag_index, lines] : diagram_lines) { + diagram_table_size = std::max(diagram_table_size, diag_index + 1); + } + _diagram_start_states.assign(diagram_table_size, 0); + nested_vector2 diagram_valid(1 << n_ext); + std::vector diagram_meta(1 << n_ext); + for (std::size_t leg = 0; leg < n_ext; ++leg) { + diagram_meta.at(1 << leg) = mask_meta.at(1 << leg); + } + CompileContext diagram_ctx{ + .valid_diags = diagram_valid, + .mask_meta = diagram_meta, + .bw_masses = _bw_masses, + .bw_widths = _bw_widths, + .bw_indices = bw_indices, + .pdg_color_types = pdg_color_types, + .have_pdg_ids = have_pdg_ids, + .max_jet_flavor = max_jet_flavor, + .beam_pdg = {have_pdg_ids ? external_pdg_ids.at(0) : 21, + have_pdg_ids ? external_pdg_ids.at(1) : 21}, + .absolute_pdgs = _pdf_absolute_pdgs, + }; + nested_vector2 diagram_states; + std::map diagram_state_map; + std::set diagram_dead_states; + std::vector> diagram_roots; + std::set compiled; + for (auto& [diag_index, lines] : diagram_lines) { + if (!compiled.insert(diag_index).second) { continue; } - if (state.at(0).particle1 == 0 && state.at(0).particle2 == 0) { - _cluster_state_machine.push_back(state.size()); - for (auto& item : state) { - _cluster_state_machine.push_back(item.next_state); - } - } else { - for (auto& item : state) { - _cluster_state_machine.push_back( - (item.particle1 << 0) + (item.particle2 << 8) + - (item.mass_index << 16) + (item.massive_in << 24) + - (item.massive_out1 << 25) + (item.massive_out2 << 26) + - (item.is_qcd << 27) + (item.is_jet1 << 28) + (item.is_jet2 << 29) + - ((&item == &state.back()) << 30) - ); - _cluster_state_machine.push_back(first_indices.at(item.next_state)); - _cluster_state_machine.push_back( - static_cast(item.trace_mode) + (item.is_jet_in << 2) + - (item.is_colored_in << 3) + - ((item.flavor_class_in + 1) << 4) + - (item.is_octet_in << 12) + (item.mother_is_daughter1 << 13) + - (item.mother_is_daughter2 << 14) + (item.all_colorless << 15) - ); - } + int diag = static_cast(diag_index); + for (auto& [mask, meta] : lines) { + diagram_valid.at(mask) = {diag}; + diagram_meta.at(mask) = meta; + } + // The state keys carry the diagram, so no state is shared between two + // diagrams: their lines differ even where their masks agree. + int root = static_cast(diagram_states.size()); + diagram_states.push_back({}); + diagram_state_map[{masks, {diag}}] = root; + find_clusterings( + diagram_ctx, + masks, + {diag}, + diagram_states, + diagram_state_map, + diagram_dead_states, + root + ); + if (diagram_states.at(root).size() > 0) { + diagram_roots.push_back({diag_index, root}); + } + for (auto& [mask, meta] : lines) { + diagram_valid.at(mask).clear(); + diagram_meta.at(mask) = {}; } } + auto diagram_offsets = append_state_layout(_cluster_state_machine, diagram_states); + for (auto [diag_index, root] : diagram_roots) { + _diagram_start_states.at(diag_index) = diagram_offsets.at(root); + } } NamedVector MLMClustering::build_function_impl( FunctionBuilder& fb, const NamedVector& args +) const { + auto start_state = + fb.full({static_cast(0), fb.batch_size({args.at(0)})}); + return build_kernel(fb, args.at(0), start_state, 0); +} + +NamedVector MLMClustering::build_along_diagram( + FunctionBuilder& fb, Value momenta, Value diagram +) const { + if (_clustering_history == ClusteringHistory::all_diagrams) { + throw std::logic_error( + "a clustering along one diagram needs a clustering_history other " + "than all_diagrams" + ); + } + return build_from_start_state( + fb, momenta, fb.gather_int(diagram, _diagram_start_states) + ); +} + +NamedVector MLMClustering::build_from_start_state( + FunctionBuilder& fb, Value momenta, Value start_state +) const { + if (_clustering_history == ClusteringHistory::all_diagrams) { + throw std::logic_error( + "a clustering along one diagram needs a clustering_history other " + "than all_diagrams" + ); + } + return build_kernel( + fb, momenta, start_state, static_cast(_clustering_history) + ); +} + +NamedVector MLMClustering::build_kernel( + FunctionBuilder& fb, Value momenta, Value start_state, me_int_t history_mode ) const { std::array mlm_out; - Value random = fb.squeeze(fb.random(fb.batch_size(args.values()), 1)); + Value random = fb.squeeze(fb.random(fb.batch_size({momenta}), 1)); if (_hadronic) { mlm_out = fb.mlm_clustering_hadronic( - args.at(0), + momenta, random, + start_state, _cluster_state_machine, _external_masses, _bw_masses, @@ -844,12 +974,14 @@ NamedVector MLMClustering::build_function_impl( static_cast(_parton_line_scheme), static_cast(_alphas_scheme), static_cast(_pdf_reweighting), - static_cast(_clustering_measure) + static_cast(_clustering_measure), + history_mode ); } else { mlm_out = fb.mlm_clustering_leptonic( - args.at(0), + momenta, random, + start_state, _cluster_state_machine, _external_masses, _bw_masses, @@ -865,8 +997,27 @@ NamedVector MLMClustering::build_function_impl( static_cast(_parton_line_scheme), static_cast(_alphas_scheme), static_cast(_pdf_reweighting), - static_cast(_clustering_measure) + static_cast(_clustering_measure), + history_mode ); } return {return_types().keys(), {mlm_out.begin(), mlm_out.end()}}; } + +MLMClusteringAlongDiagram::MLMClusteringAlongDiagram(const MLMClustering& clustering) : + FunctionGenerator( + "MLMClusteringAlongDiagram", + [&] { + auto types = clustering.arg_types(); + types.push_back("diagram", batch_int); + return types; + }(), + clustering.return_types() + ), + _clustering(clustering) {} + +NamedVector MLMClusteringAlongDiagram::build_function_impl( + FunctionBuilder& fb, const NamedVector& args +) const { + return _clustering.build_along_diagram(fb, args.at(0), args.at(1)); +} diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index 56ef771fca..44c3bdf83d 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -1028,6 +1028,15 @@ PYBIND11_MODULE(_madspace_py, m) { {"madevent", ClusteringMeasure::madevent}, } ); + add_enum( + mlm, + "ClusteringHistory", + { + {"all_diagrams", ClusteringHistory::all_diagrams}, + {"diagram", ClusteringHistory::diagram}, + {"madevent", ClusteringHistory::madevent}, + } + ); add_enum( mlm, "PartonLineScheme", @@ -1055,7 +1064,8 @@ PYBIND11_MODULE(_madspace_py, m) { PartonLineScheme, AlphasScheme, bool, - ClusteringMeasure>(), + ClusteringMeasure, + ClusteringHistory>(), py::arg("topologies"), py::arg("permutations"), py::arg("diagram_indices"), @@ -1072,7 +1082,8 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("parton_line_scheme") = PartonLineScheme::goodjet, py::arg("alphas_scheme") = AlphasScheme::per_vertex, py::arg("pdf_reweighting") = true, - py::arg("clustering_measure") = ClusteringMeasure::fxfx + py::arg("clustering_measure") = ClusteringMeasure::fxfx, + py::arg("clustering_history") = ClusteringHistory::all_diagrams ) .def_property_readonly( "pdf_absolute_pdgs", &MLMClustering::pdf_absolute_pdgs @@ -1080,6 +1091,12 @@ PYBIND11_MODULE(_madspace_py, m) { .def_property_readonly( "clustering_measure", &MLMClustering::clustering_measure ) + .def_property_readonly( + "clustering_history", &MLMClustering::clustering_history + ) + .def_property_readonly( + "diagram_start_states", &MLMClustering::diagram_start_states + ) .def_property_readonly( "cluster_state_machine", &MLMClustering::cluster_state_machine ) @@ -1087,6 +1104,11 @@ PYBIND11_MODULE(_madspace_py, m) { .def_property_readonly("bw_masses", &MLMClustering::bw_masses) .def_property_readonly("bw_widths", &MLMClustering::bw_widths); + py::classh( + m, "MLMClusteringAlongDiagram" + ) + .def(py::init(), py::arg("clustering")); + py::classh(m, "VegasGridOptimizer") .def( "add_data", diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 6729e8d3a8..66905417b5 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -1959,3 +1959,130 @@ def test_the_fxfx_measure_lets_the_jet_pair_with_the_w(): momenta, ) assert fxfx < 0.95, "the FxFx measure should pair a visible fraction with the W" + + +# -------------------------------------------------------------------------- +# clustering_history: over every diagram, or along one diagram +# -------------------------------------------------------------------------- + +HISTORY = ms.MLMClustering.ClusteringHistory + + +def history_clustering(diagrams, pdg_ids, history, **kwargs): + kwargs.setdefault("scale_scheme", ms.MLMClustering.ScaleScheme.madevent) + return make_clustering( + diagrams, external_pdg_ids=pdg_ids, clustering_history=history, **kwargs + ) + + +def along(clustering, momenta, diagram): + diagram = np.broadcast_to(np.asarray(diagram, dtype=np.int32), len(momenta)) + return [ + np.asarray(v) + for v in ms.MLMClusteringAlongDiagram(clustering)( + momenta, np.ascontiguousarray(diagram) + ) + ] + + +def test_all_diagrams_is_the_default_history(process): + _, diagrams, pdg_ids = process + clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + assert clustering.clustering_history == HISTORY.all_diagrams + assert len(clustering.diagram_start_states) == 0 + + +def test_every_diagram_gets_a_state_machine_after_the_union_one(process): + _, diagrams, pdg_ids = process + union = history_clustering(diagrams, pdg_ids, HISTORY.all_diagrams) + per_diagram = history_clustering(diagrams, pdg_ids, HISTORY.diagram) + machine = per_diagram.cluster_state_machine + union_size = len(union.cluster_state_machine) + assert machine[:union_size] == union.cluster_state_machine + starts = np.asarray(per_diagram.diagram_start_states) + assert len(starts) == sum(len(d["permutations"]) for d in diagrams) + assert np.all(starts >= union_size) + assert np.all(starts < len(machine)) + assert len(set(starts)) == len(starts) + + +def test_the_history_setting_leaves_the_union_walk_alone(process): + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + reference = run_all(history_clustering(diagrams, pdg_ids, HISTORY.all_diagrams), momenta) + for history in (HISTORY.diagram, HISTORY.madevent): + other = run_all(history_clustering(diagrams, pdg_ids, history), momenta) + for a, b in zip(reference, other): + np.testing.assert_array_equal(a, b) + + +def test_the_union_walk_cannot_be_walked_along_a_diagram(process): + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams, batch_size=4) + clustering = history_clustering(diagrams, pdg_ids, HISTORY.all_diagrams) + with pytest.raises(Exception): + along(clustering, momenta, 0) + + +def test_a_history_along_a_diagram_reports_that_diagram(process): + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + clustering = history_clustering(diagrams, pdg_ids, HISTORY.diagram, xqcut=20.0) + count = len(clustering.diagram_start_states) + picked = np.random.default_rng(3).integers(0, count, len(momenta)) + out = along(clustering, momenta, picked) + np.testing.assert_array_equal(out[4], picked) + for value in out: + assert np.all(np.isfinite(value)) + + +def test_a_single_diagram_clusters_along_itself_as_its_union_does(process): + """A process made of one diagram has nothing to merge, so the history over + every diagram and the one along that diagram are the same walk.""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + single = [dict(diagrams[0], permutations=diagrams[0]["permutations"][:1])] + union = run_all(history_clustering(single, pdg_ids, HISTORY.all_diagrams), momenta) + full = history_clustering(diagrams, pdg_ids, HISTORY.diagram) + out = along(full, momenta, 0) + for a, b in zip(union, out): + np.testing.assert_array_equal(a, b) + + +def test_the_madevent_history_is_either_the_union_or_the_diagram(process): + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + count = sum(len(d["permutations"]) for d in diagrams) + picked = np.random.default_rng(4).integers(0, count, len(momenta)) + union = run_all(history_clustering(diagrams, pdg_ids, HISTORY.madevent), momenta) + diagram = along(history_clustering(diagrams, pdg_ids, HISTORY.diagram), momenta, picked) + madevent = along(history_clustering(diagrams, pdg_ids, HISTORY.madevent), momenta, picked) + is_union = np.all([np.isclose(a, b).reshape(len(momenta), -1).all(axis=1) + for a, b in zip(union, madevent)], axis=0) + is_diagram = np.all([np.isclose(a, b).reshape(len(momenta), -1).all(axis=1) + for a, b in zip(diagram[:6], madevent[:6])], axis=0) + assert np.all(is_union | is_diagram) + # both outcomes occur for a process with this many diagrams + assert 0 < np.mean(is_union) < 1 + + +def test_a_diagram_history_uses_that_diagrams_own_line_flavours(): + """g u > e+ ve d. Along the t-channel diagram the jet goes into beam 0 and + its clustering is a QCD vertex that alpha_s is reweighted at. Along the + s-channel one (u* > d W) the only QCD vertex left is g u > u*, which the + union can also pick; the two diagrams must never hand back each other's + history.""" + diagrams = w_plus_jet_two_diagrams() + momenta = sample_momenta(diagrams, batch_size=2000) + clustering = w_plus_jet_two_diagram_clustering( + clustering_history=HISTORY.diagram, + clustering_measure=ms.MLMClustering.ClusteringMeasure.madevent, + ) + s_channel = along(clustering, momenta, 0) + t_channel = along(clustering, momenta, 1) + jet = momenta[:, 4, :] + jet_pt = np.sqrt(jet[:, 1] ** 2 + jet[:, 2] ** 2) + # the t-channel history always takes the jet into a beam at its pt + assert np.mean(np.abs(t_channel[3][:, 2] / jet_pt - 1.0) < 1e-5) == 1.0 + # the s-channel one never clusters the jet with beam 0 before the W + assert np.mean(np.abs(s_channel[3][:, 2] / jet_pt - 1.0) < 1e-5) < 0.5 From d93c0efd6e2b8dcde5ab9973df9ffeb5f15c6d93 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Tue, 15 Sep 2026 22:20:49 +0200 Subject: [PATCH 46/81] Pick the MLM clustering history per event from the diagram weights With a clustering_history other than all_diagrams, the union history only serves the pdf prior for the flavour sampling. The matrix element is evaluated at its scales for the normalised diagram weights. One diagram is picked from them, the event is clustered along it, and the matrix element is evaluated again at that history's scales, with its pdfs, merging cut and alpha_s and pdf reweighting. The second evaluation keeps mixed coupling orders exact, where a rescaling by alpha_s ratios would not be. The channel weights come from the first evaluation. The xqcut, alpha_s and pdf reweighting factors move into Integrand::mlm_weights so both paths share them. Co-Authored-By: Claude Opus 5 --- .../include/madspace/phasespace/integrand.hpp | 22 + .../include/madspace/phasespace/scale.hpp | 15 + madspace/src/phasespace/integrand.cpp | 418 ++++++++++++------ madspace/src/phasespace/scale.cpp | 8 + 4 files changed, 321 insertions(+), 142 deletions(-) diff --git a/madspace/include/madspace/phasespace/integrand.hpp b/madspace/include/madspace/phasespace/integrand.hpp index 0179739b37..d9be537d96 100644 --- a/madspace/include/madspace/phasespace/integrand.hpp +++ b/madspace/include/madspace/phasespace/integrand.hpp @@ -118,6 +118,22 @@ class Integrand : public FunctionGenerator { build_channel_part(FunctionBuilder& fb, const NamedVector& args) const; NamedVector build_common_part(FunctionBuilder& fb, const NamedVector& channel_out) const; + // The beam densities at the scales an MLM or dynamical scale choice + // produced, for the sampled flavour. + std::array evaluate_pdfs( + FunctionBuilder& fb, + const NamedVector& scales, + const std::array& x, + Value flavor_id + ) const; + // The factors an MLM clustering puts on the event weight: the merging cut, + // the alpha_s and the pdf reweighting, each with its veto. + ValueVec mlm_weights( + FunctionBuilder& fb, + const NamedVector& scales, + const std::array& x, + Value flavor_id + ) const; PhaseSpaceMapping _mapping; std::vector _diff_xs; @@ -138,6 +154,12 @@ class Integrand : public FunctionGenerator { std::vector _pdf_rw_class_offsets; std::optional _running_coupling; std::optional _energy_scale; + // One row with a 1 for every matrix-element diagram the MLM clustering has + // a history of its own for, used to restrict the per-event diagram pick. + nested_vector2 _mlm_diagram_mask; + // MLMClustering::diagram_start_states padded to the matrix element's + // diagram count, so that any diagram the pick can return has an entry. + std::vector _mlm_start_states; std::optional _prop_chan_weights; std::optional _subchan_weights; std::optional _chan_weight_net; diff --git a/madspace/include/madspace/phasespace/scale.hpp b/madspace/include/madspace/phasespace/scale.hpp index f4b2626bec..011744b49b 100644 --- a/madspace/include/madspace/phasespace/scale.hpp +++ b/madspace/include/madspace/phasespace/scale.hpp @@ -75,6 +75,21 @@ class EnergyScale : public FunctionGenerator { const std::vector& mlm_pdf_absolute_pdgs() const { return _clustering.value().pdf_absolute_pdgs(); } + // Whether the MLM history is picked per event from the diagram weights, + // which moves the clustering after a first matrix element evaluation. + bool mlm_history_per_diagram() const { + return _clustering && + _clustering->clustering_history() != ClusteringHistory::all_diagrams; + } + const std::vector& mlm_diagram_start_states() const { + return _clustering.value().diagram_start_states(); + } + // The scales of the MLM clustering walked from one diagram's start state + // (see MLMClustering::diagram_start_states), with the same scale range + // applied as the function itself applies. + NamedVector build_mlm_from_start_state( + FunctionBuilder& fb, Value momenta, Value start_state + ) const; // The band the pdf grid covers, for whoever has to keep a scale inside it. double min_scale() const { return _min_scale; } double max_scale() const { return _max_scale; } diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index a0b10141f3..9775c9b6de 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -262,6 +262,26 @@ Integrand::Integrand( } } + if (energy_scale && energy_scale->mlm_history_per_diagram()) { + // The diagram is picked from the matrix element's own diagram weights, + // and those only line up with the clustering's diagram numbering for + // a single matrix element. + if (diff_xs.size() != 1) { + throw std::invalid_argument( + "an MLM clustering history per diagram needs a single matrix element" + ); + } + auto& starts = energy_scale->mlm_diagram_start_states(); + std::size_t diagram_count = diff_xs.at(0).matrix_element().diagram_count(); + std::vector mask(diagram_count, 0.); + _mlm_start_states.assign(diagram_count, 0); + for (std::size_t i = 0; i < std::min(diagram_count, starts.size()); ++i) { + mask.at(i) = starts.at(i) != 0 ? 1. : 0.; + _mlm_start_states.at(i) = starts.at(i); + } + _mlm_diagram_mask.push_back(mask); + } + if (active_flavors.size() > 0) { if (active_flavors.size() != mapping.channel_count()) { throw std::invalid_argument( @@ -624,128 +644,17 @@ NamedVector Integrand::build_channel_part( weights_after_cuts.push_back(mirror_det); } - if (_energy_scale && _energy_scale->is_mlm()) { - // The merging cut comes out of the clustering, which only runs for - // events that already passed the phase-space cuts, so it enters as a - // factor on the weight rather than as one of the cuts themselves. An - // event below xqcut ends up with weight zero and is never unweighted. - weights_after_cuts.push_back(scales.at("xqcut_weight")); - - // alpha_s reweighting, the CKKW-style factor madevent applies in - // Template/LO/SubProcesses/reweight.f: every clustering vertex that - // produced a parton is evaluated at its own scale rather than at the - // event's, so the weight carries prod_i alphas(pt_i) instead of - // alphas(mu_R)^n. Without it a merged sample is short by one factor - // per emission, compounding with multiplicity. - // - // The kernel hands back mu_R for any vertex it does not reweight, so - // that vertex's ratio is one and no mask is needed here. - if (_energy_scale->mlm_alphas_reweighting() && _running_coupling) { - auto vertex_scales = scales.at("alphas_scales"); - std::size_t vertex_count = vertex_scales.type.shape.at(0); - if (vertex_count > 0) { - auto reference = _running_coupling.value() - .build_function(fb, {scales.at("ren_scale")}) - .at(0); - Value factor; - for (std::size_t i = 0; i < vertex_count; ++i) { - auto [rest, one_scale] = fb.pop(vertex_scales); - vertex_scales = rest; - auto alpha = - _running_coupling.value().build_function(fb, {one_scale}).at(0); - auto ratio = fb.div(alpha, reference); - factor = factor ? fb.mul(factor, ratio) : ratio; - } - weights_after_cuts.push_back(factor); - weights_after_cuts.push_back(scales.at("alphas_weight")); - } - } - - // pdf reweighting: the beam density, evaluated above at the bottom of - // the clustering ladder, walked back up one clustering at a time. Each - // slot the kernel marked active contributes f(x, Q_i) / f(x, Q_i-1) - // for the flavour its beam line carried at that point. - // - // The flavour is only knowable here, after the sampling: the kernel - // hands out a class - the gluon, one of the two beams' own flavours, or - // a flavour the diagram pinned down - and the table turns that plus the - // sampled option into an index into the reweighting density. - if (_pdf_rw && _pid_options.size() > 0) { - auto flavor_classes = scales.at("pdf_rw_flavor"); - std::size_t slot_count = flavor_classes.type.shape.at(0); - double low = _energy_scale->min_scale(); - double high = _energy_scale->max_scale() > 0. - ? _energy_scale->max_scale() - : 1e30; - auto ones = fb.full({1., batch_size_acc}); - Value factor, veto; - for (std::size_t i = 0; i < slot_count; ++i) { - std::vector column{static_cast(i)}; - auto pick = [&](const char* name) { - return fb.squeeze(fb.select(scales.at(name), column)); - }; - auto active = pick("pdf_rw_active"); - auto beam = pick("pdf_rw_beam"); - // x of the beam line: the beam's own momentum fraction times - // everything the clustering z has taken off it since. - auto x_beam = fb.add( - x_acc.at(0), - fb.mul(beam, fb.sub(x_acc.at(1), x_acc.at(0))) - ); - auto x = fb.mul(x_beam, pick("pdf_rw_x")); - auto scale_of = [&](const char* name) { - auto q = pick(name); - if (low > 0.) { - q = fb.max(q, fb.full({low, batch_size_acc})); - } - return fb.min(q, fb.full({high, batch_size_acc})); - }; - auto index = fb.gather_int( - fb.add_int( - fb.gather_int( - fb.squeeze(fb.select_int(flavor_classes, column)), - _pdf_rw_class_offsets - ), - flavor_id - ), - _pdf_rw_table - ); - auto density = [&](const char* name) { - return _pdf_rw.value() - .build_function(fb, {x, scale_of(name), index}) - .at(0); - }; - auto numerator = density("pdf_rw_q_num"); - auto denominator = density("pdf_rw_q_den"); - // madevent drops the event outright when the density it is - // dividing by falls under 1e-10, where the grid is no longer - // saying anything. Same threshold, as a ramp rather than a - // branch, and the floor under the division keeps the term - // finite so that a vetoed slot multiplies to zero and not to - // a NaN. - auto floor = fb.full({1e-10, batch_size_acc}); - auto usable = fb.min( - fb.max( - fb.mul(denominator, fb.full({1e10, batch_size_acc})), - fb.full({0., batch_size_acc}) - ), - ones - ); - auto ratio = fb.div(numerator, fb.max(denominator, floor)); - // An inert slot contributes exactly one, whatever its density - // came out as. - auto term = fb.add(ones, fb.mul(active, fb.sub(ratio, ones))); - auto pass = fb.add(ones, fb.mul(active, fb.sub(usable, ones))); - factor = factor ? fb.mul(factor, term) : term; - veto = veto ? fb.mul(veto, pass) : pass; - } - if (factor) { - weights_after_cuts.push_back(factor); - weights_after_cuts.push_back(veto); - } + // With a clustering history per diagram these scales only serve the pdf + // prior above: the history, and every weight read off it, is settled after + // the matrix element has given the diagram weights, in the common part. + bool mlm_history_per_diagram = + _energy_scale && _energy_scale->mlm_history_per_diagram(); + if (_energy_scale && _energy_scale->is_mlm() && !mlm_history_per_diagram) { + for (auto& weight : mlm_weights(fb, scales, x_acc, flavor_id)) { + weights_after_cuts.push_back(weight); } } - if (_energy_scale && _energy_scale->has_scale_range()) { + if (_energy_scale && _energy_scale->has_scale_range() && !mlm_history_per_diagram) { // Same for the floor on the scales themselves, which applies to every // dynamical scale choice rather than only to the merging one. weights_after_cuts.push_back(scales.at("scale_weight")); @@ -815,6 +724,158 @@ NamedVector Integrand::build_channel_part( return out; } +ValueVec Integrand::mlm_weights( + FunctionBuilder& fb, + const NamedVector& scales, + const std::array& x, + Value flavor_id +) const { + ValueVec weights; + auto batch_size_acc = fb.batch_size({flavor_id}); + // The merging cut comes out of the clustering, which only runs for + // events that already passed the phase-space cuts, so it enters as a + // factor on the weight rather than as one of the cuts themselves. An + // event below xqcut ends up with weight zero and is never unweighted. + weights.push_back(scales.at("xqcut_weight")); + + // alpha_s reweighting, the CKKW-style factor madevent applies in + // Template/LO/SubProcesses/reweight.f: every clustering vertex that + // produced a parton is evaluated at its own scale rather than at the + // event's, so the weight carries prod_i alphas(pt_i) instead of + // alphas(mu_R)^n. Without it a merged sample is short by one factor + // per emission, compounding with multiplicity. + // + // The kernel hands back mu_R for any vertex it does not reweight, so + // that vertex's ratio is one and no mask is needed here. + if (_energy_scale->mlm_alphas_reweighting() && _running_coupling) { + auto vertex_scales = scales.at("alphas_scales"); + std::size_t vertex_count = vertex_scales.type.shape.at(0); + if (vertex_count > 0) { + auto reference = _running_coupling.value() + .build_function(fb, {scales.at("ren_scale")}) + .at(0); + Value factor; + for (std::size_t i = 0; i < vertex_count; ++i) { + auto [rest, one_scale] = fb.pop(vertex_scales); + vertex_scales = rest; + auto alpha = + _running_coupling.value().build_function(fb, {one_scale}).at(0); + auto ratio = fb.div(alpha, reference); + factor = factor ? fb.mul(factor, ratio) : ratio; + } + weights.push_back(factor); + weights.push_back(scales.at("alphas_weight")); + } + } + + // pdf reweighting: the beam density, evaluated above at the bottom of + // the clustering ladder, walked back up one clustering at a time. Each + // slot the kernel marked active contributes f(x, Q_i) / f(x, Q_i-1) + // for the flavour its beam line carried at that point. + // + // The flavour is only knowable here, after the sampling: the kernel + // hands out a class - the gluon, one of the two beams' own flavours, or + // a flavour the diagram pinned down - and the table turns that plus the + // sampled option into an index into the reweighting density. + if (_pdf_rw && _pid_options.size() > 0) { + auto flavor_classes = scales.at("pdf_rw_flavor"); + std::size_t slot_count = flavor_classes.type.shape.at(0); + double low = _energy_scale->min_scale(); + double high = _energy_scale->max_scale() > 0. + ? _energy_scale->max_scale() + : 1e30; + auto ones = fb.full({1., batch_size_acc}); + Value factor, veto; + for (std::size_t i = 0; i < slot_count; ++i) { + std::vector column{static_cast(i)}; + auto pick = [&](const char* name) { + return fb.squeeze(fb.select(scales.at(name), column)); + }; + auto active = pick("pdf_rw_active"); + auto beam = pick("pdf_rw_beam"); + // x of the beam line: the beam's own momentum fraction times + // everything the clustering z has taken off it since. + auto x_beam = fb.add( + x.at(0), + fb.mul(beam, fb.sub(x.at(1), x.at(0))) + ); + auto x = fb.mul(x_beam, pick("pdf_rw_x")); + auto scale_of = [&](const char* name) { + auto q = pick(name); + if (low > 0.) { + q = fb.max(q, fb.full({low, batch_size_acc})); + } + return fb.min(q, fb.full({high, batch_size_acc})); + }; + auto index = fb.gather_int( + fb.add_int( + fb.gather_int( + fb.squeeze(fb.select_int(flavor_classes, column)), + _pdf_rw_class_offsets + ), + flavor_id + ), + _pdf_rw_table + ); + auto density = [&](const char* name) { + return _pdf_rw.value() + .build_function(fb, {x, scale_of(name), index}) + .at(0); + }; + auto numerator = density("pdf_rw_q_num"); + auto denominator = density("pdf_rw_q_den"); + // madevent drops the event outright when the density it is + // dividing by falls under 1e-10, where the grid is no longer + // saying anything. Same threshold, as a ramp rather than a + // branch, and the floor under the division keeps the term + // finite so that a vetoed slot multiplies to zero and not to + // a NaN. + auto floor = fb.full({1e-10, batch_size_acc}); + auto usable = fb.min( + fb.max( + fb.mul(denominator, fb.full({1e10, batch_size_acc})), + fb.full({0., batch_size_acc}) + ), + ones + ); + auto ratio = fb.div(numerator, fb.max(denominator, floor)); + // An inert slot contributes exactly one, whatever its density + // came out as. + auto term = fb.add(ones, fb.mul(active, fb.sub(ratio, ones))); + auto pass = fb.add(ones, fb.mul(active, fb.sub(usable, ones))); + factor = factor ? fb.mul(factor, term) : term; + veto = veto ? fb.mul(veto, pass) : pass; + } + if (factor) { + weights.push_back(factor); + weights.push_back(veto); + } + } + return weights; +} + +std::array Integrand::evaluate_pdfs( + FunctionBuilder& fb, + const NamedVector& scales, + const std::array& x, + Value flavor_id +) const { + std::array pdfs; + for (std::size_t i = 0; i < 2; ++i) { + if (!_diff_xs.at(0).has_pdf(i)) { + continue; + } + auto& pdf_scale = _energy_scale->mlm_pdf_reweighting() + ? scales.at(std::format("pdf_scale{}", i + 1)) + : scales.at(std::format("fact_scale{}", i + 1)); + auto pdf = _pdfs.at(i).value().build_function(fb, {x.at(i), pdf_scale}).at(0); + pdfs.at(i) = _pid_options.size() > 1 + ? fb.gather(fb.gather_int(flavor_id, _pdf_indices.at(i)), pdf) + : fb.squeeze(pdf); + } + return pdfs; +} + NamedVector Integrand::build_common_part( FunctionBuilder& fb, const NamedVector& args ) const { @@ -871,24 +932,48 @@ NamedVector Integrand::build_common_part( Value alpha_qcd_acc = _running_coupling.value().build_function(fb, {args.at("ren_scale")}).at(0); - // Evaluate differential cross section - ValueVec xs_args{ - momenta_acc, - _flavor_remap.size() > 0 ? fb.gather_int(flavor_id, _flavor_remap) : flavor_id, - }; - if (_energy_scale && _energy_scale->is_mlm()) { - xs_args.push_back(args.at("scale_diagram_index_acc")); - } - xs_args.push_back(x1_acc); - xs_args.push_back(x2_acc); - xs_args.push_back(flavor_id); - if (_diff_xs.at(0).has_pdf(0)) { - xs_args.push_back(args.at("pdf1")); + // The scales and densities the event is evaluated with. They come from the + // channel part, unless the MLM history is picked per diagram further down. + Value ren_scale_acc = args.at("ren_scale"); + std::array fact_scales_acc, pdfs_acc; + for (std::size_t i = 0; i < 2; ++i) { + if (_diff_xs.at(0).has_pdf(i)) { + fact_scales_acc.at(i) = args.at(std::format("fact_scale{}", i + 1)); + pdfs_acc.at(i) = args.at(std::format("pdf{}", i + 1)); + } } - if (_diff_xs.at(0).has_pdf(1)) { - xs_args.push_back(args.at("pdf2")); + Value cluster_scales_acc; + if (_energy_scale && _energy_scale->is_mlm()) { + cluster_scales_acc = args.at("cluster_scales_acc"); } - xs_args.push_back(alpha_qcd_acc); + + // Evaluate differential cross section + auto make_xs_args = [&](Value diagram, std::array& pdfs, Value alpha) { + ValueVec xs_args{ + momenta_acc, + _flavor_remap.size() > 0 ? fb.gather_int(flavor_id, _flavor_remap) + : flavor_id, + }; + if (_energy_scale && _energy_scale->is_mlm()) { + xs_args.push_back(diagram); + } + xs_args.push_back(x1_acc); + xs_args.push_back(x2_acc); + xs_args.push_back(flavor_id); + for (std::size_t i = 0; i < 2; ++i) { + if (_diff_xs.at(0).has_pdf(i)) { + xs_args.push_back(pdfs.at(i)); + } + } + xs_args.push_back(alpha); + return xs_args; + }; + ValueVec xs_args = make_xs_args( + _energy_scale && _energy_scale->is_mlm() ? args.at("scale_diagram_index_acc") + : Value(), + pdfs_acc, + alpha_qcd_acc + ); ValueVec dxs_vec; Value ps_flavor_id; Value subproc_id; @@ -950,11 +1035,60 @@ NamedVector Integrand::build_common_part( chan_weights_acc = fb.batch_merge_by_index(split_channel_weights); } } + + // A clustering history per diagram. The evaluation above, at the scales of + // the history over every diagram, has supplied the diagram weights + // |A_i|^2 / sum_j |A_j|^2 (and the channel weights, which stay as they + // are). One diagram is picked from them - a choice between histories, not + // an importance sample, so it puts no factor on the weight - the event is + // clustered along it, and the matrix element is evaluated again at the + // scales that history gives. Evaluating it again rather than rescaling by + // alpha_s ratios keeps this exact when coupling orders mix, as they do for + // gluon fusion and VBF in one process. + ValueVec mlm_history_weights; + if (_energy_scale && _energy_scale->mlm_history_per_diagram()) { + auto batch_size_acc = fb.batch_size({momenta_acc}); + auto diagram_mask = fb.gather_vector( + fb.full({static_cast(0), batch_size_acc}), _mlm_diagram_mask + ); + auto [diagram, diagram_det] = fb.sample_discrete_probs( + fb.squeeze(fb.random(batch_size_acc, 1)), + fb.mul(dxs_vec.at(1), diagram_mask) + ); + auto scales = _energy_scale->build_mlm_from_start_state( + fb, momenta_acc, fb.gather_int(diagram, _mlm_start_states) + ); + std::array x_acc{x1_acc, x2_acc}; + mlm_history_weights = mlm_weights(fb, scales, x_acc, flavor_id); + if (_energy_scale->has_scale_range()) { + mlm_history_weights.push_back(scales.at("scale_weight")); + } + ren_scale_acc = scales.at("ren_scale"); + for (std::size_t i = 0; i < 2; ++i) { + if (_diff_xs.at(0).has_pdf(i)) { + fact_scales_acc.at(i) = scales.at(std::format("fact_scale{}", i + 1)); + } + } + pdfs_acc = evaluate_pdfs(fb, scales, x_acc, flavor_id); + cluster_scales_acc = scales.at("outgoing_scales"); + alpha_qcd_acc = + _running_coupling.value().build_function(fb, {ren_scale_acc}).at(0); + dxs_vec = _diff_xs.at(0) + .build_function( + fb, + make_xs_args(scales.at("diagram_index"), pdfs_acc, alpha_qcd_acc) + ) + .values(); + } + auto diff_xs_acc = dxs_vec.at(0); if (_flavor_factors.size() > 0) { diff_xs_acc = fb.mul(diff_xs_acc, fb.gather(flavor_id, _flavor_factors)); } ValueVec weights_after_cuts{args.at("weight_after_cuts"), diff_xs_acc}; + for (auto& weight : mlm_history_weights) { + weights_after_cuts.push_back(weight); + } ValueVec extra_weights_after_cuts; if (args.index_map().contains("extra_weight_after_cuts")) { extra_weights_after_cuts.push_back(args.at("extra_weight_after_cuts")); @@ -1115,23 +1249,23 @@ NamedVector Integrand::build_common_part( } outputs.push_back( - "ren_scale", scatter_or_drop(zeros_float, args.at("ren_scale")) + "ren_scale", scatter_or_drop(zeros_float, ren_scale_acc) ); outputs.push_back("alpha_qcd", scatter_or_drop(zeros_float, alpha_qcd_acc)); ValueVec pdf_vals; if (_diff_xs.at(0).has_pdf(0)) { outputs.push_back("x1", scatter_or_drop(zeros_float, args.at("x1_acc"))); outputs.push_back( - "fact_scale1", scatter_or_drop(zeros_float, args.at("fact_scale1")) + "fact_scale1", scatter_or_drop(zeros_float, fact_scales_acc.at(0)) ); - pdf_vals.push_back(args.at("pdf1")); + pdf_vals.push_back(pdfs_acc.at(0)); } if (_diff_xs.at(0).has_pdf(1)) { outputs.push_back("x2", scatter_or_drop(zeros_float, args.at("x2_acc"))); outputs.push_back( - "fact_scale2", scatter_or_drop(zeros_float, args.at("fact_scale2")) + "fact_scale2", scatter_or_drop(zeros_float, fact_scales_acc.at(1)) ); - pdf_vals.push_back(args.at("pdf2")); + pdf_vals.push_back(pdfs_acc.at(1)); } if (_partial_weights && (_diff_xs.at(0).has_pdf(0) || _diff_xs.at(0).has_pdf(1))) { @@ -1146,7 +1280,7 @@ NamedVector Integrand::build_common_part( "cluster_scales", scatter_or_drop( fb.full({0., batch_size_val, outgoing_count}), - args.at("cluster_scales_acc") + cluster_scales_acc ) ); } diff --git a/madspace/src/phasespace/scale.cpp b/madspace/src/phasespace/scale.cpp index ab480d0436..5b73b4cded 100644 --- a/madspace/src/phasespace/scale.cpp +++ b/madspace/src/phasespace/scale.cpp @@ -95,6 +95,14 @@ NamedVector EnergyScale::apply_scale_range( return scales; } +NamedVector EnergyScale::build_mlm_from_start_state( + FunctionBuilder& fb, Value momenta, Value start_state +) const { + return apply_scale_range( + fb, _clustering.value().build_from_start_state(fb, momenta, start_state) + ); +} + NamedVector EnergyScale::build_function_impl( FunctionBuilder& fb, const NamedVector& args ) const { From 692fe92b88fbcf917d4f1501d13cbc85afb67008 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Tue, 15 Sep 2026 22:20:50 +0200 Subject: [PATCH 47/81] Add beam.clustering_history to the mg7 run card all_diagrams (default, unchanged), diagram or madevent, passed to MLMClustering by the launcher. Co-Authored-By: Claude Opus 5 --- madgraph/iolibs/template_files/mg7/launch.py | 4 ++++ .../iolibs/template_files/mg7/run_card.toml | 7 +++++++ madgraph/various/banner.py | 17 +++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index d8a6b90d73..c04c055a67 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -2089,6 +2089,10 @@ def __init__( ms.MLMClustering.ClusteringMeasure, self.process.run_card["beam"]["clustering_measure"], ), + clustering_history=getattr( + ms.MLMClustering.ClusteringHistory, + self.process.run_card["beam"]["clustering_history"], + ), ), min_scale=self.process.run_card["beam"]["min_scale"], max_scale=max_scale, diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index 2b3d93451b..3457deb796 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -77,6 +77,13 @@ pdf_reweighting = %(beam.pdf_reweighting)s # massless-massive pair scores the massless one's transverse mass). They only # differ for e.g. q* > q W and g* > g h; madevent matches madevent's MLM clustering_measure = %(beam.clustering_measure)s +# which diagrams the MLM clustering history may follow (dynamical_scale_choice = +# "mlm"). options: all_diagrams (smallest measure over every diagram at once), +# diagram (one diagram per event, picked by |A_i|^2, clustered with its own +# line flavours), madevent (all_diagrams unless its merging-jet count differs +# from the picked diagram's, as madevent reclusters along the channel). The +# last two evaluate the matrix element twice per event +clustering_history = %(beam.clustering_history)s # floor on mu_R and mu_F: an event whose scales fall below this is dropped, # since a PDF grid has no densities there (0 disables it) min_scale = %(beam.min_scale)s diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index da4b3247be..3dbf069d03 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6649,6 +6649,23 @@ def default_setup(self): # Only read when dynamical_scale_choice = "mlm". self.add_toml_param('beam', 'clustering_measure', "fxfx", allowed=['fxfx', 'madevent']) + # Which diagrams the clustering history may follow: + # "all_diagrams": the smallest-measure history over every diagram at + # once, each line's flavour taken from the first + # diagram that has it. + # "diagram": pick one diagram per event with probability + # proportional to its |A_i|^2 and cluster along it + # alone, with its own line flavours. Tells a VBF + # diagram's W/Z lines from gluon fusion's gluons. + # "madevent": keep the all_diagrams history unless it calls a + # different number of legs merging jets than the + # picked diagram does, as setclscales reclusters + # along the integration channel. + # Anything but "all_diagrams" evaluates the matrix element twice per + # event: once for the diagram weights, once at the scales of the chosen + # history. Only read when dynamical_scale_choice = "mlm". + self.add_toml_param('beam', 'clustering_history', "all_diagrams", + allowed=['all_diagrams', 'diagram', 'madevent']) # Floor on mu_R and mu_F, whatever the dynamical scale choice. Below # the lowest Q of a PDF grid the densities are undefined, so an event # whose scales fall under this is dropped. madevent applies the same From 2dc49ed6c4787d97b89565792023b75c5df60468 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Tue, 15 Sep 2026 23:42:58 +0200 Subject: [PATCH 48/81] Write madevent's ptclus for the MLM jet scales under scale_scheme madevent setclscales stores a leg's clustering scale only from jet vertices its line is still a good jet at, and a line stops being one at the first leg iqjets does not call a merging jet. Every other leg is written at sqrt(s), which tells the shower's MLM matching to leave that parton alone. The kernel booked every QCD vertex onto the legs instead. So a VBF tagging quark that radiated a gluon kept the scale of that emission and was matched as a merging jet: in madevent 0% of the p p > h j j j (QCD=1) weight has only one of the two tagging quarks non-merging and the gluon merging, in madspace 20%. Only under scale_scheme = madevent with the production jet scale scheme, which is madevent's. The emission scheme keeps its booking. Cross sections do not move; only pt_clust does. Co-Authored-By: Claude Opus 5 --- madspace/src/kernels/mlm.hpp | 80 ++++++++++++++++++++++++++++++++++++ madspace/tests/test_mlm.py | 77 ++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 715c8c2449..e1385dae6d 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -560,6 +560,11 @@ KERNELSPEC int mlm_clustering_walk( // second time. int step_bare[N_EXT_MAX - 3]; int step_mother_leg1[N_EXT_MAX - 3], step_mother_leg2[N_EXT_MAX - 3]; + // The external legs each daughter's line ends at, as the step found them: + // (daughter 1 first, daughter 2 second, -1 for none). madevent's ptclus + // loop reads ipart of the daughters, which the slots no longer hold once + // the mother has taken them over. + int step_daughter_legs[N_EXT_MAX - 3][4]; bool by_production = jet_scale_scheme == SCHEME_PRODUCTION; FVal ren_scale_val = 1.0; @@ -616,6 +621,11 @@ KERNELSPEC int mlm_clustering_walk( } is_last_cluster &= ~((1 << particle1) | (1 << particle2)); + step_daughter_legs[i][0] = rep1[particle1]; + step_daughter_legs[i][1] = rep2[particle1]; + step_daughter_legs[i][2] = rep1[particle2]; + step_daughter_legs[i][3] = rep2[particle2]; + // Carry the parton line into the mother, which occupies slot // particle1. Only needed for the production scheme, but keeping it // unconditional costs nothing and keeps the two branches comparable. @@ -856,11 +866,16 @@ KERNELSPEC int mlm_clustering_walk( }; int steps = cluster_max + (leftover >= 0 ? 1 : 0); + // goodjet of the two daughters as each step meets them, before the + // step turns the first slot into the mother. The ptclus loop needs it. + bool step_daughter_goodjet[N_EXT_MAX - 3][2]; for (int i = 0; i < cluster_max; ++i) { int data = cluster_history[i]; int particle1 = data & 0xFF; int particle2 = (data >> 8) & 0xFF; int trace = cluster_trace[i]; + step_daughter_goodjet[i][0] = goodjet[particle1]; + step_daughter_goodjet[i][1] = goodjet[particle2]; bool is_jet_in = (trace & TRACE_IS_JET_IN) != 0; if (particle1 >= 2) { // Final-state clustering. isjetvx() of reweight.f: a QCD @@ -1048,6 +1063,71 @@ KERNELSPEC int mlm_clustering_walk( alphas_step[i] = pt_step[i]; } + // The jet scales madevent writes, the "Store jet info for matching" + // loop at the end of setclscales. A leg only takes a scale from a jet + // vertex its line is still a good jet at, and a line stops being one + // at the first leg that iqjets does not call a merging jet. Everything + // else is written at sqrt(s), which is what tells the shower's MLM + // matching to leave that parton alone: without this a VBF tagging + // quark that radiated a gluon kept the scale of that emission, and was + // matched and vetoed as if it were a merging jet. + // + // Only the production scheme is madevent's; the emission scheme keeps + // the booking above. + if (by_production) { + FVal ptclus[N_EXT_MAX]; + for (int leg = 0; leg < n_part; ++leg) { + ptclus[leg] = 0.0; + } + auto book = [&](int leg, bool jet_vertex, bool& good, FVal scale) { + if (leg < 2) { + return; + } + if (good && iqjets[leg] == 0) { + good = false; + } + if (jet_vertex && good) { + ptclus[leg] = max(ptclus[leg], scale); + } else if (ptclus[leg] == 0.0) { + ptclus[leg] = FVal(cm_energy); + } + }; + for (int i = 0; i < cluster_max; ++i) { + int data = cluster_history[i]; + int trace = cluster_trace[i]; + bool is_qcd_vertex = (data >> 27) & 1; + bool is_jet1 = (data >> 28) & 1; + bool is_jet2 = (data >> 29) & 1; + bool is_jet_in = (trace & TRACE_IS_JET_IN) != 0; + // isjetvx: an initial-state vertex is a jet vertex when the + // emitted daughter is a jet, a final-state one as in the + // iqjets walk above. + bool jet_vertex = is_qcd_vertex && + ((data & 0xFF) < 2 + ? is_jet2 + : ((is_jet1 && + (is_jet_in || (trace & TRACE_MOTHER_IS_DAU2) != 0)) || + (is_jet2 && + (is_jet_in || (trace & TRACE_MOTHER_IS_DAU1) != 0)))); + for (int k = 0; k < 2; ++k) { + bool good = step_daughter_goodjet[i][k]; + for (int m = 0; m < 2; ++m) { + book(step_daughter_legs[i][2 * k + m], jet_vertex, good, pt_step[i]); + } + } + } + if (leftover >= 0) { + // the root is never a jet vertex (islast in isjetvx) + bool good = goodjet[leftover]; + book(rep1[leftover], false, good, 0.0); + book(rep2[leftover], false, good, 0.0); + } + for (int leg = 2; leg < n_part; ++leg) { + outgoing_scales[leg - 2] = + ptclus[leg] > 0.0 ? ptclus[leg] : FVal(cm_energy); + } + } + FVal s_last[2], s_central[2]; for (int j = 0; j < 2; ++j) { s_last[j] = jlast[j] < 0 ? 0.0 : pt_step[jlast[j]]; diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 66905417b5..2b249e83c0 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -2086,3 +2086,80 @@ def test_a_diagram_history_uses_that_diagrams_own_line_flavours(): assert np.mean(np.abs(t_channel[3][:, 2] / jet_pt - 1.0) < 1e-5) == 1.0 # the s-channel one never clusters the jet with beam 0 before the W assert np.mean(np.abs(s_channel[3][:, 2] / jet_pt - 1.0) < 1e-5) < 0.5 + + +# -------------------------------------------------------------------------- +# jet scales of legs that are not merging jets, under scale_scheme = madevent +# -------------------------------------------------------------------------- + + +def vbf_like_fsr_diagram(): + """u d > u d g with a colourless t-channel Z between the two quark lines + and the gluon radiated off the outgoing u: + + u* > u g (final state, a jet vertex) + u Z > u* (beam 0 line, through the Z) + Z d > d (beam 1 line) + + Neither quark is a merging jet in madevent (the Z stops both beam lines + being parton lines), the gluon is.""" + return [ + { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0, 0.0], + "propagators": [(0.0, 0.0, 2), (M_Z, 0.0, 23)], + "vertices": [["o0", "o2", "p0"], ["i0", "p1", "p0"], ["p1", "i1", "o1"]], + "permutations": [[0, 1, 2, 3, 4]], + }, + ] + + +def vbf_like_fsr_clustering(**kwargs): + diagrams = vbf_like_fsr_diagram() + return ms.MLMClustering( + [ + ms.Topology( + ms.Diagram( + d["incoming_masses"], + d["outgoing_masses"], + [ms.Propagator(mass=m, width=w, pdg_id=i) + for m, w, i in d["propagators"]], + d["vertices"], + ) + ) + for d in diagrams + ], + [d["permutations"] for d in diagrams], + make_diagram_indices(diagrams), + cm_energy=CM_ENERGY, + external_pdg_ids=[2, 1, 2, 1, 21], + scale_scheme=ms.MLMClustering.ScaleScheme.madevent, + **kwargs, + ) + + +def fsr_jet_scales(**kwargs): + momenta = sample_momenta(vbf_like_fsr_diagram(), batch_size=500) + return run(vbf_like_fsr_clustering(**kwargs), momenta)[3] + + +def test_a_quark_that_is_not_a_merging_jet_is_written_at_the_collider_energy(): + """madevent writes ptclus = sqrt(s) for every leg iqjets does not call a + merging jet, even one that radiated at a QCD vertex: the shower's MLM + matching must leave it alone. The gluon keeps the scale of the vertex it + was emitted at. (The kt measure of these unconstrained points can itself + exceed sqrt(s), so the scales are compared to each other, not to it.)""" + production = fsr_jet_scales() + emission = fsr_jet_scales(jet_scale_scheme=ms.MLMClustering.JetScaleScheme.emission) + np.testing.assert_array_equal(production[:, 0], CM_ENERGY) + np.testing.assert_array_equal(production[:, 1], CM_ENERGY) + np.testing.assert_array_equal(production[:, 2], emission[:, 2]) + assert np.all(production[:, 2] != CM_ENERGY) + + +def test_the_emission_scheme_still_books_the_radiating_quark(): + """The emission scheme is not madevent's and keeps booking the (u, g) + vertex onto both of its legs.""" + emission = fsr_jet_scales(jet_scale_scheme=ms.MLMClustering.JetScaleScheme.emission) + np.testing.assert_array_equal(emission[:, 0], emission[:, 2]) + np.testing.assert_array_equal(emission[:, 1], CM_ENERGY) From 6b9778da8bc57090225ee9cd22cb49e84ef17bab Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 16 Sep 2026 05:07:49 +0200 Subject: [PATCH 49/81] Test that auto_ptj_mjj names cuts madspace can build The merging-cut adjustment names a cut after the observable it wants, and parse_observable feeds that name straight to ms.Observable, but nothing checked the two agree: when madspace renamed m_inv to pair_mass, every merged run with xqcut > 0 died building its cuts. These call both methods unbound on a stand-in run card, so they need no process directory, and assert each cut written is one madspace accepts. Co-Authored-By: Claude Opus 5 --- tests/unit_tests/interface/test_mg7_launch.py | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/tests/unit_tests/interface/test_mg7_launch.py b/tests/unit_tests/interface/test_mg7_launch.py index 325b90507a..41817c1816 100644 --- a/tests/unit_tests/interface/test_mg7_launch.py +++ b/tests/unit_tests/interface/test_mg7_launch.py @@ -713,3 +713,83 @@ class _Process(object): # _find_event_file would fail on the fake path, and MG7RunCmd would # fail harder: returning early means neither is reached launch.run_selected_tools(switch, _Process()) + + +@unittest.skipUnless(mg7_bootstrap.madspace_is_installed(), + 'madspace is not installed') +class MG7AutoPtjMjjCutNamesTest(unittest.TestCase): + """The cuts auto_ptj_mjj writes must be ones madspace can build. + + apply_auto_ptj_mjj names a cut after the observable it wants, and + parse_observable turns that name straight into an ms.Observable option. + Nothing else checks the two agree, so renaming an observable in madspace + (m_inv became pair_mass) left the launcher asking for a cut that no longer + existed, and every merged run with xqcut > 0 died building its cuts -- + before reaching the clustering the cut exists for. + + The two methods are called unbound, on a stand-in carrying just the run + card: building a real MadgraphProcess needs a process directory, which + would put this test out of reach of a unit run. + """ + + OUTGOING_PDGS = [21, 21, 25, 1, -1] + + def setUp(self): + from madgraph.iolibs.template_files.mg7 import launch as mg7_launch + self.launch = mg7_launch + + def make_process(self, cuts, xqcut=20.0, auto=True): + return types.SimpleNamespace(run_card={ + 'beam': {'dynamical_scale_choice': 'mlm'}, + 'phasespace': {'xqcut': xqcut, 'auto_ptj_mjj': auto}, + 'multiparticles': {'jet': [21, 1, 2, -1, -2], + 'lepton': [11, 13, -11, -13]}, + 'cuts': dict(cuts), + }) + + def apply(self, cuts, **kwargs): + process = self.make_process(cuts, **kwargs) + out = {key: dict(values) for key, values in cuts.items()} + self.launch.MadgraphProcess.apply_auto_ptj_mjj(process, out) + return process, out + + def test_every_cut_it_writes_builds_an_observable(self): + import madspace as ms + + process, cuts = self.apply({'jet-pt': {'min': 10.0}, + 'jet-delta_r': {'min': 0.4}}) + for key in cuts: + kwargs = self.launch.MadgraphProcess.parse_observable( + process, key, 'pt') + # raises TypeError if the observable name is not one madspace knows + ms.Observable(self.OUTGOING_PDGS, **kwargs) + + def test_it_tightens_the_jet_pt_and_pair_mass_to_xqcut(self): + _, cuts = self.apply({'jet-pt': {'min': 10.0}}, xqcut=20.0) + self.assertEqual(cuts['jet-pt']['min'], 20.0) + self.assertEqual(cuts['jet-pair_mass']['min'], 20.0) + + def test_it_drops_the_jet_cone_cuts(self): + """A dR cut would carve a hole out of the region the shower fills.""" + _, cuts = self.apply({'jet-delta_r': {'min': 0.4}, + 'jet-lepton-delta_r': {'min': 0.4}}) + self.assertEqual(cuts['jet-delta_r']['min'], 0.0) + self.assertEqual(cuts['jet-lepton-delta_r']['min'], 0.0) + + def test_with_the_option_off_it_only_zeroes_cuts_above_xqcut(self): + _, cuts = self.apply({'jet-pt': {'min': 50.0}}, xqcut=20.0, auto=False) + self.assertEqual(cuts['jet-pt']['min'], 0.0) + self.assertNotIn('jet-pair_mass', cuts) + _, cuts = self.apply({'jet-pt': {'min': 10.0}}, xqcut=20.0, auto=False) + self.assertEqual(cuts['jet-pt']['min'], 10.0) + + def test_it_leaves_an_unmerged_run_alone(self): + for kwargs in ({'xqcut': 0.0}, {}): + process = self.make_process({'jet-pt': {'min': 10.0}}, **kwargs) + if not kwargs: + process.run_card['beam']['dynamical_scale_choice'] = \ + 'transverse_mass' + cuts = {'jet-pt': {'min': 10.0}, 'jet-delta_r': {'min': 0.4}} + self.launch.MadgraphProcess.apply_auto_ptj_mjj(process, cuts) + self.assertEqual(cuts, {'jet-pt': {'min': 10.0}, + 'jet-delta_r': {'min': 0.4}}) From 761a43de18fc29edb2ce376fcb892be17b88ced2 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 16 Sep 2026 09:33:06 +0200 Subject: [PATCH 50/81] Run the mg7 launcher cut-name tests in the mg7 acceptance workflow They need a compiled madspace to check the launcher's cut names against ms.Observable, and the unittest jobs have none, so listed there they would only ever skip. This workflow already builds madspace once and every job restores it. Co-Authored-By: Claude Opus 5 --- .github/workflows/acceptancetest_mg7.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/acceptancetest_mg7.yml b/.github/workflows/acceptancetest_mg7.yml index 7b5d610325..a24d9c7b84 100644 --- a/.github/workflows/acceptancetest_mg7.yml +++ b/.github/workflows/acceptancetest_mg7.yml @@ -589,3 +589,22 @@ jobs: ./tests/test_manager.py test_w_production_with_ms_decay_mg7 -pA -t0 -l INFO + acceptancetest_mg7_launcher_cut_names: + needs: build_madspace + # Unit tests, but they belong here rather than in unittest.yml: they need a + # compiled madspace to check that the cut names the mg7 launcher writes are + # ones ms.Observable accepts, and the unit jobs have none (they would skip). + # No LHAPDF or model files: nothing is generated, the launcher's cut helpers + # are called on a stand-in run card. Hence no heptools restore, and no -pA: + # these live in the default tests/unit_tests path. + runs-on: ubuntu-24.04 + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork == true + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/checkout_mg5 + - uses: ./.github/actions/install_madspace + - uses: ./.github/actions/restore-pip-cache + - name: test the mg7 launcher writes cut names madspace can build + run: | + cd $GITHUB_WORKSPACE + ./tests/test_manager.py test_every_cut_it_writes_builds_an_observable test_it_tightens_the_jet_pt_and_pair_mass_to_xqcut test_it_drops_the_jet_cone_cuts test_with_the_option_off_it_only_zeroes_cuts_above_xqcut test_it_leaves_an_unmerged_run_alone -t0 -l INFO From 46b609bf30dbced0aeced6f096b11b5d52598d38 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Tue, 6 Oct 2026 22:40:19 +0200 Subject: [PATCH 51/81] Fix the MLM scale floor, diagram input, merged subprocesses and decays min_scale only vetoes under MLM. madevent's setclscales drops a merged event whose factorisation scale is under 2 GeV, but for any other scale choice it lets LHAPDF freeze the density, so the other choices now clamp the scales into the grid's band without a scale_weight. A run without beam densities (lepton collider, decay) gets no range at all: a decay of a particle lighter than 2 GeV came out with zero width. ta- > vt e- ve~ now gives 4.07e-13 GeV. Go back to random_diagram_in for the matrix element under MLM. Neither backend reads diagram_in: madmatrix ignores it and the mg7 api.cpp rejects it. So madmatrix drew its diagram, and with it the colour flow, at a fixed random number of 0.5 for every event. MLM turns merge_subprocesses off with a warning. A merged subprocess bundles matrix elements whose legs differ (q q~ > e+ e- g next to q g > e+ e- q), while the clustering is built from one flavour per leg. It crashed before (no pdg_color_types in the merged meta, three-level diagram indices), and patched past that it clustered the gluon-initiated elements with a quark beam. p p > e+ e- j at xqcut 20: 312.3(6) pb merged against 319.0(7) unmerged. With the fallback it gives 319.3(1.4). It also removes the per-diagram history's single-matrix-element crash. dynamical_scale_choice = mlm on a decay warns and uses the decaying mass as a fixed scale, instead of building a clustering with one incoming leg. The MLM EnergyScale constructor left the closed-form scale members uninitialised; they now have defaults. Co-Authored-By: Claude Opus 5 --- docs/mlm-merging-status.md | 11 ++-- madgraph/iolibs/template_files/mg7/launch.py | 59 ++++++++++++------- .../iolibs/template_files/mg7/run_card.toml | 7 ++- madgraph/various/banner.py | 14 +++-- .../include/madspace/phasespace/scale.hpp | 32 ++++++---- madspace/src/phasespace/integrand.cpp | 23 ++------ madspace/src/phasespace/scale.cpp | 32 +++++----- 7 files changed, 101 insertions(+), 77 deletions(-) diff --git a/docs/mlm-merging-status.md b/docs/mlm-merging-status.md index b5f7edf716..949ccc1a9f 100644 --- a/docs/mlm-merging-status.md +++ b/docs/mlm-merging-status.md @@ -194,10 +194,13 @@ much as for madspace. information; if merging is to work well, the fraction of jets landing there needs to come down. -* Pass the selected diagram index through to the matrix element. The plumbing is - in place (`MatrixElement::diagram_in` replaces `random_diagram_in` when MLM is - active) but the madmatrix side needs per-event diagram input support. Affects - the LHE output only, not the scales. +* Pass the selected diagram index through to the matrix element. Neither + backend takes a per-event diagram yet: madmatrix accepts `diagram_in` and + ignores it, and the mg7 `api.cpp` rejects it as an unsupported input. Feeding + it `diagram_in` in place of `random_diagram_in` therefore left madmatrix + drawing its diagram, and with it the colour flow, at a fixed random number of + 0.5 for every event, so MLM keeps `random_diagram_in` until the backends + support it. Affects the LHE output only, not the scales. * The `dj_clus` massless/massive test is `mass > 0` here, where the Fortran uses thresholds tied to `maxjetflavor` (`m >= 3` if `maxjetflavor > 4`, else diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index c04c055a67..f222aa2e5c 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -240,7 +240,20 @@ def load_cards(self) -> None: self.param_card = ParamCard(self.param_card_path) with open(os.path.join("SubProcesses", "subprocesses.json")) as f: self.subprocess_data = json.load(f) - if self.run_card["phasespace"]["merge_subprocesses"]: + merge_subprocesses = self.run_card["phasespace"]["merge_subprocesses"] + if merge_subprocesses and self.run_card["beam"]["dynamical_scale_choice"] == "mlm": + # A merged subprocess bundles matrix elements whose legs differ - + # q q~ > l l g next to q g > l l q - while the MLM clustering is + # built once per subprocess from a single assignment of flavours to + # legs. It would cluster the gluon-initiated elements as if their + # beam were a quark, and pick the history per diagram from the + # weights of one element only. + logger.warning( + "merge_subprocesses is not supported with dynamical_scale_choice " + "= mlm; the subprocesses are kept separate" + ) + merge_subprocesses = False + if merge_subprocesses: with open(os.path.join("SubProcesses", "merged_subprocesses.json")) as f: self.merged_subprocess_data = json.load(f) else: @@ -446,7 +459,7 @@ def apply_auto_ptj_mjj(self, cuts: dict) -> None: With the option off, madevent still drops the dR cuts, and only zeroes a pt or pair-mass cut that sits above xqcut - it never tightens. """ - if self.run_card["beam"]["dynamical_scale_choice"] != "mlm": + if self.is_decay or self.run_card["beam"]["dynamical_scale_choice"] != "mlm": return xqcut = self.run_card["phasespace"]["xqcut"] if xqcut <= 0: @@ -609,7 +622,17 @@ def init_beam(self) -> None: raise ValueError("Unknown dynamical scale choice") if self.is_decay: # One scale is available for a decay -- the decaying mass -- so use - # it, fixed, whatever the card asks for. + # it, fixed, whatever the card asks for. That includes "mlm": a + # decay has no beams to cluster into, so there is no merging to do. + if self.mlm_clustering: + logger.warning( + "dynamical_scale_choice = mlm has no meaning for a decay; " + "using the decaying mass as a fixed scale instead" + ) + self.mlm_clustering = False + self.scale_kwargs.setdefault( + "dynamical_scale_type", ms.EnergyScale.half_transverse_mass + ) self.scale_kwargs.update( ren_scale_fixed=True, fact_scale_fixed=True, @@ -2047,7 +2070,15 @@ def __init__( else None ) - max_scale = 0.0 if self.process.leptonic else self.process.max_scale + # The scale range exists to keep the densities inside the PDF grid, so + # a run without beam densities - a lepton collider or a decay - has + # none: a floor there would only throw away events whose scale is + # legitimately low, a decay of a particle lighter than min_scale say. + if self.process.leptonic: + min_scale = max_scale = 0.0 + else: + min_scale = self.process.run_card["beam"]["min_scale"] + max_scale = self.process.max_scale if self.process.mlm_clustering: mc_data = self.build_multi_channel_data() self.scale = ms.EnergyScale( @@ -2094,13 +2125,13 @@ def __init__( self.process.run_card["beam"]["clustering_history"], ), ), - min_scale=self.process.run_card["beam"]["min_scale"], + min_scale=min_scale, max_scale=max_scale, ) else: self.scale = ms.EnergyScale( particle_count=self.particle_count, - min_scale=self.process.run_card["beam"]["min_scale"], + min_scale=min_scale, max_scale=max_scale, **self.process.scale_kwargs, ) @@ -2673,24 +2704,12 @@ def build_integrands( flavor_factors.append(len(flav["options"])) flavor_mirror.append(flav["mirror"]) - # With MLM the clustering already selects a diagram, so the matrix element - # takes it as an input instead of drawing one from its own random number. - if self.scale.is_mlm(): - me_inputs = [ - ms.MatrixElement.diagram_in - if inp == ms.MatrixElement.random_diagram_in - else inp - for inp in ms.Integrand.matrix_element_inputs - ] - else: - me_inputs = ms.Integrand.matrix_element_inputs - cross_sections = [] for matrix_element in self.matrix_elements: if matrix_element: mat = ms.MatrixElement( matrix_element, - me_inputs, + ms.Integrand.matrix_element_inputs, ms.Integrand.matrix_element_outputs, True, ) @@ -2699,7 +2718,7 @@ def build_integrands( mat = ms.MatrixElement( 0xBADCAFE, self.particle_count, - me_inputs, + ms.Integrand.matrix_element_inputs, ms.Integrand.matrix_element_outputs, self.meta["diagram_count"], True, diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index 3457deb796..ca54e6506d 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -84,8 +84,9 @@ clustering_measure = %(beam.clustering_measure)s # from the picked diagram's, as madevent reclusters along the channel). The # last two evaluate the matrix element twice per event clustering_history = %(beam.clustering_history)s -# floor on mu_R and mu_F: an event whose scales fall below this is dropped, -# since a PDF grid has no densities there (0 disables it) +# floor on mu_R and mu_F, since a PDF grid has no densities below it: the +# scales are raised to it, and under dynamical_scale_choice = "mlm" the event +# is dropped instead, as madevent does. Ignored without beam PDFs (0 disables it) min_scale = %(beam.min_scale)s [generation] @@ -135,7 +136,7 @@ max_batch_size = %(vegas.max_batch_size)s [phasespace] mode = %(phasespace.mode)s #options: multichannel, flat, both -merge_subprocesses = %(phasespace.merge_subprocesses)s +merge_subprocesses = %(phasespace.merge_subprocesses)s # ignored under dynamical_scale_choice = "mlm" sde_strategy = %(phasespace.sde_strategy)s #options: diagrams, denominators decays = %(phasespace.decays)s # options: all, massive, none t_channel = %(phasespace.t_channel)s # options: propagator, rambo, chili diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index 3dbf069d03..e67af6ed6a 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6663,13 +6663,17 @@ def default_setup(self): # along the integration channel. # Anything but "all_diagrams" evaluates the matrix element twice per # event: once for the diagram weights, once at the scales of the chosen - # history. Only read when dynamical_scale_choice = "mlm". + # history. Only read when dynamical_scale_choice = "mlm", which also + # turns phasespace.merge_subprocesses off: the clustering is built + # from one flavour assignment per subprocess. self.add_toml_param('beam', 'clustering_history', "all_diagrams", allowed=['all_diagrams', 'diagram', 'madevent']) - # Floor on mu_R and mu_F, whatever the dynamical scale choice. Below - # the lowest Q of a PDF grid the densities are undefined, so an event - # whose scales fall under this is dropped. madevent applies the same - # floor to mu_F. 0 disables it. + # Floor on mu_R and mu_F. Below the lowest Q of a PDF grid the + # densities are undefined, so the scales are raised to it, which is + # what LHAPDF's freezing does for madevent. Under + # dynamical_scale_choice = "mlm" an event whose scales fall under it is + # dropped instead, as madevent's setclscales does at 2 GeV. Ignored + # for a run without beam PDFs (lepton collider, decay). 0 disables it. self.add_toml_param('beam', 'min_scale', 2.0) # -------------------------- [generation] ---------------------- diff --git a/madspace/include/madspace/phasespace/scale.hpp b/madspace/include/madspace/phasespace/scale.hpp index 011744b49b..d59e41ee97 100644 --- a/madspace/include/madspace/phasespace/scale.hpp +++ b/madspace/include/madspace/phasespace/scale.hpp @@ -50,10 +50,10 @@ class EnergyScale : public FunctionGenerator { double ren_scale, double fact_scale1, double fact_scale2, - // Floor on mu_R and mu_F. An event below it is vetoed through the - // scale_weight output and the scales are clamped, so that a pdf is - // never asked for a density below the bottom of its grid. Zero - // disables it. + // Floor on mu_R and mu_F. The scales are clamped to it, so that a pdf + // is never asked for a density below the bottom of its grid. Only an + // MLM clustering also vetoes an event below it, through the + // scale_weight output, as setclscales does. Zero disables it. double min_scale = 0., // Upper end of the same range, normally the top of the PDF grid. double max_scale = 0. @@ -97,6 +97,12 @@ class EnergyScale : public FunctionGenerator { bool has_scale_range() const { return _min_scale > 0. || _max_scale > 0.; } + // Whether the scales come with a scale_weight output vetoing an event + // outside the range. Only the MLM clustering has one: madevent drops a + // merged event whose factorisation scale falls under 2 GeV, but for any + // other scale choice it lets LHAPDF freeze the density instead, which is + // what clamping the scale does here. + bool has_scale_veto() const { return is_mlm() && has_scale_range(); } private: NamedVector apply_scale_range( @@ -107,14 +113,16 @@ class EnergyScale : public FunctionGenerator { FunctionBuilder& fb, const NamedVector& args ) const override; - DynamicalScaleType _dynamical_scale_type; - bool _ren_scale_fixed; - bool _fact_scale_fixed; - double _ren_scale; - double _fact_scale1; - double _fact_scale2; - double _min_scale; - double _max_scale; + // The MLM constructor leaves these at their defaults: the clustering + // decides the scales there. + DynamicalScaleType _dynamical_scale_type = half_transverse_mass; + bool _ren_scale_fixed = false; + bool _fact_scale_fixed = false; + double _ren_scale = 0.; + double _fact_scale1 = 0.; + double _fact_scale2 = 0.; + double _min_scale = 0.; + double _max_scale = 0.; std::optional _clustering; }; diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index 9775c9b6de..a5f547ec2a 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -398,7 +398,6 @@ NamedVector Integrand::compute_channel_part_ret_types() const { if (_energy_scale && _energy_scale->is_mlm()) { ret.push_back("cluster_scales_acc", acc_float_array(particle_count - 2)); - ret.push_back("scale_diagram_index_acc", acc_int); } return ret; @@ -654,7 +653,7 @@ NamedVector Integrand::build_channel_part( weights_after_cuts.push_back(weight); } } - if (_energy_scale && _energy_scale->has_scale_range() && !mlm_history_per_diagram) { + if (_energy_scale && _energy_scale->has_scale_veto() && !mlm_history_per_diagram) { // Same for the floor on the scales themselves, which applies to every // dynamical scale choice rather than only to the merging one. weights_after_cuts.push_back(scales.at("scale_weight")); @@ -718,7 +717,6 @@ NamedVector Integrand::build_channel_part( if (_energy_scale && _energy_scale->is_mlm()) { out.push_back("cluster_scales_acc", scales.at("outgoing_scales")); - out.push_back("scale_diagram_index_acc", scales.at("diagram_index")); } return out; @@ -948,15 +946,12 @@ NamedVector Integrand::build_common_part( } // Evaluate differential cross section - auto make_xs_args = [&](Value diagram, std::array& pdfs, Value alpha) { + auto make_xs_args = [&](std::array& pdfs, Value alpha) { ValueVec xs_args{ momenta_acc, _flavor_remap.size() > 0 ? fb.gather_int(flavor_id, _flavor_remap) : flavor_id, }; - if (_energy_scale && _energy_scale->is_mlm()) { - xs_args.push_back(diagram); - } xs_args.push_back(x1_acc); xs_args.push_back(x2_acc); xs_args.push_back(flavor_id); @@ -968,12 +963,7 @@ NamedVector Integrand::build_common_part( xs_args.push_back(alpha); return xs_args; }; - ValueVec xs_args = make_xs_args( - _energy_scale && _energy_scale->is_mlm() ? args.at("scale_diagram_index_acc") - : Value(), - pdfs_acc, - alpha_qcd_acc - ); + ValueVec xs_args = make_xs_args(pdfs_acc, alpha_qcd_acc); ValueVec dxs_vec; Value ps_flavor_id; Value subproc_id; @@ -1060,7 +1050,7 @@ NamedVector Integrand::build_common_part( ); std::array x_acc{x1_acc, x2_acc}; mlm_history_weights = mlm_weights(fb, scales, x_acc, flavor_id); - if (_energy_scale->has_scale_range()) { + if (_energy_scale->has_scale_veto()) { mlm_history_weights.push_back(scales.at("scale_weight")); } ren_scale_acc = scales.at("ren_scale"); @@ -1074,10 +1064,7 @@ NamedVector Integrand::build_common_part( alpha_qcd_acc = _running_coupling.value().build_function(fb, {ren_scale_acc}).at(0); dxs_vec = _diff_xs.at(0) - .build_function( - fb, - make_xs_args(scales.at("diagram_index"), pdfs_acc, alpha_qcd_acc) - ) + .build_function(fb, make_xs_args(pdfs_acc, alpha_qcd_acc)) .values(); } diff --git a/madspace/src/phasespace/scale.cpp b/madspace/src/phasespace/scale.cpp index 5b73b4cded..c393ca2aae 100644 --- a/madspace/src/phasespace/scale.cpp +++ b/madspace/src/phasespace/scale.cpp @@ -16,14 +16,9 @@ EnergyScale::EnergyScale( FunctionGenerator( "EnergyScale", {{"momenta", batch_four_vec_array(particle_count)}}, - (min_scale > 0. || max_scale > 0.) - ? NamedVector{{"ren_scale", batch_float}, - {"fact_scale1", batch_float}, - {"fact_scale2", batch_float}, - {"scale_weight", batch_float}} - : NamedVector{{"ren_scale", batch_float}, - {"fact_scale1", batch_float}, - {"fact_scale2", batch_float}} + {{"ren_scale", batch_float}, + {"fact_scale1", batch_float}, + {"fact_scale2", batch_float}} ), _dynamical_scale_type(dynamical_scale_type), _ren_scale_fixed(ren_scale_fixed), @@ -57,11 +52,14 @@ EnergyScale::EnergyScale( // 1 to 10000 GeV for NNPDF23, say - and outside it the densities are not // defined: below, a scale of a few MeV, above, one of several TeV. Either // comes back as a NaN that no later cut can remove, because the pdf is -// evaluated before the veto weight multiplies it and NaN times zero is still -// NaN. So an event outside the range is both vetoed, through the scale_weight -// output, and clamped, which keeps what is computed on the way finite. +// evaluated before any veto weight multiplies it and NaN times zero is still +// NaN. So the scales are clamped into the range, which is what LHAPDF's +// freezing does for madevent. // -// madevent applies the same floor to mu_F, at 2 GeV, in reweight.f. The upper +// An MLM clustering also vetoes the event, through the scale_weight output: +// madevent's setclscales drops a merged event whose factorisation scale is +// under 2 GeV (reweight.f) rather than evaluating it at a frozen density. No +// other scale choice has that veto in madevent, so none has it here. The upper // end has no counterpart there: LHAPDF extrapolates rather than returning a // hole, so madevent never has to look. NamedVector EnergyScale::apply_scale_range( @@ -85,13 +83,17 @@ NamedVector EnergyScale::apply_scale_range( } for (auto name : names) { auto& scale = scales.at(name); - auto pass = fb.cut_one(scale, low, high); - weight = weight ? fb.mul(weight, pass) : pass; + if (_clustering) { + auto pass = fb.cut_one(scale, low, high); + weight = weight ? fb.mul(weight, pass) : pass; + } auto batch_size = fb.batch_size({scale}); scale = fb.max(scale, fb.full({low, batch_size})); scale = fb.min(scale, fb.full({high, batch_size})); } - scales.push_back("scale_weight", weight); + if (weight) { + scales.push_back("scale_weight", weight); + } return scales; } From 317a05f6123f07645b12600a9a2de98e0091d8d3 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 7 Oct 2026 21:35:33 +0200 Subject: [PATCH 52/81] Fix MLM clustering through flavour-forbidden merged graphs With apply_flavor_grouping, cluster() filtered the candidate graphs of a leg pair by flavour (flavor_compatible) but never checked that any graph survived, so the filter had no effect: for the q q~ > q' q~' rows of q q~ > q q~ the clustering took the q-q' t-channel gluon of the merged graphs, which does not exist for those flavours, and skipped the alpha_s reweighting of the q' q~' > g clustering that the ungrouped P0_uux_ddx applies (+13% on the s-channel gluon channel at xqcut=20). - flavor_filter replaces flavor_compatible and works on the event's merged-flavour indices instead of the remapped PDG codes: a g/a/Z/h can only join two merged legs with the same index. Everything else (unmerged or internal legs, W, unmapped merged leptons) stays permissive, as it effectively was. It is applied to every candidate pair (first pass and recomputation) and to igraphs after a winner, and pairs whose graph list ends up empty are no longer clustered. - setclscales: a chcluster-forced clustering on a channel whose graph is forbidden for the event's flavours (s-channel config, u d~ > u d~ row) now returns .false. (zero weight, which the multichannel factor gives anyway) instead of stopping madevent. test_mlm_rewgt_auto_match_qq_to_qq compared the flavour-grouped channel (all 32 flavour rows) with a single ungrouped P directory (8 rows, the one with most channels), a factor ~3.8 by construction. It now compiles every P directory, matches channels by s- and t-channel propagators with flavours mapped onto the merged codes, compares each grouped channel with the sum of its ungrouped counterparts, and uses 50k points (5k left the +13% at 4.4 sigma). run_single_channel removes results.dat before a run, since a Fortran STOP exits with 0 and left the previous channel's result to be read. --- Template/LO/SubProcesses/cluster.f | 189 +++++++--------- Template/LO/SubProcesses/reweight.f | 12 +- tests/acceptance_tests/test_MLM_reweight.py | 226 +++++++++++--------- 3 files changed, 213 insertions(+), 214 deletions(-) diff --git a/Template/LO/SubProcesses/cluster.f b/Template/LO/SubProcesses/cluster.f index 6b4973d446..8d069ea5d3 100644 --- a/Template/LO/SubProcesses/cluster.f +++ b/Template/LO/SubProcesses/cluster.f @@ -434,97 +434,79 @@ subroutine checkbw(nbw,ibwlist,isbw,p) end - logical function flavor_compatible(ipdg_prop, ipdg1, ipdg2) -c Validates if two particles can couple via the given propagator -c Based on flavor conservation rules for QCD and electroweak interactions -c Returns .true. if the coupling is allowed, .false. otherwise - integer ipdg_prop, ipdg1, ipdg2 - integer abs_pdg1, abs_pdg2, abs_prop - logical is_quark1, is_quark2, is_gluon1, is_gluon2 - - abs_pdg1 = abs(ipdg1) - abs_pdg2 = abs(ipdg2) - abs_prop = abs(ipdg_prop) - - is_quark1 = (abs_pdg1.ge.1.and.abs_pdg1.le.5) - is_quark2 = (abs_pdg2.ge.1.and.abs_pdg2.le.5) - is_gluon1 = (ipdg1.eq.21) - is_gluon2 = (ipdg2.eq.21) - -c Gluon (21): diagonal in flavor, requires SAME flavor for both particles - if(abs_prop.eq.21) then - if(is_gluon1.and.is_gluon2) then - flavor_compatible = .true. - else if(is_quark1.and.is_quark2) then -c Same flavor only (abs_pdg1 must equal abs_pdg2) - flavor_compatible = (abs_pdg1.eq.abs_pdg2) - else - flavor_compatible = .false. - endif - return - endif + subroutine flavor_filter(idi,idj,flavor,icgs) +c************************************************************************** +c Drop from icgs the graphs in which legs idi and idj cannot be joined +c with the flavours of this event. +c +c flavor(i) is the merged-flavour index of external leg i (0 when the +c leg is not a merged particle such as 81): two merged legs carry the +c same flavour iff they carry the same index. A flavour-diagonal boson +c (g, a, Z, h) can therefore only join two merged legs with the same +c index -- e.g. for q q~ > q' q~' (flavour pattern 1,1,2,2) the q-q' +c t-channel gluon of the merged graphs does not exist. Every other +c case (unmerged or internal legs, W) is left to the diagrams. +c************************************************************************** + implicit none + include 'nexternal.inc' + include 'maxamps.inc' + include 'cluster.inc' + integer idi, idj, flavor(nexternal), icgs(0:n_max_cg) + integer i, k, l, iflav, jflav, ipdg_prop -c Photon (22): diagonal in flavor, same flavor only - if(abs_prop.eq.22) then - if(is_quark1.and.is_quark2) then -c Same flavor quarks only - flavor_compatible = (abs_pdg1.eq.abs_pdg2) - else if((abs_pdg1.ge.11.and.abs_pdg1.le.16).and. - & (abs_pdg2.ge.11.and.abs_pdg2.le.16)) then -c Same flavor leptons only (e+/e-, mu+/mu-, etc) - flavor_compatible = (abs_pdg1.eq.abs_pdg2) - else - flavor_compatible = .false. - endif - return - endif + integer imirror, iproc + common/to_mirror/imirror, iproc -c Z Boson (23): diagonal in flavor, same flavor only - if(abs_prop.eq.23) then - if(is_quark1.and.is_quark2) then -c Same flavor quarks only - flavor_compatible = (abs_pdg1.eq.abs_pdg2) - else if((abs_pdg1.ge.11.and.abs_pdg1.le.16).and. - & (abs_pdg2.ge.11.and.abs_pdg2.le.16)) then -c Same flavor leptons only - flavor_compatible = (abs_pdg1.eq.abs_pdg2) - else - flavor_compatible = .false. + iflav=0 + jflav=0 + do l=1,nexternal + if(idi.eq.ishft(1,l-1)) iflav=abs(flavor(l)) + if(idj.eq.ishft(1,l-1)) jflav=abs(flavor(l)) + enddo + if(iflav.eq.0.or.jflav.eq.0.or.iflav.eq.jflav) return + + k=0 + do i=1,icgs(0) + ipdg_prop=abs(ipdgcl(idi+idj,icgs(i),iproc)) + if(ipdg_prop.lt.21.or.ipdg_prop.gt.25.or.ipdg_prop.eq.24)then + k=k+1 + icgs(k)=icgs(i) endif - return - endif + enddo + icgs(0)=k + end -c W Boson (24): couples up-type to down-type (opposite weak isospin) -c Charge conservation requires one quark (positive PDG) and one antiquark -c (negative PDG): e.g. u(+2)+d~(-1) is valid, u(+2)+d(+1) is not. - if(abs_prop.eq.24) then -c Up-type (even abs PDG) must pair with down-type (odd abs PDG) -c and the two must have opposite signs for electric charge conservation - flavor_compatible = ( - & ((mod(abs_pdg1,2).eq.1.and.mod(abs_pdg2,2).eq.0).or. - & (mod(abs_pdg1,2).eq.0.and.mod(abs_pdg2,2).eq.1)).and. - & (ipdg1*ipdg2.lt.0)) - return - endif -c Higgs (25): diagonal in flavor, same flavor only - if(iabs(ipdg_prop).eq.25) then - if(is_quark1.and.is_quark2) then -c Same flavor quarks only - flavor_compatible = (abs_pdg1.eq.abs_pdg2) - else if((abs_pdg1.ge.11.and.abs_pdg1.le.16).and. - & (abs_pdg2.ge.11.and.abs_pdg2.le.16)) then -c Same flavor leptons only - flavor_compatible = (abs_pdg1.eq.abs_pdg2) - else - flavor_compatible = .false. - endif - return - endif + logical function flavor_allows_graph(igraph,flavor) +c************************************************************************** +c False if graph igraph joins two external legs that the flavours of +c this event forbid (see flavor_filter), i.e. if the graph does not +c exist for these flavours. +c************************************************************************** + implicit none + include 'nexternal.inc' + include 'maxamps.inc' + include 'cluster.inc' + integer igraph, flavor(nexternal) + integer i, j, k, idij, icgs(0:n_max_cg) -c Default: be permissive for unknown particles - flavor_compatible = .true. + integer imirror, iproc + common/to_mirror/imirror, iproc + flavor_allows_graph=.true. + do i=3,nexternal + do j=1,i-1 + idij=ishft(1,i-1)+ishft(1,j-1) + do k=1,id_cl(iproc,idij,0) + if(id_cl(iproc,idij,k).eq.igraph)then + icgs(0)=1 + icgs(1)=igraph + call flavor_filter(ishft(1,i-1),ishft(1,j-1),flavor,icgs) + if(icgs(0).eq.0) flavor_allows_graph=.false. + endif + enddo + enddo + enddo end logical function findmt(idij,icgs) @@ -628,7 +610,7 @@ logical function cluster(p, flavor, ivec) real*8 pi(0:3), nr(0:3), pz(0:3) integer i, j, k, n, l, idi, idj, idij, icgs(0:n_max_cg) integer nleft, iwin, jwin, iwinp, imap(nexternal,2) - integer igraph, ipdg_prop, ipdg_i, ipdg_j + integer igraph double precision nn2,ct,st double precision minpt2ij,pt2ij(n_max_cl),zij(n_max_cl) integer tmppdg @@ -643,8 +625,8 @@ logical function cluster(p, flavor, ivec) data (pz(i),i=0,3)/1d0,0d0,0d0,1d0/ integer combid - logical findmt, flavor_compatible - external findmt, flavor_compatible + logical findmt + external findmt double precision dj, pydj, djb, pyjb, dot, SumDot, zclus external dj, pydj, djb, pyjb, dot, SumDot, zclus, combid integer next4 @@ -709,25 +691,12 @@ logical function cluster(p, flavor, ivec) icgs(0)=0 idij=combid(idi,idj) pt2ij(idij)=1.0d37 - if (findmt(idij,icgs)) then -c Filter diagrams by flavor compatibility (CKM/flavor conservation) -c Keep only diagrams where particles i,j can couple via propagator - if(icgs(0).gt.0) then - k=0 - do igraph=1,icgs(0) - ipdg_prop=ipdgcl(idij,icgs(igraph),iproc) - ipdg_i=ipdgcl(ishft(1,i-1),icgs(igraph),iproc) - ipdg_j=ipdgcl(ishft(1,j-1),icgs(igraph),iproc) -c Check if particles can couple via this propagator - if(flavor_compatible(ipdg_prop,ipdg_i,ipdg_j))then - k=k+1 - icgs(k)=icgs(igraph) - endif - enddo - icgs(0)=k - if(btest(mlevel,5)) +c and only via graphs allowed for the flavours of this event + if (findmt(idij,icgs)) + $ call flavor_filter(idi,idj,flavor,icgs) + if (icgs(0).gt.0) then + if(btest(mlevel,5)) $ write(*,*)'After flavor filter: ',icgs(0),' graphs remain' - endif if (j.ne.1.and.j.ne.2) then c final state clustering if(isbw(idij))then @@ -828,8 +797,10 @@ logical function cluster(p, flavor, ivec) enddo endif c Reset igraphs with new mother - if (.not.findmt(imocl(n),igraphs)) then - write(*,*) 'cluster.f: Error. Invalid combination.' + if (findmt(imocl(n),igraphs)) + $ call flavor_filter(idacl(n,1),idacl(n,2),flavor,igraphs) + if (igraphs(0).eq.0) then + write(*,*) 'cluster.f: Error. Invalid combination.' return endif if (btest(mlevel,4)) then @@ -983,7 +954,9 @@ logical function cluster(p, flavor, ivec) idij=combid(idi,idj) c write (*,*) 'RECALC !!! ',idij pt2ij(idij)=1.0d37 - if (findmt(idij,icgs)) then + if (findmt(idij,icgs)) + $ call flavor_filter(idi,idj,flavor,icgs) + if (icgs(0).gt.0) then if (btest(mlevel,4)) then write(*,*)'diagrams: ',(icgs(k),k=1,icgs(0)) endif diff --git a/Template/LO/SubProcesses/reweight.f b/Template/LO/SubProcesses/reweight.f index b5a46c8cd2..08fcadbd8e 100644 --- a/Template/LO/SubProcesses/reweight.f +++ b/Template/LO/SubProcesses/reweight.f @@ -639,10 +639,11 @@ logical function setclscales(p, keepq2bck, ivec, flavor) integer pdgm, pdgid1, pdgid2 logical isqcd,isjet,isparton,cluster,isjetvx,is_octet + logical flavor_allows_graph integer ifsno double precision alphas external isqcd, isjet, isparton, cluster, isjetvx, alphas, ifsno - external is_octet + external is_octet, flavor_allows_graph setclscales=.true. c WARNING: goodjet() is sometimes accessed for variable which are not @@ -677,6 +678,15 @@ logical function setclscales(p, keepq2bck, ivec, flavor) 100 clustered = cluster(p(0,1), flavor, ivec) if(.not.clustered) then if(init_mode) goto 999 +c chcluster allows only the graph of this channel, which may not exist +c for the flavours of this event (e.g. the s-channel gluon of a merged +c q q~ > q q~ channel for a u d~ > u d~ row): the event has no weight +c in this channel, so there is nothing to cluster. + if(chcluster.and..not.flavor_allows_graph(iconfig,flavor)) then + chcluster=chclusold + setclscales=.false. + return + endif open(unit=26,file='../../../error',status='unknown',err=999) write(26,*) 'Error: Clustering failed in cluster.f.' write(*,*) 'Error: Clustering failed in cluster.f.' diff --git a/tests/acceptance_tests/test_MLM_reweight.py b/tests/acceptance_tests/test_MLM_reweight.py index 5919dcb071..105714366f 100644 --- a/tests/acceptance_tests/test_MLM_reweight.py +++ b/tests/acceptance_tests/test_MLM_reweight.py @@ -172,15 +172,16 @@ def _iter_data_assignments(text): def parse_configs_inc(configs_path): - """Parse configs.inc and extract mapconfig(0) and SPROP content robustly.""" + """Parse configs.inc and extract mapconfig(0), SPROP and TPRID content robustly.""" if not os.path.exists(configs_path): - return {'nconfigs': 0, 'sprop_by_config': {}} + return {'nconfigs': 0, 'sprop_by_config': {}, 'tprid_by_config': {}} with open(configs_path) as f: text = f.read() nconfigs = 0 sprop_by_config = {} + tprid_by_config = {} for lhs, rhs in _iter_data_assignments(text): # Handle direct MAPCONFIG(0) initialization @@ -198,6 +199,14 @@ def parse_configs_inc(configs_path): nconfigs = max(nconfigs, int(numbers[0])) continue + # Handle TPRID(branch,config) data statements (t-channel propagators). + tp_match = re.search(r'\bTPRID\s*\(\s*(-?\d+)\s*,\s*(\d+)\s*\)', lhs, re.I) + if tp_match: + config = int(tp_match.group(2)) + pdgs = [int(x) for x in re.findall(r'[+-]?\d+', rhs)] + tprid_by_config.setdefault(config, []).extend(x for x in pdgs if x != 0) + continue + # Handle SPROP data statements. # Accept both SPROP(I,branch,config) and SPROP(idx,branch,config) forms. sp_match = re.search( @@ -217,7 +226,8 @@ def parse_configs_inc(configs_path): if nconfigs == 0 and sprop_by_config: nconfigs = max(sprop_by_config) - return {'nconfigs': nconfigs, 'sprop_by_config': sprop_by_config} + return {'nconfigs': nconfigs, 'sprop_by_config': sprop_by_config, + 'tprid_by_config': tprid_by_config} def get_channel_count(Pdir): @@ -229,13 +239,31 @@ def get_channel_count(Pdir): return parsed['nconfigs'] +def _flavor_blind_pdg(pdg): + """Map a propagator PDG code onto its apply_flavor_grouping class. + + The flavor-grouped output writes merged codes (81 quarks, 82 charged + leptons, 83 neutrinos) where the ungrouped output has explicit flavors, + so the explicit codes are mapped onto the merged ones before comparing. + """ + pdg = abs(pdg) + if 1 <= pdg <= 5: + return 81 + if pdg in (11, 13, 15): + return 82 + if pdg in (12, 14, 16): + return 83 + return pdg + + def get_channel_topology(Pdir): """Read channel topology info from configs.inc to allow channel matching. Returns a dict mapping config_number -> topology_signature where the - signature is a tuple of (propagator_pdg_ids) that uniquely identifies - the diagram topology. This can be used to match corresponding channels - between apply_flavor_grouping=True/False outputs. + signature is a pair of sorted tuples (s-channel propagators, t-channel + propagators), with flavors mapped by _flavor_blind_pdg. This can be used + to match corresponding channels between apply_flavor_grouping=True/False + outputs. Returns: dict: {config_number: topology_signature} @@ -244,39 +272,25 @@ def get_channel_topology(Pdir): topologies = {} for config in range(1, parsed['nconfigs'] + 1): sprops = parsed['sprop_by_config'].get(config, []) - topologies[config] = tuple(sorted(sprops)) + tprops = parsed['tprid_by_config'].get(config, []) + topologies[config] = (tuple(sorted(_flavor_blind_pdg(x) for x in sprops)), + tuple(sorted(_flavor_blind_pdg(x) for x in tprops))) return topologies -def find_matching_channels(Pdir_fg_true, Pdir_fg_false): - """Find corresponding channels between flavor_grouping True and False outputs. - - Compares the topology signatures of channels in both modes and returns - a list of (channel_true, channel_false) pairs that have matching topologies. - - Args: - Pdir_fg_true: subprocess dir path for apply_flavor_grouping=True - Pdir_fg_false: subprocess dir path for apply_flavor_grouping=False +def find_channels_with_topology(Pdirs, signature): + """Return the (Pdir, channel) pairs, over all Pdirs, whose topology is signature. - Returns: - list of (channel_fg_true, channel_fg_false) tuples + Without subprocess grouping, apply_flavor_grouping=False splits the + flavor combinations that a single flavor-grouped channel integrates over + several P directories (e.g. P0_uux_uux and P0_uux_ddx both carry the + s-channel gluon of q q~ > q q~), so a channel has to be compared with the + sum of all its counterparts. """ - topo_true = get_channel_topology(Pdir_fg_true) - topo_false = get_channel_topology(Pdir_fg_false) - - matches = [] - used_false = set() - - for ch_true, sig_true in sorted(topo_true.items()): - for ch_false, sig_false in sorted(topo_false.items()): - if ch_false in used_false: - continue - if sig_true == sig_false: - matches.append((ch_true, ch_false)) - used_false.add(ch_false) - break - - return matches + return [(Pdir, ch) + for Pdir in sorted(Pdirs) + for ch, sig in sorted(get_channel_topology(Pdir).items()) + if sig == signature] def read_results_dat(results_path): @@ -452,6 +466,12 @@ def run_single_channel(Pdir, channel, npoints=5000, maxiter=5, binary='madevent' # Write input config file config_path = write_run_config(Pdir, channel, npoints, maxiter) + # A Fortran STOP exits with 0 without writing results.dat: never read + # the one of a previous channel run in the same directory. + results_path = pjoin(Pdir, 'results.dat') + if os.path.exists(results_path): + os.remove(results_path) + # Run madevent with stdin from config file with open(config_path) as config_in: proc = subprocess.Popen( @@ -476,7 +496,6 @@ def run_single_channel(Pdir, channel, npoints=5000, maxiter=5, binary='madevent' return result # Try to read results from results.dat - results_path = pjoin(Pdir, 'results.dat') parsed = read_results_dat(results_path) if parsed: result['xsec'], result['error'], result['nevents'] = parsed @@ -676,18 +695,27 @@ class TestMLMReweightAutoMatch(MLMReweightTestBase): """Test suite using automatic channel matching between flavor grouping modes. Instead of manually specifying channel numbers for each mode, this test - generates both outputs, uses topology matching to identify corresponding - channels, and then compares them. + generates both outputs without subprocess grouping, uses topology matching + to identify corresponding channels, and compares each flavor-grouped + channel with the sum of its counterparts over all ungrouped P directories. """ def test_mlm_rewgt_auto_match_qq_to_qq(self): - """Compare MLM REWGT for q q~ > q q~ using automatic channel matching.""" + """Compare MLM REWGT for q q~ > q q~ using automatic channel matching. + + The s-channel gluon channel is the sensitive one: for the + q q~ > q' q~' rows the flavor-grouped clustering used to pick the q-q' + t-channel gluon of the merged graphs, which does not exist for those + flavors, so the event skipped the alpha_s reweighting of the d d~ > g + clustering (+13% on that channel at xqcut=20). + """ process = 'q q~ > q q~' model = 'sm' defines = ['q = u d s c', 'q~ = u~ d~ s~ c~'] xqcut = 20.0 - npoints = 5000 + # 5000 points leave the +13% above at only ~4 sigma + npoints = 50000 maxiter = 5 Pdirs_by_mode = {} @@ -696,21 +724,6 @@ def test_mlm_rewgt_auto_match_qq_to_qq(self): fg_label = 'fg_true' if fg_mode else 'fg_false' run_dir = pjoin(self.path, 'MLM_auto_%s' % fg_label) - def pick_pdir(path): - pdirs_list = get_Pdir(path) - subproc_dir = pjoin(path, 'SubProcesses') - if fg_mode: - return pjoin(subproc_dir, sorted(pdirs_list)[0]) - best_pdir = None - best_nchan = 0 - for pname in pdirs_list: - ppath = pjoin(subproc_dir, pname) - nchan = get_channel_count(ppath) - if nchan > best_nchan: - best_nchan = nchan - best_pdir = ppath - return best_pdir or pjoin(subproc_dir, sorted(pdirs_list)[0]) - _, Pdir, binary = self.prepare_mode_run( run_dir=run_dir, process=process, @@ -719,59 +732,62 @@ def pick_pdir(path): xqcut=xqcut, apply_fg=fg_mode, group_subprocesses=False, - pdir_selector=pick_pdir ) + binaries = {Pdir: binary} + for pname in get_Pdir(run_dir): + other = pjoin(run_dir, 'SubProcesses', pname) + if other not in binaries: + binaries[other] = compile_madevent(run_dir, other) + self.assertIsNotNone(binaries[other], + 'Compilation failed (apply_flavor_grouping=%s, subproc=%s)' % + (fg_mode, pname)) + Pdirs_by_mode[fg_label] = binaries + + def run_sum(binaries, channels): + xsec, err2 = 0., 0. + for Pdir, ch in channels: + result = run_single_channel(Pdir, ch, npoints=npoints, + maxiter=maxiter, binary=binaries[Pdir]) + self.assertEqual(result['return_code'], 0, + 'Run failed for %s channel %d' % (os.path.basename(Pdir), ch)) + xsec += result['xsec'] + err2 += result['error'] ** 2 + return xsec, math.sqrt(err2) + + def describe(channels): + return ' + '.join('%s ch=%d' % (os.path.basename(Pdir), ch) + for Pdir, ch in channels) + + signatures = set() + for Pdir in Pdirs_by_mode['fg_true']: + signatures.update(get_channel_topology(Pdir).values()) + self.assertGreater(len(signatures), 0, 'No channel found for fg=True') + + for signature in sorted(signatures): + channels_t = find_channels_with_topology(Pdirs_by_mode['fg_true'], signature) + channels_f = find_channels_with_topology(Pdirs_by_mode['fg_false'], signature) + self.assertGreater(len(channels_f), 0, + 'No fg=False channel matches the topology %s of %s' % + (signature, describe(channels_t))) + + xsec_t, err_t = run_sum(Pdirs_by_mode['fg_true'], channels_t) + xsec_f, err_f = run_sum(Pdirs_by_mode['fg_false'], channels_f) - Pdirs_by_mode[fg_label] = (Pdir, binary) - - # Find matching channels - matches = find_matching_channels( - Pdirs_by_mode['fg_true'][0], - Pdirs_by_mode['fg_false'][0] - ) - - self.assertGreater(len(matches), 0, - 'No matching channels found between flavor grouping modes') - - if getattr(unittest, "debug", False): - misc.sprint('Found %d matching channel pairs' % len(matches)) - - # Run and compare first matched channel pair - ch_true, ch_false = matches[0] - - Pdir_true, binary_true = Pdirs_by_mode['fg_true'] - Pdir_false, binary_false = Pdirs_by_mode['fg_false'] - - result_true = run_single_channel( - Pdir_true, ch_true, - npoints=npoints, maxiter=maxiter, binary=binary_true) - self.assertEqual(result_true['return_code'], 0, - 'Run failed for fg=True channel %d' % ch_true) - - result_false = run_single_channel( - Pdir_false, ch_false, - npoints=npoints, maxiter=maxiter, binary=binary_false) - self.assertEqual(result_false['return_code'], 0, - 'Run failed for fg=False channel %d' % ch_false) - - # Compare cross-sections - xsec_t, err_t = result_true['xsec'], result_true['error'] - xsec_f, err_f = result_false['xsec'], result_false['error'] - - if getattr(unittest, "debug", False): - misc.sprint(' fg=True ch=%d: %s +- %s' % (ch_true, xsec_t, err_t)) - misc.sprint(' fg=False ch=%d: %s +- %s' % (ch_false, xsec_f, err_f)) - - self.assertGreater(abs(xsec_t), 0, - 'Zero xsec for fg=True channel %d' % ch_true) - self.assertGreater(abs(xsec_f), 0, - 'Zero xsec for fg=False channel %d' % ch_false) - - sigma_diff = sigma_difference(xsec_t, err_t, xsec_f, err_f) - self.assertLess(sigma_diff, 5, - 'Cross-sections differ by %.1f sigma: ' - 'fg=True ch=%d (%s +- %s) vs fg=False ch=%d (%s +- %s)' % - (sigma_diff, ch_true, xsec_t, err_t, ch_false, xsec_f, err_f)) + if getattr(unittest, "debug", False): + misc.sprint(' fg=True %s: %s +- %s' % (describe(channels_t), xsec_t, err_t)) + misc.sprint(' fg=False %s: %s +- %s' % (describe(channels_f), xsec_f, err_f)) + + self.assertGreater(abs(xsec_t), 0, + 'Zero xsec for fg=True %s' % describe(channels_t)) + self.assertGreater(abs(xsec_f), 0, + 'Zero xsec for fg=False %s' % describe(channels_f)) + + sigma_diff = sigma_difference(xsec_t, err_t, xsec_f, err_f) + self.assertLess(sigma_diff, 5, + 'Cross-sections differ by %.1f sigma: ' + 'fg=True %s (%s +- %s) vs fg=False %s (%s +- %s)' % + (sigma_diff, describe(channels_t), xsec_t, err_t, + describe(channels_f), xsec_f, err_f)) # ============================================================================ From 03c532ab1f7198968e79223147ca02be003fbe65 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 8 Oct 2026 00:16:04 +0200 Subject: [PATCH 53/81] madspace MLM: do not cluster through flavour-forbidden merged graphs The madspace twin of the cluster.f fix (317a05f61). One MLMClustering serves every flavour of a merged subprocess, and the union of its diagrams has vertices that exist for some flavours only: for the q q~ > q' q~' rows of q q~ > q q~ the q-q' t-channel gluon, which (as the initial-state measure always wins a 2 -> 2) every such event was clustered through. For W + 4 quarks (p p > e+ ve j j, quark-only j, the paper's run_ms.sh settings) flavour grouping on vs off: clustering_history before after all_diagrams 149.66 / 134.25 134.13 / 134.18 madevent 134.23 / 122.87 123.03 / 122.87 diagram 121.66 / 121.61 unchanged - The compiler marks a clustering TRACE_SAME_FLAVOR (trace bit 16) when it joins two bare external fermions and every diagram left puts a g, a, Z or h on that line (per diagram, not from the union's one line meta, which prefers a W). - The kernel takes the flavour of each leg (flavor_index into a leg_flavors table of abs pdg ids, new inputs of mlm_clustering_*) and skips a marked clustering whose legs differ; when a state offers nothing else it falls back to the best skipped one, so the walk always goes on. A row of zeros keeps every clustering. - Integrand: the per-diagram histories (diagram, madevent) are clustered after the flavour is sampled and now get it. all_diagrams clusters before, for the flavour prior; when the clustering depends on the flavours the event is clustered again once the flavour is sampled and its densities re-evaluated at the new scales (the prior keeps the first ones). Nothing changes for a subprocess without such a vertex. - MLMClusteringWithFlavors exposes build_with_flavors to Python, as MLMClusteringAlongDiagram does build_along_diagram; two tests in test_mlm.py (marking, and u u~ > d d~ / u d~ > u d~ following the s- / t-channel diagram). madspace tests: 1667 passed, 11 skipped (torch-only test_flow/test_mlp not run). --- .../include/madspace/phasespace/integrand.hpp | 4 + .../madspace/phasespace/mlm_clustering.hpp | 48 +++++- .../include/madspace/phasespace/scale.hpp | 18 ++- madspace/instruction_set.yaml | 12 ++ madspace/src/kernels/mlm.hpp | 91 +++++++++-- madspace/src/phasespace/integrand.cpp | 49 +++++- madspace/src/phasespace/mlm_clustering.cpp | 144 ++++++++++++++++-- madspace/src/phasespace/scale.cpp | 23 ++- madspace/src/python/madspace.cpp | 9 ++ madspace/tests/test_mlm.py | 76 +++++++++ 10 files changed, 448 insertions(+), 26 deletions(-) diff --git a/madspace/include/madspace/phasespace/integrand.hpp b/madspace/include/madspace/phasespace/integrand.hpp index d9be537d96..4bd80d82f8 100644 --- a/madspace/include/madspace/phasespace/integrand.hpp +++ b/madspace/include/madspace/phasespace/integrand.hpp @@ -160,6 +160,10 @@ class Integrand : public FunctionGenerator { // MLMClustering::diagram_start_states padded to the matrix element's // diagram count, so that any diagram the pick can return has an entry. std::vector _mlm_start_states; + // abs pdg id of every external leg per flavour option, row-major, for the + // clusterings that only exist for some flavours of a merged subprocess. + // Empty when the clustering does not depend on the flavours. + std::vector _mlm_leg_flavors; std::optional _prop_chan_weights; std::optional _subchan_weights; std::optional _chan_weight_net; diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp index b4596bb0dc..e597d6c568 100644 --- a/madspace/include/madspace/phasespace/mlm_clustering.hpp +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -149,9 +149,28 @@ class MLMClustering : public FunctionGenerator { build_along_diagram(FunctionBuilder& fb, Value momenta, Value diagram) const; // The same from an offset into cluster_state_machine, as // diagram_start_states lists them, for a caller that keeps its own table. + // flavor_index and leg_flavors restrict the walk to the clusterings that + // exist for the flavours of each event: leg_flavors holds, row-major, the + // abs pdg id of every external leg for each flavour index. Left out, every + // clustering of the union stays allowed. NamedVector build_from_start_state( - FunctionBuilder& fb, Value momenta, Value start_state + FunctionBuilder& fb, + Value momenta, + Value start_state, + Value flavor_index = Value(), + const std::vector& leg_flavors = {} ) const; + // The history over every diagram, restricted to the flavours of each event + // as build_from_start_state does. + NamedVector build_with_flavors( + FunctionBuilder& fb, + Value momenta, + Value flavor_index, + const std::vector& leg_flavors + ) const; + // Whether any clustering of the compiled state machines depends on the + // flavours of the event. Without one, the flavours change nothing. + bool has_flavor_dependent_clusterings() const { return _has_same_flavor; } // The compiled clustering state machine, in the flat encoding the kernel // walks. Exposed so that its structure can be checked directly. @@ -183,7 +202,12 @@ class MLMClustering : public FunctionGenerator { FunctionBuilder& fb, const NamedVector& args ) const override; NamedVector build_kernel( - FunctionBuilder& fb, Value momenta, Value start_state, me_int_t history_mode + FunctionBuilder& fb, + Value momenta, + Value start_state, + me_int_t history_mode, + Value flavor_index, + const std::vector& leg_flavors ) const; std::vector _cluster_state_machine; @@ -200,6 +224,7 @@ class MLMClustering : public FunctionGenerator { ClusteringMeasure _clustering_measure; ClusteringHistory _clustering_history; std::vector _pdf_absolute_pdgs; + bool _has_same_flavor = false; int _beam_flags; int _jet_leg_mask; double _xqcut; @@ -223,4 +248,23 @@ class MLMClusteringAlongDiagram : public FunctionGenerator { MLMClustering _clustering; }; +// The clustering over every diagram restricted to the flavours of each event, +// taking the flavour index as a second argument and the table of leg flavours +// (see MLMClustering::build_with_flavors) at construction. The integrand calls +// build_with_flavors directly; this is how that path is reached from Python. +class MLMClusteringWithFlavors : public FunctionGenerator { +public: + MLMClusteringWithFlavors( + const MLMClustering& clustering, std::vector leg_flavors + ); + +private: + NamedVector build_function_impl( + FunctionBuilder& fb, const NamedVector& args + ) const override; + + MLMClustering _clustering; + std::vector _leg_flavors; +}; + } // namespace madspace diff --git a/madspace/include/madspace/phasespace/scale.hpp b/madspace/include/madspace/phasespace/scale.hpp index d59e41ee97..5b0c590c47 100644 --- a/madspace/include/madspace/phasespace/scale.hpp +++ b/madspace/include/madspace/phasespace/scale.hpp @@ -87,9 +87,25 @@ class EnergyScale : public FunctionGenerator { // The scales of the MLM clustering walked from one diagram's start state // (see MLMClustering::diagram_start_states), with the same scale range // applied as the function itself applies. + // flavor_index and leg_flavors as in MLMClustering::build_from_start_state. NamedVector build_mlm_from_start_state( - FunctionBuilder& fb, Value momenta, Value start_state + FunctionBuilder& fb, + Value momenta, + Value start_state, + Value flavor_index = Value(), + const std::vector& leg_flavors = {} ) const; + // The MLM history over every diagram, restricted to the flavours of each + // event (MLMClustering::build_with_flavors), with the scale range applied. + NamedVector build_mlm_with_flavors( + FunctionBuilder& fb, + Value momenta, + Value flavor_index, + const std::vector& leg_flavors + ) const; + bool mlm_flavor_dependent() const { + return _clustering && _clustering->has_flavor_dependent_clusterings(); + } // The band the pdf grid covers, for whoever has to keep a scale inside it. double min_scale() const { return _min_scale; } double max_scale() const { return _max_scale; } diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index 431a4b9d47..4005576baf 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -2268,6 +2268,12 @@ mlm_clustering_hadronic: - name: history_mode type: [int, single] desc: ClusteringHistory, how the start state and the history over every diagram are combined + - name: flavor_index + type: [int] + desc: sampled flavour of the event, i.e. the row of leg_flavors that applies to it + - name: leg_flavors + type: [int, single, f] + desc: abs pdg id of every external leg per flavour, row-major; a row of zeros (or a flavor_index past the table) leaves every clustering allowed outputs: - name: ren_scale type: [float] @@ -2382,6 +2388,12 @@ mlm_clustering_leptonic: - name: history_mode type: [int, single] desc: ClusteringHistory, how the start state and the history over every diagram are combined + - name: flavor_index + type: [int] + desc: sampled flavour of the event, i.e. the row of leg_flavors that applies to it + - name: leg_flavors + type: [int, single, f] + desc: abs pdg id of every external leg per flavour, row-major; a row of zeros (or a flavor_index past the table) leaves every clustering allowed outputs: - name: ren_scale type: [float] diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index e1385dae6d..b91d6cc2a8 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -54,6 +54,13 @@ constexpr int TRACE_IS_OCTET_IN = 1 << 12; constexpr int TRACE_MOTHER_IS_DAU1 = 1 << 13; constexpr int TRACE_MOTHER_IS_DAU2 = 1 << 14; constexpr int TRACE_ALL_COLORLESS = 1 << 15; +// The clustering joins two bare external fermions through a flavour-diagonal +// boson (g, a, Z, h), so it only exists when the two carry the same flavour. +// One state machine serves every flavour of a merged subprocess, and the union +// of its diagrams has such a vertex for some flavours only: for q q~ > q' q~' +// the q-q' t-channel gluon of q q~ > q q~. The flavour is a per-event input, +// see flavor_index and leg_flavors. +constexpr int TRACE_SAME_FLAVOR = 1 << 16; // jet_leg_mask carries is_jet per leg in the low half and is_octet in the high // half; n_ext_max is 12, so one word holds both. constexpr int LEG_OCTET_SHIFT = 16; @@ -284,6 +291,8 @@ KERNELSPEC int mlm_clustering_walk( IIn alphas_scheme, IIn pdf_reweighting, IIn clustering_measure, + IIn flavor_index, + IIn leg_flavors, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -350,10 +359,26 @@ KERNELSPEC int mlm_clustering_walk( masses_tmp[i] = external_masses[i]; } + // abs pdg id of each external leg for this event, zero where it is not + // known, which leaves every clustering touching that leg allowed. + int leg_flavor[N_EXT_MAX]; + for (int i = 0; i < n_part; ++i) { + int entry = flavor_index * n_part + i; + leg_flavor[i] = flavor_index >= 0 && entry < leg_flavors.size() + ? leg_flavors[entry] + : 0; + } + int win_next_state = -1, win_data = 0, win_trace = TRACE_FIRST; bool win_resonant = false; bool win_initial = false; FVal win_scale = SCALE_MAX * 10.; + // The best of the candidates the flavours of the event forbid, taken only + // when a state offers nothing else, so that the walk can always go on. + int alt_next_state = -1, alt_data = 0, alt_trace = TRACE_FIRST; + bool alt_resonant = false; + bool alt_initial = false; + FVal alt_scale = SCALE_MAX * 10.; while (cluster_count < cluster_max) { int data = state_machine[state]; int next_state = state_machine[state + 1]; @@ -418,17 +443,43 @@ KERNELSPEC int mlm_clustering_walk( // the madevent measure an initial-state clustering and a massless-massive // final-state one can tie exactly, so settle that tie the same way // whatever order the state machine lists them in. - bool beats_on_tie = clustering_measure == MEASURE_MADEVENT && - is_initial && !win_initial && - !(scale > win_scale * (1.0 + 1e-12)); - if (win_next_state == -1 || (!win_resonant && resonant) || - (win_resonant == resonant && (scale < win_scale || beats_on_tie))) { - win_next_state = next_state; - win_scale = scale; - win_data = data; - win_trace = trace_data; - win_resonant = resonant; - win_initial = is_initial; + bool forbidden = (trace_data & TRACE_SAME_FLAVOR) && + leg_flavor[particle1] != 0 && leg_flavor[particle2] != 0 && + leg_flavor[particle1] != leg_flavor[particle2]; + if (forbidden) { + bool beats_on_tie = clustering_measure == MEASURE_MADEVENT && + is_initial && !alt_initial && + !(scale > alt_scale * (1.0 + 1e-12)); + if (alt_next_state == -1 || (!alt_resonant && resonant) || + (alt_resonant == resonant && (scale < alt_scale || beats_on_tie))) { + alt_next_state = next_state; + alt_scale = scale; + alt_data = data; + alt_trace = trace_data; + alt_resonant = resonant; + alt_initial = is_initial; + } + } else { + bool beats_on_tie = clustering_measure == MEASURE_MADEVENT && + is_initial && !win_initial && + !(scale > win_scale * (1.0 + 1e-12)); + if (win_next_state == -1 || (!win_resonant && resonant) || + (win_resonant == resonant && (scale < win_scale || beats_on_tie))) { + win_next_state = next_state; + win_scale = scale; + win_data = data; + win_trace = trace_data; + win_resonant = resonant; + win_initial = is_initial; + } + } + if (is_last && win_next_state == -1) { + win_next_state = alt_next_state; + win_scale = alt_scale; + win_data = alt_data; + win_trace = alt_trace; + win_resonant = alt_resonant; + win_initial = alt_initial; } if (is_last) { int p1_win = win_data & 0xFF; @@ -511,6 +562,12 @@ KERNELSPEC int mlm_clustering_walk( win_resonant = false; win_initial = false; win_scale = SCALE_MAX * 10.; + alt_next_state = -1; + alt_data = 0; + alt_trace = TRACE_FIRST; + alt_resonant = false; + alt_initial = false; + alt_scale = SCALE_MAX * 10.; } else { state += STATE_ITEM_SIZE; } @@ -1484,6 +1541,8 @@ KERNELSPEC void mlm_clustering( IIn pdf_reweighting, IIn clustering_measure, IIn history_mode, + IIn flavor_index, + IIn leg_flavors, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -1522,6 +1581,8 @@ KERNELSPEC void mlm_clustering( alphas_scheme, pdf_reweighting, clustering_measure, + flavor_index, + leg_flavors, ren_scale, fact_scale1, fact_scale2, @@ -1579,6 +1640,8 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( IIn pdf_reweighting, IIn clustering_measure, IIn history_mode, + IIn flavor_index, + IIn leg_flavors, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -1617,6 +1680,8 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( pdf_reweighting, clustering_measure, history_mode, + flavor_index, + leg_flavors, ren_scale, fact_scale1, fact_scale2, @@ -1659,6 +1724,8 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( IIn pdf_reweighting, IIn clustering_measure, IIn history_mode, + IIn flavor_index, + IIn leg_flavors, FOut ren_scale, FOut fact_scale1, FOut fact_scale2, @@ -1697,6 +1764,8 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( pdf_reweighting, clustering_measure, history_mode, + flavor_index, + leg_flavors, ren_scale, fact_scale1, fact_scale2, diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index a5f547ec2a..abf18ff2d2 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -209,6 +209,19 @@ Integrand::Integrand( _flavor_per_subproc_remap( flavor_per_subproc_remap.begin(), flavor_per_subproc_remap.end() ) { + if (energy_scale && energy_scale->mlm_flavor_dependent() && + pid_options.size() > 1) { + for (auto& option : pid_options) { + if (option.size() != pid_options.at(0).size()) { + throw std::invalid_argument( + "every flavour option needs the same number of external legs" + ); + } + for (me_int_t pid : option) { + _mlm_leg_flavors.push_back(std::abs(pid)); + } + } + } if (pdf_grid) { for (std::size_t i = 0; i < 2; ++i) { std::set pids; @@ -648,6 +661,36 @@ NamedVector Integrand::build_channel_part( // the matrix element has given the diagram weights, in the common part. bool mlm_history_per_diagram = _energy_scale && _energy_scale->mlm_history_per_diagram(); + + // The history over every diagram above was clustered before the flavour + // was known, as the flavour prior needs its scales. Some of its + // clusterings only exist for some flavours of a merged subprocess (the + // q-q' t-channel gluon of q q~ > q q~ for a q q~ > q' q~' event), so the + // event is clustered again with its own flavours, and the densities are + // taken again at the scales that gives. The prior keeps the first ones: + // it only steers the sampling. + if (_energy_scale && _energy_scale->is_mlm() && !mlm_history_per_diagram && + !_mlm_leg_flavors.empty()) { + scales = _energy_scale->build_mlm_with_flavors( + fb, momenta_acc, flavor_id, _mlm_leg_flavors + ); + for (std::size_t i = 0; i < 2; ++i) { + if (!pdf_results.at(i)) { + continue; + } + auto& pdf_scale = _energy_scale->mlm_pdf_reweighting() + ? scales.at(std::format("pdf_scale{}", i + 1)) + : scales.at(i + 1); + auto pdf = _pdfs.at(i) + .value() + .build_function(fb, {x_acc.at(i), pdf_scale}) + .at(0); + // _mlm_leg_flavors is only filled with more than one flavour + pdf_results.at(i) = + fb.gather(fb.gather_int(flavor_id, _pdf_indices.at(i)), pdf); + } + } + if (_energy_scale && _energy_scale->is_mlm() && !mlm_history_per_diagram) { for (auto& weight : mlm_weights(fb, scales, x_acc, flavor_id)) { weights_after_cuts.push_back(weight); @@ -1046,7 +1089,11 @@ NamedVector Integrand::build_common_part( fb.mul(dxs_vec.at(1), diagram_mask) ); auto scales = _energy_scale->build_mlm_from_start_state( - fb, momenta_acc, fb.gather_int(diagram, _mlm_start_states) + fb, + momenta_acc, + fb.gather_int(diagram, _mlm_start_states), + flavor_id, + _mlm_leg_flavors ); std::array x_acc{x1_acc, x2_acc}; mlm_history_weights = mlm_weights(fb, scales, x_acc, flavor_id); diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index 094c438655..99d8bff645 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -176,8 +176,49 @@ struct CompileContext { // The absolute flavours needed beyond gluon and the two beams, in the // order the classes above index them. std::vector& absolute_pdgs; + // The propagator pdg ids each diagram puts on a line, keyed by (diagram, + // mask): what the union of mask_meta keeps only one of. + const std::map, std::vector>& line_pdgs; + // Set once any clustering is marked same_flavor. + bool& has_same_flavor; }; +// Whether a line is a fermion whose flavour a g, a, Z or h vertex preserves. +bool is_flavored_fermion_pdg(int pdg_id) { + int a = std::abs(pdg_id); + return (a >= 1 && a <= 6) || (a >= 11 && a <= 16); +} + +// Whether every diagram among diags that has the line mask puts a g, a, Z or h +// on it, so that the two bare external legs it joins must share their flavour. +// madevent's cluster.f checks the same per graph, see flavor_filter there. +bool joins_same_flavor( + const CompileContext& ctx, int mask, int mask_1, int mask_2, + const std::vector& diags +) { + if (!ctx.have_pdg_ids || std::popcount(static_cast(mask_1)) != 1 || + std::popcount(static_cast(mask_2)) != 1 || + !is_flavored_fermion_pdg(ctx.mask_meta.at(mask_1).pdg_id) || + !is_flavored_fermion_pdg(ctx.mask_meta.at(mask_2).pdg_id)) { + return false; + } + bool any = false; + for (int diag : diags) { + auto search = ctx.line_pdgs.find({diag, mask}); + if (search == ctx.line_pdgs.end()) { + return false; + } + for (int pdg_id : search->second) { + int a = std::abs(pdg_id); + if (a != 21 && a != 22 && a != 23 && a != 25) { + return false; + } + any = true; + } + } + return any; +} + // The flavour class of one line, allocating a new absolute class if it needs // one. flavor_class_none for anything the reweighting cannot follow. // @@ -247,6 +288,10 @@ struct StateItem { // this clustering. flavor_class_none ends the pdf reweighting chain, // which is also what stops madevent following ibeam(j) any further. int flavor_class_in; + // The two daughters are bare external fermions joined by a g, a, Z or h in + // every diagram left, so the clustering only exists for an event in which + // they carry the same flavour (TRACE_SAME_FLAVOR in kernels/mlm.hpp). + bool same_flavor; }; // 1-based index into the Breit-Wigner tables handed to the kernel, or 0 for @@ -267,7 +312,12 @@ int breit_wigner_index(CompileContext& ctx, const LineMeta& meta) { } StateItem make_state_item( - CompileContext& ctx, int next_state, int mask_in, int mask_1, int mask_2 + CompileContext& ctx, + int next_state, + int mask_in, + int mask_1, + int mask_2, + const std::vector& diags ) { int particle1 = std::countr_zero(static_cast(mask_1)); int particle2 = std::countr_zero(static_cast(mask_2)); @@ -327,6 +377,11 @@ StateItem make_state_item( is_jet2 = is_jet_pdg(meta_2.pdg_id, ctx.max_jet_flavor); } + bool same_flavor = joins_same_flavor(ctx, mask_in, mask_1, mask_2, diags); + if (same_flavor) { + ctx.has_same_flavor = true; + } + return { .next_state = next_state, .particle1 = particle1, @@ -352,6 +407,7 @@ StateItem make_state_item( color_2 == 1, .flavor_class_in = flavor_class(ctx, meta_in.pdg_id, is_initial ? particle1 : -1), + .same_flavor = same_flavor, }; } @@ -447,6 +503,7 @@ void find_clusterings( .mother_is_daughter2 = false, .all_colorless = false, .flavor_class_in = flavor_class_none, + .same_flavor = false, }); } } else { @@ -466,7 +523,7 @@ void find_clusterings( } states.at(prev_index) - .push_back(make_state_item(ctx, index, mask, mask_i, mask_j)); + .push_back(make_state_item(ctx, index, mask, mask_i, mask_j, new_diags)); } } } @@ -526,7 +583,8 @@ std::vector append_state_layout( (item.is_colored_in << 3) + ((item.flavor_class_in + 1) << 4) + (item.is_octet_in << 12) + (item.mother_is_daughter1 << 13) + - (item.mother_is_daughter2 << 14) + (item.all_colorless << 15) + (item.mother_is_daughter2 << 14) + (item.all_colorless << 15) + + (item.same_flavor << 16) ); } } @@ -794,6 +852,13 @@ MLMClustering::MLMClustering( } } + std::map, std::vector> line_pdgs; + for (auto& [diag_index, lines] : diagram_lines) { + for (auto& [mask, meta] : lines) { + line_pdgs[{static_cast(diag_index), mask}].push_back(meta.pdg_id); + } + } + std::vector masks; masks.reserve(n_ext); for (int i = 0; i < n_ext; ++i) { @@ -815,6 +880,8 @@ MLMClustering::MLMClustering( .beam_pdg = {have_pdg_ids ? external_pdg_ids.at(0) : 21, have_pdg_ids ? external_pdg_ids.at(1) : 21}, .absolute_pdgs = _pdf_absolute_pdgs, + .line_pdgs = line_pdgs, + .has_same_flavor = _has_same_flavor, }; std::set dead_states; find_clusterings(ctx, masks, all_diags, states, state_map, dead_states, 0); @@ -870,6 +937,8 @@ MLMClustering::MLMClustering( .beam_pdg = {have_pdg_ids ? external_pdg_ids.at(0) : 21, have_pdg_ids ? external_pdg_ids.at(1) : 21}, .absolute_pdgs = _pdf_absolute_pdgs, + .line_pdgs = line_pdgs, + .has_same_flavor = _has_same_flavor, }; nested_vector2 diagram_states; std::map diagram_state_map; @@ -918,7 +987,17 @@ NamedVector MLMClustering::build_function_impl( ) const { auto start_state = fb.full({static_cast(0), fb.batch_size({args.at(0)})}); - return build_kernel(fb, args.at(0), start_state, 0); + return build_kernel(fb, args.at(0), start_state, 0, Value(), {}); +} + +NamedVector MLMClustering::build_with_flavors( + FunctionBuilder& fb, + Value momenta, + Value flavor_index, + const std::vector& leg_flavors +) const { + auto start_state = fb.full({static_cast(0), fb.batch_size({momenta})}); + return build_kernel(fb, momenta, start_state, 0, flavor_index, leg_flavors); } NamedVector MLMClustering::build_along_diagram( @@ -936,7 +1015,11 @@ NamedVector MLMClustering::build_along_diagram( } NamedVector MLMClustering::build_from_start_state( - FunctionBuilder& fb, Value momenta, Value start_state + FunctionBuilder& fb, + Value momenta, + Value start_state, + Value flavor_index, + const std::vector& leg_flavors ) const { if (_clustering_history == ClusteringHistory::all_diagrams) { throw std::logic_error( @@ -945,15 +1028,33 @@ NamedVector MLMClustering::build_from_start_state( ); } return build_kernel( - fb, momenta, start_state, static_cast(_clustering_history) + fb, + momenta, + start_state, + static_cast(_clustering_history), + flavor_index, + leg_flavors ); } NamedVector MLMClustering::build_kernel( - FunctionBuilder& fb, Value momenta, Value start_state, me_int_t history_mode + FunctionBuilder& fb, + Value momenta, + Value start_state, + me_int_t history_mode, + Value flavor_index, + const std::vector& leg_flavors ) const { std::array mlm_out; Value random = fb.squeeze(fb.random(fb.batch_size({momenta}), 1)); + // Without the flavours of the event a single row of zeros, which leaves + // every clustering allowed: the history over the union of all diagrams. + if (!flavor_index || leg_flavors.empty()) { + flavor_index = fb.full({static_cast(0), fb.batch_size({momenta})}); + } + std::vector flavor_table = leg_flavors.empty() + ? std::vector(_external_masses.size(), 0) + : leg_flavors; if (_hadronic) { mlm_out = fb.mlm_clustering_hadronic( momenta, @@ -975,7 +1076,9 @@ NamedVector MLMClustering::build_kernel( static_cast(_alphas_scheme), static_cast(_pdf_reweighting), static_cast(_clustering_measure), - history_mode + history_mode, + flavor_index, + flavor_table ); } else { mlm_out = fb.mlm_clustering_leptonic( @@ -998,7 +1101,9 @@ NamedVector MLMClustering::build_kernel( static_cast(_alphas_scheme), static_cast(_pdf_reweighting), static_cast(_clustering_measure), - history_mode + history_mode, + flavor_index, + flavor_table ); } return {return_types().keys(), {mlm_out.begin(), mlm_out.end()}}; @@ -1021,3 +1126,24 @@ NamedVector MLMClusteringAlongDiagram::build_function_impl( ) const { return _clustering.build_along_diagram(fb, args.at(0), args.at(1)); } + +MLMClusteringWithFlavors::MLMClusteringWithFlavors( + const MLMClustering& clustering, std::vector leg_flavors +) : + FunctionGenerator( + "MLMClusteringWithFlavors", + [&] { + auto types = clustering.arg_types(); + types.push_back("flavor_index", batch_int); + return types; + }(), + clustering.return_types() + ), + _clustering(clustering), + _leg_flavors(std::move(leg_flavors)) {} + +NamedVector MLMClusteringWithFlavors::build_function_impl( + FunctionBuilder& fb, const NamedVector& args +) const { + return _clustering.build_with_flavors(fb, args.at(0), args.at(1), _leg_flavors); +} diff --git a/madspace/src/phasespace/scale.cpp b/madspace/src/phasespace/scale.cpp index c393ca2aae..a218ff8f60 100644 --- a/madspace/src/phasespace/scale.cpp +++ b/madspace/src/phasespace/scale.cpp @@ -98,10 +98,29 @@ NamedVector EnergyScale::apply_scale_range( } NamedVector EnergyScale::build_mlm_from_start_state( - FunctionBuilder& fb, Value momenta, Value start_state + FunctionBuilder& fb, + Value momenta, + Value start_state, + Value flavor_index, + const std::vector& leg_flavors ) const { return apply_scale_range( - fb, _clustering.value().build_from_start_state(fb, momenta, start_state) + fb, + _clustering.value().build_from_start_state( + fb, momenta, start_state, flavor_index, leg_flavors + ) + ); +} + +NamedVector EnergyScale::build_mlm_with_flavors( + FunctionBuilder& fb, + Value momenta, + Value flavor_index, + const std::vector& leg_flavors +) const { + return apply_scale_range( + fb, + _clustering.value().build_with_flavors(fb, momenta, flavor_index, leg_flavors) ); } diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index 44c3bdf83d..308c02f405 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -1109,6 +1109,15 @@ PYBIND11_MODULE(_madspace_py, m) { ) .def(py::init(), py::arg("clustering")); + py::classh( + m, "MLMClusteringWithFlavors" + ) + .def( + py::init>(), + py::arg("clustering"), + py::arg("leg_flavors") + ); + py::classh(m, "VegasGridOptimizer") .def( "add_data", diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 2b249e83c0..0fe2770c17 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -2163,3 +2163,79 @@ def test_the_emission_scheme_still_books_the_radiating_quark(): emission = fsr_jet_scales(jet_scale_scheme=ms.MLMClustering.JetScaleScheme.emission) np.testing.assert_array_equal(emission[:, 0], emission[:, 2]) np.testing.assert_array_equal(emission[:, 1], CM_ENERGY) + + +# -------------------------------------------------------------------------- +# clusterings that only exist for some flavours of a merged subprocess +# -------------------------------------------------------------------------- + +TRACE_SAME_FLAVOR = 1 << 16 + +# q q~ > q q~ with a merged quark, as clean_pids in launch.py represents it +QQ_TO_QQ_PDGS = [1, -1, 1, -1] +GLUON = [0.0, 0.0, 0, 0.0, 0.0, 21] + + +def qq_to_qq_diagrams(): + """The s-channel gluon (diagram 0: legs 2 and 3) and the t-channel gluon + (diagram 1: legs 0 and 2, 1 and 3) of q q~ > q q~. For a q q~ > q' q~' + flavour only the first exists, for q q~' > q q~' only the second.""" + common = { + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0], + "propagators": [GLUON], + "permutations": [[0, 1, 2, 3]], + } + return [ + dict(common, vertices=[["o0", "o1", "p0"], ["i0", "i1", "p0"]]), + dict(common, vertices=[["i0", "o0", "p0"], ["p0", "o1", "i1"]]), + ] + + +def with_flavors(clustering, leg_flavors, momenta, flavor_index): + index = np.full(len(momenta), flavor_index, dtype=np.int32) + return [ + np.asarray(v) + for v in ms.MLMClusteringWithFlavors(clustering, leg_flavors)(momenta, index) + ] + + +def test_flavour_diagonal_clusterings_of_fermions_are_marked(): + """Every clustering of q q~ > q q~ joins two quarks through a gluon, so all + of them depend on the flavours of the event.""" + clustering = make_clustering(qq_to_qq_diagrams(), external_pdg_ids=QQ_TO_QQ_PDGS) + non_terminal, _ = walk(np.asarray(clustering.cluster_state_machine), 4) + marked, unmarked = set(), set() + for _, (_, transitions) in non_terminal.items(): + for data, _, trace in transitions: + pair = (field(data, BIT_PARTICLE1), field(data, BIT_PARTICLE2)) + (marked if trace & TRACE_SAME_FLAVOR else unmarked).add(pair) + assert marked == {(0, 2), (1, 3), (2, 3)} + assert unmarked == set() + + +def test_clustering_follows_the_diagrams_the_flavours_allow(): + """u u~ > d d~ only has the s-channel gluon and u d~ > u d~ only the + t-channel one, whichever of the two the kt measure prefers; a flavour with + every leg alike, or no flavour information, keeps the union. + + For a 2 -> 2 the initial-state measure (the pt of the jet) always beats the + final-state one of the back-to-back pair, so the union always takes the + t-channel gluon: the q-q' clustering a u u~ > d d~ event does not have.""" + diagrams = qq_to_qq_diagrams() + clustering = make_clustering(diagrams, external_pdg_ids=QQ_TO_QQ_PDGS) + momenta = sample_momenta(diagrams) + leg_flavors = [1, 1, 1, 1, 2, 2, 1, 1, 2, 1, 2, 1] + union = run(clustering, momenta) + assert np.all(union[4] == 1) + + same = with_flavors(clustering, leg_flavors, momenta, 0) + s_channel = with_flavors(clustering, leg_flavors, momenta, 1) + t_channel = with_flavors(clustering, leg_flavors, momenta, 2) + unknown = with_flavors(clustering, [0, 0, 0, 0], momenta, 0) + + assert np.all(s_channel[4] == 0) + assert np.all(t_channel[4] == 1) + for other in (same, unknown): + for a, b in zip(union, other[:5]): + np.testing.assert_array_equal(a, b) From 2c3372047b41a11dd5a596b56136f24018e0089b Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sun, 25 Oct 2026 20:19:56 +0100 Subject: [PATCH 54/81] MG7AutoPtjMjjCutNamesTest: give the stand-in process is_decay apply_auto_ptj_mjj reads self.is_decay since 46b609bf30, and the SimpleNamespace standing in for the process had none: all five tests errored with an AttributeError. --- tests/unit_tests/interface/test_mg7_launch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/interface/test_mg7_launch.py b/tests/unit_tests/interface/test_mg7_launch.py index e6a77d7ec7..6e4af33ae2 100644 --- a/tests/unit_tests/interface/test_mg7_launch.py +++ b/tests/unit_tests/interface/test_mg7_launch.py @@ -1594,7 +1594,7 @@ def setUp(self): self.launch = mg7_launch def make_process(self, cuts, xqcut=20.0, auto=True): - return types.SimpleNamespace(run_card={ + return types.SimpleNamespace(is_decay=False, run_card={ 'beam': {'dynamical_scale_choice': 'mlm'}, 'phasespace': {'xqcut': xqcut, 'auto_ptj_mjj': auto}, 'multiparticles': {'jet': [21, 1, 2, -1, -2], From da647856d06d9cb982d7f4037316cd01c3e9b1b2 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sun, 25 Oct 2026 20:21:57 +0100 Subject: [PATCH 55/81] mg7 MLM: build external_pdg_ids where the clustering is built The merge with main took away the all_pids local the MLMClustering call read (main builds the cut pdg ids per observable now), so every MLM run died with a NameError before integrating. --- madgraph/iolibs/template_files/mg7/launch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index 36bc3848ae..db337ad104 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -2874,6 +2874,8 @@ def cut_pids(observable_kwargs): max_scale = self.process.max_scale if self.process.mlm_clustering: mc_data = self.build_multi_channel_data() + all_pids = (clean_pids(self.meta["incoming"]) + + clean_pids(self.meta["outgoing"])) self.scale = ms.EnergyScale( ms.MLMClustering( topologies=[topo[0] for topo in mc_data.topologies], From 7fd19fa983b19bc288d9c90ef75765092bcd2e88 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sun, 25 Oct 2026 20:40:24 +0100 Subject: [PATCH 56/81] MLM pdf reweighting: drop the event when the density divided by is under 1e-10 The veto on the denominator density was a ramp, clamp(den*1e10, 0, 1), so for 0 < den < 1e-10 the step contributed num/1e-10 * den*1e10 = num*den*1e20 instead of zeroing the event. madevent's rewgt sets rewgt = 0 outright when pdfj2 < 1e-10 (reweight.f). Make it a hard step with cut_one, keeping the floor under the division so a vetoed slot multiplies to zero rather than NaN. Test: a whole Integrand (flat test ME, flat x f = 1e-12 PDF grid) must give exactly zero for every event with an active reweighting step and a positive weight for every other one. --- madspace/src/phasespace/integrand.cpp | 17 ++---- madspace/tests/test_mlm.py | 88 +++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index 2361f5383c..94f9605464 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -947,18 +947,13 @@ ValueVec Integrand::mlm_weights( auto denominator = density("pdf_rw_q_den"); // madevent drops the event outright when the density it is // dividing by falls under 1e-10, where the grid is no longer - // saying anything. Same threshold, as a ramp rather than a - // branch, and the floor under the division keeps the term - // finite so that a vetoed slot multiplies to zero and not to - // a NaN. + // saying anything (reweight.f, rewgt=0 for pdfj2 < 1e-10). Same + // threshold, as a hard step: a ramp in the denominator would let + // 0 < den < 1e-10 through at num*den*1e20 instead of zero. The + // floor under the division keeps the term finite so that a + // vetoed slot multiplies to zero and not to a NaN. auto floor = fb.full({1e-10, batch_size_acc}); - auto usable = fb.min( - fb.max( - fb.mul(denominator, fb.full({1e10, batch_size_acc})), - fb.full({0., batch_size_acc}) - ), - ones - ); + auto usable = fb.cut_one(denominator, 1e-10, 1e300); auto ratio = fb.div(numerator, fb.max(denominator, floor)); // An inert slot contributes exactly one, whatever its density // came out as. diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 0fe2770c17..b672d00338 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -1564,6 +1564,93 @@ def test_pdf_reweighting_is_off_under_the_other_scale_scheme(t_channel_momenta): assert out["pdf_scale2"] == pytest.approx(fact2) +def write_flat_pdf_grid(path, value): + """An lhagrid1 file with x f(x, Q) = value for every parton, x and Q.""" + xs = np.geomspace(1e-7, 1.0, 30) + qs = np.geomspace(1.0, 1e5, 20) + pids = [-5, -4, -3, -2, -1, 1, 2, 3, 4, 5, 21] + with open(path, "w") as f: + f.write("PdfType: central\nFormat: lhagrid1\n---\n") + f.write(" ".join(f"{x:.12e}" for x in xs) + "\n") + f.write(" ".join(f"{q:.12e}" for q in qs) + "\n") + f.write(" ".join(str(pid) for pid in pids) + "\n") + row = " ".join(f"{value:.6e}" for _ in pids) + "\n" + f.write(row * (len(xs) * len(qs))) + f.write("---\n") + + +def t_channel_integrand_weights(tmp_path, pdf_value, batch_size=4000): + """Weights and momenta of the t-channel process run through a whole + Integrand under MLM pdf reweighting, with the built-in flat test matrix + element and a flat PDF of the given value.""" + context = ms.Context(device=ms.cpu_device(), thread_count=1) + alphas_file = tmp_path / "alphas.info" + alphas_file.write_text( + "AlphaS_Qs: [1.0, 10.0, 100.0, 1000.0, 100000.0]\n" + "AlphaS_Vals: [0.3, 0.2, 0.12, 0.1, 0.08]\n" + ) + alphas_grid = ms.AlphaSGrid(str(alphas_file)) + alphas_grid.initialize_globals(context) + pdf_file = tmp_path / "flat.dat" + write_flat_pdf_grid(pdf_file, pdf_value) + pdf_grid = ms.PdfGrid(str(pdf_file)) + pdf_grid.initialize_globals(context) + + diagrams = t_channel_diagram() + mapping = ms.PhaseSpaceMapping( + make_topologies(diagrams)[0], + CM_ENERGY, + permutations=diagrams[0]["permutations"], + ) + # 0xBADCAFE: the built-in flat test matrix element, no library needed + matrix_element = ms.MatrixElement( + 0xBADCAFE, + len(T_CHANNEL_PDGS), + ms.Integrand.matrix_element_inputs, + ms.Integrand.matrix_element_outputs, + 1, + True, + ) + diff_xs = ms.DifferentialCrossSection( + matrix_element=matrix_element, + cm_energy=CM_ENERGY, + running_coupling=None, + energy_scale=ms.CachedScale(), + pdf1=ms.CachedPdf(), + pdf2=ms.CachedPdf(), + ) + integrand = ms.Integrand( + mapping, + [diff_xs], + pid_options=[T_CHANNEL_PDGS], + pdf_grid=pdf_grid, + running_coupling=ms.RunningCoupling(alphas_grid), + energy_scale=ms.EnergyScale(t_channel_clustering(pdf_reweighting=True)), + channel_indices=[0], + ) + out = ms.FunctionRuntime(integrand.function(), context)( + np.array([batch_size], dtype=np.int64) + ) + return np.asarray(out[0]), np.asarray(out[1]) + + +def test_a_density_too_small_to_divide_by_drops_the_event(tmp_path): + """reweight.f sets rewgt = 0 when the density it would divide by is under + 1e-10. With x f = 1e-12 everywhere every step of the ladder is such a + division, so every event with a step on it must come out at zero. + + A ramp in the denominator used to let these through at num den 1e20 - a + hundredth of the weight per step here, not zero.""" + weights, momenta = t_channel_integrand_weights(tmp_path, 1e-12) + out = pdf_outputs(t_channel_clustering(pdf_reweighting=True), momenta) + stepped = (out["pdf_rw_active"] == 1.0).any(axis=1) + assert stepped.any() and not stepped.all(), "both kinds needed" + assert np.all(weights[stepped] == 0.0) + # The other vetoes (alpha_s and the 2 GeV scale floor) still drop the odd + # event of the rest, so only most of those survive. + assert np.mean(weights[~stepped] > 0.0) > 0.9 + + # -------------------------------------------------------------------------- # the merging cut must not fire at a vertex that produced no radiation # -------------------------------------------------------------------------- @@ -2239,3 +2326,4 @@ def test_clustering_follows_the_diagrams_the_flavours_allow(): for other in (same, unknown): for a, b in zip(union, other[:5]): np.testing.assert_array_equal(a, b) + From 9c0f114056ec60e97bfd9bd787d10dcc4028d796 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sun, 25 Oct 2026 20:40:24 +0100 Subject: [PATCH 57/81] MLM clustering: support 2 -> 1 processes (0-jet sample of p p > z + jets) With three external legs there is nothing to cluster, so the compiler found no transition from the start state and threw "MLM clustering found no valid clustering for this process" - every merged p p > z @0 / p p > z j @1 run died on its 0-jet sample. Start such a process at a terminal state (all diagrams, none of them clustered; also per diagram for the per-diagram histories), and in the kernel take the scales from the outgoing line's invariant mass when the walk has no steps, as cluster.f's "special 2 -> 1 case" (pt2ijcl = pcl(4, imocl)) and setclscales do. Under the madevent scale scheme the existing root step already gives the same: mT of the z, which is its mass since it has no pT. The z is no jet, so its LHE scale is sqrt(s) and the xqcut weight is one. --- madspace/src/kernels/mlm.hpp | 10 ++- madspace/src/phasespace/mlm_clustering.cpp | 86 +++++++++++++--------- madspace/tests/test_mlm.py | 56 ++++++++++++++ 3 files changed, 118 insertions(+), 34 deletions(-) diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index b91d6cc2a8..c681d0071e 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -741,7 +741,15 @@ KERNELSPEC int mlm_clustering_walk( } } - ren_scale_val = pow(ren_scale_val, 1.0 / cluster_max); + if (cluster_max == 0) { + // A 2 -> 1 process has no clustering at all. cluster.f's special + // 2 -> 1 case takes pt2ijcl = pcl(4, imocl), the invariant mass squared + // of the outgoing line, and setclscales sets every scale from it. + FourMom p_out{momenta[2][0], momenta[2][1], momenta[2][2], momenta[2][3]}; + ren_scale_val = sqrt(max(lsquare(p_out), 0.0)); + } else { + ren_scale_val = pow(ren_scale_val, 1.0 / cluster_max); + } if (fac_scale > ren_scale_val) { fac_scale = ren_scale_val; } diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index 99d8bff645..4a5f5dde75 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -411,6 +411,36 @@ StateItem make_state_item( }; } +// A terminal state: nothing left to cluster, only the diagrams the history +// can have come from, one of which the walk picks at random. +void append_terminal_items( + std::vector& state, const std::vector& diags +) { + for (int diag_index : diags) { + state.push_back({ + .next_state = diag_index, + .particle1 = 0, + .particle2 = 0, + .mass_index = 0, + .massive_in = false, + .massive_out1 = false, + .massive_out2 = false, + .is_qcd = false, + .is_jet1 = false, + .is_jet2 = false, + .trace_mode = trace_first, + .is_jet_in = false, + .is_colored_in = false, + .is_octet_in = false, + .mother_is_daughter1 = false, + .mother_is_daughter2 = false, + .all_colorless = false, + .flavor_class_in = flavor_class_none, + .same_flavor = false, + }); + } +} + void find_clusterings( CompileContext& ctx, const std::vector& particle_masks, @@ -483,29 +513,7 @@ void find_clusterings( // per path leading to it. if (is_new_state) { if (is_terminal) { - for (int diag_index : new_diags) { - states.at(index).push_back({ - .next_state = diag_index, - .particle1 = 0, - .particle2 = 0, - .mass_index = 0, - .massive_in = false, - .massive_out1 = false, - .massive_out2 = false, - .is_qcd = false, - .is_jet1 = false, - .is_jet2 = false, - .trace_mode = trace_first, - .is_jet_in = false, - .is_colored_in = false, - .is_octet_in = false, - .mother_is_daughter1 = false, - .mother_is_daughter2 = false, - .all_colorless = false, - .flavor_class_in = flavor_class_none, - .same_flavor = false, - }); - } + append_terminal_items(states.at(index), new_diags); } else { find_clusterings( ctx, new_masks, new_diags, states, state_map, @@ -884,7 +892,15 @@ MLMClustering::MLMClustering( .has_same_flavor = _has_same_flavor, }; std::set dead_states; - find_clusterings(ctx, masks, all_diags, states, state_map, dead_states, 0); + if (n_ext == 3) { + // A 2 -> 1 process (the 0-jet sample of p p > z + jets) has nothing to + // cluster: the walk starts at a terminal state, and the kernel takes + // the scales off the single outgoing line, as cluster.f's "special + // 2 -> 1 case" does. + append_terminal_items(states.at(0), all_diags); + } else { + find_clusterings(ctx, masks, all_diags, states, state_map, dead_states, 0); + } if (states.at(0).size() == 0) { throw std::logic_error( @@ -959,15 +975,19 @@ MLMClustering::MLMClustering( int root = static_cast(diagram_states.size()); diagram_states.push_back({}); diagram_state_map[{masks, {diag}}] = root; - find_clusterings( - diagram_ctx, - masks, - {diag}, - diagram_states, - diagram_state_map, - diagram_dead_states, - root - ); + if (n_ext == 3) { + append_terminal_items(diagram_states.at(root), {diag}); + } else { + find_clusterings( + diagram_ctx, + masks, + {diag}, + diagram_states, + diagram_state_map, + diagram_dead_states, + root + ); + } if (diagram_states.at(root).size() > 0) { diagram_roots.push_back({diag_index, root}); } diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index b672d00338..f103588d88 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -2327,3 +2327,59 @@ def test_clustering_follows_the_diagrams_the_flavours_allow(): for a, b in zip(union, other[:5]): np.testing.assert_array_equal(a, b) + +# -------------------------------------------------------------------------- +# 2 -> 1: the 0-jet sample of p p > z + jets +# -------------------------------------------------------------------------- + + +def drell_yan_two_to_one_clustering(**kwargs): + """u u~ > z: nothing to cluster. cluster.f handles it as its "special + 2 -> 1 case", pt2ijcl = the invariant mass squared of the z, and + setclscales sets both scales from that.""" + kwargs.setdefault("cm_energy", CM_ENERGY) + kwargs.setdefault("external_pdg_ids", [2, -2, 23]) + return ms.MLMClustering( + [ms.Topology(ms.Diagram([0.0, 0.0], [M_Z], [], [["i0", "i1", "o0"]]))], + [[[0, 1, 2]]], + [[0]], + **kwargs, + ) + + +def two_to_one_momenta(batch_size=BATCH_SIZE, seed=SEED): + """Unequal beam energies, so that the event is not at rest in the lab.""" + rng = np.random.default_rng(seed) + e1 = rng.uniform(20.0, 500.0, batch_size) + e2 = rng.uniform(20.0, 500.0, batch_size) + p = np.zeros((batch_size, 3, 4)) + p[:, 0] = np.stack([e1, 0 * e1, 0 * e1, e1], axis=1) + p[:, 1] = np.stack([e2, 0 * e2, 0 * e2, -e2], axis=1) + p[:, 2] = p[:, 0] + p[:, 1] + return p, np.sqrt(4.0 * e1 * e2) + + +@pytest.mark.parametrize( + "scale_scheme", + [ms.MLMClustering.ScaleScheme.clustering_mean, ms.MLMClustering.ScaleScheme.madevent], +) +@pytest.mark.parametrize( + "history", + list(ms.MLMClustering.ClusteringHistory.__members__.values()), +) +def test_a_two_to_one_process_takes_its_scales_from_shat(scale_scheme, history): + """Used to throw "MLM clustering found no valid clustering for this + process", which killed the 0-jet sample of every p p > z @0 + jets run.""" + clustering = drell_yan_two_to_one_clustering( + scale_scheme=scale_scheme, clustering_history=history, xqcut=20.0 + ) + momenta, shat_root = two_to_one_momenta() + ren, fact1, fact2, outgoing, diagram, xqcut_weight = run_all(clustering, momenta) + np.testing.assert_allclose(ren, shat_root, rtol=1e-12) + np.testing.assert_allclose(fact1, shat_root, rtol=1e-12) + np.testing.assert_allclose(fact2, shat_root, rtol=1e-12) + # the z is no jet: written at the collider energy, where no veto trips + assert np.all(outgoing == CM_ENERGY) + assert np.all(diagram == 0) + # nothing was emitted, so nothing can fail the merging cut + assert np.all(xqcut_weight == 1.0) From 977529fa20048e47ddd3b61a86d82a4b21b35a5a Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Sun, 25 Oct 2026 20:44:40 +0100 Subject: [PATCH 58/81] madspace MLM: drop dead PDF-ratio work and the repeated madevent walk Bit-identical speed-ups of the MLM clustering consumers: - EnergyScale::mlm_pdf_reweighting() is now true only under ScaleScheme::madevent. Every other scheme leaves all reweighting slots inert (pdf_scale == fact_scale), so the Integrand evaluated 2(n-1) PDF interpolations per event only to multiply the weight by exactly 1. MLMClustering::pdf_reweighting() keeps the constructor value, so the kernel input and the EnergyScale clamp of pdf_scale1/2 are unchanged. - Integrand::mlm_weights skips slot 0: the first clustering only records where a beam line's density sits and never carries a ratio. - clustering_history = madevent kept the channel walk by walking it a third time; keep its outputs aside and put them back instead. --- .../madspace/phasespace/mlm_clustering.hpp | 1 + .../include/madspace/phasespace/scale.hpp | 7 ++- madspace/src/kernels/mlm.hpp | 52 ++++++++++++++++++- madspace/src/phasespace/integrand.cpp | 5 +- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp index e597d6c568..a97b014536 100644 --- a/madspace/include/madspace/phasespace/mlm_clustering.hpp +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -182,6 +182,7 @@ class MLMClustering : public FunctionGenerator { const std::vector& bw_widths() const { return _bw_widths; } AlphasScheme alphas_scheme() const { return _alphas_scheme; } bool pdf_reweighting() const { return _pdf_reweighting; } + ScaleScheme scale_scheme() const { return _scale_scheme; } ClusteringMeasure clustering_measure() const { return _clustering_measure; } ClusteringHistory clustering_history() const { return _clustering_history; } // Offset of each diagram's own state machine in cluster_state_machine, diff --git a/madspace/include/madspace/phasespace/scale.hpp b/madspace/include/madspace/phasespace/scale.hpp index dbc478218b..83df51b135 100644 --- a/madspace/include/madspace/phasespace/scale.hpp +++ b/madspace/include/madspace/phasespace/scale.hpp @@ -162,8 +162,13 @@ class EnergyScale : public FunctionGenerator { return _clustering && _clustering->alphas_scheme() != AlphasScheme::none; } + // Whether the consumer has pdf ratios to apply. Only ScaleScheme::madevent + // has a clustering ladder to walk: under any other scheme the kernel leaves + // every reweighting slot inert and pdf_scale equal to fact_scale, so the + // ratios would all come out as exactly one. bool mlm_pdf_reweighting() const { - return _clustering && _clustering->pdf_reweighting(); + return _clustering && _clustering->pdf_reweighting() && + _clustering->scale_scheme() == ScaleScheme::madevent; } const std::vector& mlm_pdf_absolute_pdgs() const { return _clustering.value().pdf_absolute_pdgs(); diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index b91d6cc2a8..c66567101f 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -1608,10 +1608,58 @@ KERNELSPEC void mlm_clustering( // setclscales clusters over every graph first and keeps that history // unless its jet count differs from the one stored for the channel, // which was itself clustered along the channel's graph. Every walk - // writes all outputs, so the one that ran last is the one that stands. + // writes all outputs, so the one that ran last is the one that stands: + // the channel's walk is kept aside and put back when it wins, rather + // than walked a second time. int diagram_jets = walk(start); + int n_part = momenta.size(); + FVal kept_ren = ren_scale, kept_fact1 = fact_scale1, + kept_fact2 = fact_scale2, kept_xqcut = xqcut_weight, + kept_alphas = alphas_weight, kept_pdf1 = pdf_scale1, + kept_pdf2 = pdf_scale2; + IVal kept_diagram = diagram_index; + FVal kept_outgoing[N_EXT_MAX], kept_alphas_scales[N_EXT_MAX]; + IVal kept_rw_flavor[N_EXT_MAX]; + FVal kept_rw_x[N_EXT_MAX], kept_rw_q_num[N_EXT_MAX], + kept_rw_q_den[N_EXT_MAX], kept_rw_active[N_EXT_MAX], + kept_rw_beam[N_EXT_MAX]; + for (int i = 0; i < n_part - 2; ++i) { + kept_outgoing[i] = outgoing_scales[i]; + } + for (int i = 0; i < n_part - 3; ++i) { + kept_alphas_scales[i] = alphas_scales[i]; + } + for (int i = 0; i < n_part - 1; ++i) { + kept_rw_flavor[i] = pdf_rw_flavor[i]; + kept_rw_x[i] = pdf_rw_x[i]; + kept_rw_q_num[i] = pdf_rw_q_num[i]; + kept_rw_q_den[i] = pdf_rw_q_den[i]; + kept_rw_active[i] = pdf_rw_active[i]; + kept_rw_beam[i] = pdf_rw_beam[i]; + } if (walk(0) != diagram_jets) { - walk(start); + ren_scale = kept_ren; + fact_scale1 = kept_fact1; + fact_scale2 = kept_fact2; + xqcut_weight = kept_xqcut; + alphas_weight = kept_alphas; + pdf_scale1 = kept_pdf1; + pdf_scale2 = kept_pdf2; + diagram_index = kept_diagram; + for (int i = 0; i < n_part - 2; ++i) { + outgoing_scales[i] = kept_outgoing[i]; + } + for (int i = 0; i < n_part - 3; ++i) { + alphas_scales[i] = kept_alphas_scales[i]; + } + for (int i = 0; i < n_part - 1; ++i) { + pdf_rw_flavor[i] = kept_rw_flavor[i]; + pdf_rw_x[i] = kept_rw_x[i]; + pdf_rw_q_num[i] = kept_rw_q_num[i]; + pdf_rw_q_den[i] = kept_rw_q_den[i]; + pdf_rw_active[i] = kept_rw_active[i]; + pdf_rw_beam[i] = kept_rw_beam[i]; + } } } else { walk(start); diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index 2361f5383c..719e6b6721 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -907,7 +907,10 @@ ValueVec Integrand::mlm_weights( : 1e30; auto ones = fb.full({1., batch_size_acc}); Value factor, veto; - for (std::size_t i = 0; i < slot_count; ++i) { + // Slot 0 is the first clustering of the history, which can only ever + // record where a beam line's density already sits, so it never + // carries a ratio. + for (std::size_t i = 1; i < slot_count; ++i) { std::vector column{static_cast(i)}; auto pick = [&](const char* name) { return fb.squeeze(fb.select(scales.at(name), column)); From 0a80c1b46d3ce9249c0d9227a28d8c5e6289ff6e Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Mon, 26 Oct 2026 08:06:32 +0100 Subject: [PATCH 59/81] MLM: beam.jet_radius defaults to 1.0, madevent's run_card d The final-state kt measure divides by D^2. madevent's hidden run_card d (common/to_dj/D, kin_functions.f) and FxFx both use 1.0, Pythia's JetMatching:coneRadius defaults to 1.0, and the paper's MG7-vs-madevent MLM validation set jet_radius = 1.0 explicitly. The 0.4 default made the measure 2.5x madevent's and the xqcut veto weaker for final-state splittings. Changed in the run card, the C++ constructor and the Python binding. Tests: the madspace default matches jet_radius=1.0 and differs from 0.4 on g g > t t~ g g g; the TOML card's default equals RunCardLO's d. --- madgraph/various/banner.py | 6 +++-- .../madspace/phasespace/mlm_clustering.hpp | 3 ++- madspace/src/python/madspace.cpp | 2 +- madspace/tests/test_mlm.py | 22 ++++++++++++++++++- tests/unit_tests/various/test_banner.py | 12 ++++++++++ 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index 5814e5fce5..6a9d8d0edb 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6822,8 +6822,10 @@ def default_setup(self): 'half_transverse_mass', 'partonic_energy', 'mlm'], comment="dynamical scale used unless the scale is fixed") # Jet radius entering the kt clustering measure of the MLM back-clustering - # (only used when dynamical_scale_choice = "mlm"). - self.add_toml_param('beam', 'jet_radius', 0.4, + # (only used when dynamical_scale_choice = "mlm"). 1.0 is madevent's + # hidden run_card d (common/to_dj/D) and FxFx's value, and Pythia's + # JetMatching:coneRadius default. + self.add_toml_param('beam', 'jet_radius', 1.0, comment="jet radius of the MLM clustering measure (dynamical_scale_choice = mlm)") # Heaviest quark flavour treated as a jet by the MLM back-clustering # (the counterpart of maxjetflavor in the legacy run_card). diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp index e597d6c568..56af9c9352 100644 --- a/madspace/include/madspace/phasespace/mlm_clustering.hpp +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -123,7 +123,8 @@ class MLMClustering : public FunctionGenerator { // Zero disables it. double xqcut = 0, double bw_cutoff = 15, - double jet_radius = 0.4, + // D of the final-state kt measure; 1 as madevent's run_card d and FxFx. + double jet_radius = 1.0, bool hadronic = true, // pdg ids of the external particles, in leg order. When empty, every // clustering is assumed to be a QCD splitting between jets. diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index a57e654ced..fafa0ef2f0 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -1934,7 +1934,7 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("pdg_color_types") = std::unordered_map{}, py::arg("xqcut") = 0., py::arg("bw_cutoff") = 15., - py::arg("jet_radius") = 0.4, + py::arg("jet_radius") = 1.0, py::arg("hadronic") = true, py::arg("external_pdg_ids") = std::vector{}, py::arg("max_jet_flavor") = 4, diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index f103588d88..42719c921e 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -681,11 +681,31 @@ def test_single_clustering_scale_matches_the_measures(): masses = [0.0] * 4 for event, scale in zip(momenta, ren): - candidates = first_step_candidates(clustering, 4, event, 0.4, masses) + candidates = first_step_candidates(clustering, 4, event, 1.0, masses) assert candidates, "no clustering offered from the initial state" assert scale == pytest.approx(min(candidates.values()), rel=1e-9) +def test_the_default_jet_radius_is_madevents(): + """madevent's hidden run_card d (common/to_dj/D in kin_functions.f) and + FxFx both put 1 in the final-state kt measure. With 0.4 that measure came + out 2.5 times larger, and the merging cut weaker for final-state + splittings, than in the madevent samples it is compared against.""" + diagrams = load_diagrams(PROCESSES["ttggg"][0]) + pdg_ids = PROCESSES["ttggg"][1] + momenta = sample_momenta(diagrams) + default = run(make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta) + one = run( + make_clustering(diagrams, external_pdg_ids=pdg_ids, jet_radius=1.0), momenta + ) + old = run( + make_clustering(diagrams, external_pdg_ids=pdg_ids, jet_radius=0.4), momenta + ) + for a, b in zip(default, one): + np.testing.assert_array_equal(a, b) + assert not np.array_equal(default[3], old[3]) + + def test_kt_measure_matches_the_fortran(): """The first clustering step of g g > g g g, where the (o0, o1) final-state pair competes with the initial-state candidates and is scored by the kt diff --git a/tests/unit_tests/various/test_banner.py b/tests/unit_tests/various/test_banner.py index e1c8af3793..b70f16976e 100755 --- a/tests/unit_tests/various/test_banner.py +++ b/tests/unit_tests/various/test_banner.py @@ -1592,6 +1592,18 @@ def test_runcard_dispatches_to_mg7_for_toml(self): finally: os.remove(tmp.name) + def test_mlm_jet_radius_defaults_to_madevents_d(self): + """beam.jet_radius is madevent's hidden run_card d (common/to_dj/D), + which is 1 there and in FxFx, also as written to the TOML card""" + import tomllib + legacy = bannermod.RunCardLO() + self.assertEqual(legacy['d'], 1.0) + rc = bannermod.RunCardMG7() + self.assertEqual(rc['beam']['jet_radius'], legacy['d']) + out = io.StringIO() + rc.write(out, template=self.template) + self.assertEqual(tomllib.loads(out.getvalue())['beam']['jet_radius'], 1.0) + def test_numeric_suffix_like_legacy(self): """integer/float k/M suffixes are parsed as in the legacy RunCard""" rc = bannermod.RunCardMG7() From e080c807e66abe88349eca706fa424b87f0d84ec Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Mon, 26 Oct 2026 15:06:51 +0100 Subject: [PATCH 60/81] MLM pdf reweighting: no ladder-scale PDF product for the systematics The systematics divide each PDF variation, evaluated at (k times) the factorisation scale, by the event's partial_weight_product. Under MLM pdf reweighting (scale_scheme = madevent) that product is of the densities at pdf_scale, low on the clustering ladder, so on every event with a clustering step a PDF member or muF variation was off by f(x, muF) / f(x, pdf_scale): p p > z + 0,1j, NNPDF40 member 1 averaged 1.066 x nominal on the 1-jet events against 0.974 on the 0-jet ones. The integrand now leaves partial_weight_product out under pdf reweighting, and the systematics take the nominal densities at the factorisation scale themselves (their existing path for events without it). Member 1 then averages 0.998 on the 1-jet events. A variation is the ratio of the leading densities at muF; the ladder ratios and the per-vertex alpha_s stay at their nominal value. --- .../include/madspace/phasespace/integrand.hpp | 3 ++ madspace/src/phasespace/integrand.cpp | 19 +++++++++- madspace/tests/test_mlm.py | 38 ++++++++++++++++--- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/madspace/include/madspace/phasespace/integrand.hpp b/madspace/include/madspace/phasespace/integrand.hpp index 319943251e..9f717e9e9c 100644 --- a/madspace/include/madspace/phasespace/integrand.hpp +++ b/madspace/include/madspace/phasespace/integrand.hpp @@ -118,6 +118,9 @@ class Integrand : public FunctionGenerator { * rescale the weight accordingly. * @param partial_weights If true, emit the per-factor * weights for buffered unweighting. + * Ignored under MLM pdf + * reweighting, whose densities are + * not at the factorisation scale. * @param channel_indices Global indices of this * integrand's channels. * @param active_flavors Flavour indices this integrand diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index 1ad5c2329b..9820127d5c 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -31,6 +31,21 @@ std::size_t final_channel_count( } } +// Whether the event carries the product of its beam densities for the +// systematics to divide the PDF variations by. Not under MLM pdf reweighting: +// there the densities are taken low on the clustering ladder (pdf_scale) and +// walked back up to the factorisation scale by the ladder ratios, so the +// variations, which are evaluated at (k times) the factorisation scale, would +// be divided by densities at another scale, and even the central member would +// differ from one. Without it the systematics take the nominal densities at +// the factorisation scale themselves, and a variation becomes the ratio of +// the leading densities there, the ladder ratios left at their nominal value. +bool emits_partial_weights( + bool partial_weights, const std::optional& energy_scale +) { + return partial_weights && !(energy_scale && energy_scale->mlm_pdf_reweighting()); +} + } // namespace static const BatchSize acc_batch_size("acc_batch_size"); @@ -152,7 +167,7 @@ Integrand::Integrand( ret_types.push_back("x2", batch_float); ret_types.push_back("fact_scale2", batch_float); } - if (partial_weights && + if (emits_partial_weights(partial_weights, energy_scale) && (diff_xs_first.has_pdf(0) || diff_xs_first.has_pdf(1))) { ret_types.push_back("partial_weight_product", batch_float); } @@ -197,7 +212,7 @@ Integrand::Integrand( _compressed_channel_weight_count(compressed_channel_weight_count), _madnis_training(madnis_training), _drop_cuts_and_rescale(drop_cuts_and_rescale), - _partial_weights(partial_weights), + _partial_weights(emits_partial_weights(partial_weights, energy_scale)), _channel_indices(channel_indices.begin(), channel_indices.end()), _random_dim( mapping.random_dim() + // phasespace diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 42719c921e..55d151f3ac 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -1599,9 +1599,11 @@ def write_flat_pdf_grid(path, value): f.write("---\n") -def t_channel_integrand_weights(tmp_path, pdf_value, batch_size=4000): - """Weights and momenta of the t-channel process run through a whole - Integrand under MLM pdf reweighting, with the built-in flat test matrix +def t_channel_integrand_outputs( + tmp_path, pdf_value, batch_size=4000, pdf_reweighting=True, partial_weights=False +): + """Every output of the t-channel process run through a whole Integrand + under MLM (pdf reweighting by default), with the built-in flat test matrix element and a flat PDF of the given value.""" context = ms.Context(device=ms.cpu_device(), thread_count=1) alphas_file = tmp_path / "alphas.info" @@ -1645,12 +1647,20 @@ def t_channel_integrand_weights(tmp_path, pdf_value, batch_size=4000): pid_options=[T_CHANNEL_PDGS], pdf_grid=pdf_grid, running_coupling=ms.RunningCoupling(alphas_grid), - energy_scale=ms.EnergyScale(t_channel_clustering(pdf_reweighting=True)), + energy_scale=ms.EnergyScale( + t_channel_clustering(pdf_reweighting=pdf_reweighting) + ), channel_indices=[0], + partial_weights=partial_weights, ) - out = ms.FunctionRuntime(integrand.function(), context)( + return ms.FunctionRuntime(integrand.function(), context)( np.array([batch_size], dtype=np.int64) ) + + +def t_channel_integrand_weights(tmp_path, pdf_value, batch_size=4000): + """Weights and momenta of t_channel_integrand_outputs.""" + out = t_channel_integrand_outputs(tmp_path, pdf_value, batch_size) return np.asarray(out[0]), np.asarray(out[1]) @@ -1671,6 +1681,24 @@ def test_a_density_too_small_to_divide_by_drops_the_event(tmp_path): assert np.mean(weights[~stepped] > 0.0) > 0.9 +def test_no_pdf_product_for_the_systematics_under_pdf_reweighting(tmp_path): + """The systematics divide each PDF variation, taken at (k times) the + factorisation scale, by the event's partial_weight_product. Under pdf + reweighting that product is of the densities at pdf_scale, low on the + clustering ladder, so even the central member came out != 1. The + integrand leaves it out there, and the systematics take the nominal + densities at the factorisation scale themselves.""" + def output_count(**kwargs): + return len(t_channel_integrand_outputs(tmp_path, 1.0, 16, **kwargs)) + + # the product is there without pdf reweighting ... + assert (output_count(pdf_reweighting=False, partial_weights=True) + == output_count(pdf_reweighting=False) + 1) + # ... and left out with it + assert (output_count(pdf_reweighting=True, partial_weights=True) + == output_count(pdf_reweighting=True)) + + # -------------------------------------------------------------------------- # the merging cut must not fire at a vertex that produced no radiation # -------------------------------------------------------------------------- From d256e3935486f4a3fc14f84cd1e57e0008781003 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Mon, 26 Oct 2026 15:07:00 +0100 Subject: [PATCH 61/81] MLM pdf reweighting: a NaN density divided by drops the event too cut_one passes a NaN observable (both comparisons are false), and max(NaN, 1e-10) is the floor, so an active ladder step with a NaN denominator multiplied the weight by num * 1e10. Take max(den, 0) first: every backend's max returns its second argument for a NaN first one (the comparison on CPU/GPU, maxpd on x86), so a NaN becomes 0 and is vetoed like any density under 1e-10. --- madspace/src/phasespace/integrand.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index 9820127d5c..19936af05b 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -971,7 +971,11 @@ ValueVec Integrand::mlm_weights( // floor under the division keeps the term finite so that a // vetoed slot multiplies to zero and not to a NaN. auto floor = fb.full({1e-10, batch_size_acc}); - auto usable = fb.cut_one(denominator, 1e-10, 1e300); + // max(den, 0) is 0 for a NaN density (the comparison max is built + // on picks the second argument), which cut_one alone would pass + auto usable = fb.cut_one( + fb.max(denominator, fb.full({0., batch_size_acc})), 1e-10, 1e300 + ); auto ratio = fb.div(numerator, fb.max(denominator, floor)); // An inert slot contributes exactly one, whatever its density // came out as. From d0e33885833876f128e2193632b52ee07ed1578d Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Mon, 26 Oct 2026 15:07:00 +0100 Subject: [PATCH 62/81] mg7 MLM: hand the merging settings to the post-processing tools RunCardMG7's legacy keys still said "mg7 does no MLM merging": ickkw = 0, xqcut = 0, maxjetflavor = 5 whatever the card. The madevent shower interface the mg7 run reuses picks MLM jet matching from ickkw, so an mg7 MLM sample was showered by Pythia8 with no matching at all, double counting the jet multiplicities. - A merged sample (dynamical_scale_choice = "mlm", xqcut > 0, hadronic beams; RunCardMG7.is_mlm_merged) reports ickkw = 1, its xqcut, and beam.max_jet_flavor as maxjetflavor (JetMatching:nQmatch). Unmerged runs keep the old values. - The mg7 exporter writes max_n_matched_jets to proc_characteristics (JetMatching:nJetMax), counted as export_v4 does; it was always 0. - beam.max_jet_flavor follows the model's flavour scheme, as madevent's maxjetflavor does (5 in a 5-flavour model), and "set maxjetflavor N" sets it too. - Converting a madevent run_card.dat translates ickkw = 1 with xqcut to the "mlm" scale choice under scale_scheme "madevent", with the same xqcut and auto_ptj_mjj, instead of dropping them. p p > z + 0,1j, xqcut = 20, shower = Pythia8: the PY8 card now has JetMatching:merge = on, qCut = 30, nQmatch = 4, nJetMax = 1, and 1480/2000 events survive the matching (30.8 nb of 41.7 nb). --- madgraph/iolibs/export_cpp.py | 27 ++++++++- madgraph/various/banner.py | 77 ++++++++++++++++++++++--- tests/unit_tests/various/test_banner.py | 55 ++++++++++++++++++ 3 files changed, 148 insertions(+), 11 deletions(-) diff --git a/madgraph/iolibs/export_cpp.py b/madgraph/iolibs/export_cpp.py index e70cbfe98c..3994d9be5a 100755 --- a/madgraph/iolibs/export_cpp.py +++ b/madgraph/iolibs/export_cpp.py @@ -3645,11 +3645,34 @@ def create_proc_characteristics(self, matrix_elements): # Never let this break the output: on any surprise just fall back to # -1 (systematics then simply cannot reconstruct the reweighting). try: - for diagram in me.get('diagrams'): - qcd_orders.add(diagram.calculate_orders().get('QCD', 0)) + me_qcd_orders = [diagram.calculate_orders().get('QCD', 0) + for diagram in me.get('diagrams')] + qcd_orders.update(me_qcd_orders) except Exception as error: logger.debug('could not determine the QCD order: %s', error) qcd_orders.add(None) + me_qcd_orders = [] + # the number of jets an MLM matching has to match in the shower + # (Pythia8's JetMatching:nJetMax), as export_v4.write_auto_dsig_file + # counts it: light coloured final-state partons, at most the QCD + # order of the amplitude + try: + model = me.get('processes')[0].get('model') + + def is_light_parton(pid): + particle = model.get_particle(pid) + return (particle is not None + and particle.get('mass') == 'ZERO' + and particle.get('color') > 1) + + n_partons = max( + len([1 for pid in proc.get_final_ids() if is_light_parton(pid)]) + for proc in me.get('processes')) + pc['max_n_matched_jets'] = max( + pc['max_n_matched_jets'], + min(max(me_qcd_orders, default=0), n_partons)) + except Exception as error: + logger.debug('could not count the matched jets: %s', error) # a single value of alpha_s (uniform QCD power) lets systematics # reconstruct the LO reweighting info without an block diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index 6a9d8d0edb..6bba17abd3 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -7232,8 +7232,9 @@ def __setitem__(self, name, value, *args, **opts): 'ievo_eva', 'evaorder', 'event_norm', 'dynamical_scale_choice', 'nevents', # keys read by the reused madevent post-processing tools (shower, ...). - # mg7 does no MLM/CKKW merging, so these all take their "no merging" - # value, which keeps the tools on the plain (unmatched) code path. + # mg7 does no CKKW merging, so those take their "no merging" value; an + # MLM run (dynamical_scale_choice = "mlm" with xqcut > 0) reports + # ickkw = 1 and its xqcut, so that the shower does the jet matching. 'ktdurham', 'ptlund', 'xqcut', 'maxjetflavor', 'sys_matchscale', 'dparameter', 'lhaid', 'iseed', 'python_seed', # read by MadSpin (check_launch, do_import) and by Banner.write for @@ -7255,6 +7256,16 @@ def __setitem__(self, name, value, *args, **opts): 'transverse_energy': 1, } + def is_mlm_merged(self): + """Whether the events are an MLM merged sample: clustered with + dynamical_scale_choice = "mlm" and vetoed at a merging cut xqcut > 0, + on hadronic beams. Such a sample needs MLM jet matching in the shower + (ickkw = 1 for the madevent tools) or it double counts.""" + beam = self['beam'] + return (beam['dynamical_scale_choice'] == 'mlm' + and not bool(beam['leptonic']) + and float(self['phasespace']['xqcut']) > 0) + def _legacy_compat(self, key): beam = self['beam'] leptonic = bool(beam['leptonic']) @@ -7282,7 +7293,9 @@ def _legacy_compat(self, key): return float(beam['scale_factor']) if key in ('mur_over_ref', 'muf_over_ref'): return 1.0 - if key in ('ickkw', 'ievo_eva', 'evaorder'): + if key == 'ickkw': + return 1 if self.is_mlm_merged() else 0 + if key in ('ievo_eva', 'evaorder'): return 0 if key == 'event_norm': return 'average' @@ -7290,15 +7303,23 @@ def _legacy_compat(self, key): if beam['fixed_ren_scale'] and beam['fixed_fact_scale']: return -1 # fixed scale: no dynamical-scale variation return self._dyn_scale_legacy.get(beam['dynamical_scale_choice'], -1) - # --- merging / matching: mg7 does none, keep the "off" values --- + # --- merging / matching: MLM through ickkw / xqcut, no CKKW --- if key in ('ktdurham', 'ptlund'): - return -1.0 # <=0 => no CKKW / no MLM shower matching - if key in ('xqcut', 'sys_matchscale'): + return -1.0 # <=0 => no CKKW-L merging + if key == 'xqcut': + if self.is_mlm_merged(): + return float(self['phasespace']['xqcut']) + return 0.0 + if key == 'sys_matchscale': return 0.0 if key == 'maxjetflavor': + # JetMatching:nQmatch: the heaviest quark the matching treats as a + # jet, which must be the one the clustering treated as a jet + if self.is_mlm_merged(): + return int(beam['max_jet_flavor']) return 5 if key == 'dparameter': - return 0.4 # only used by the (unused) matching machinery + return 0.4 # only used by the (unused) CKKW-L machinery # --- run / seed bookkeeping --- if key == 'lhaid': return self.get_lhapdf_id() @@ -7739,6 +7760,9 @@ def number(val): jet = self._lo_jet_group(flavours) self.dynamic_sections['multiparticles']['jet'] = jet changes['multiparticles.jet'] = jet + # the MLM clustering's jet flavours, which madevent's maxjetflavor + # also sets + put('beam.max_jet_flavor', flavours) else: # per-pdg cuts, written {pdg: value, ...} as in the run_card.dat @@ -8166,6 +8190,14 @@ def create_default_for_process(self, proc_characteristic, history, proc_def): hadronic = set([1, -1, 2, -2, 3, -3, 4, -4, 5, -5, 21, 22, 81, -81]) leptons = set([11, -11, 13, -13, 15, -15, 82, -82]) + if beam_id & hadronic: + # the heaviest quark the MLM clustering treats as a jet follows the + # flavour scheme of the model, as madevent's maxjetflavor does: 5 + # once the b is massless and in the proton + nflav = get_model_flavour_scheme(proc_def) or 4 + self['beam']['max_jet_flavor'] = max( + [nflav] + [abs(i) for i in beam_id if -7 < i < 7]) + if beam_id and beam_id <= leptons: # lepton collider: no PDF, lower default energy, no hadronic cuts self['beam']['leptonic'] = True @@ -8655,9 +8687,10 @@ def remove_jet_cuts(self): 'iseed', 'nhel', 'limhel', 'hel_recycling', 'hel_filtering', 'hel_splitamp', 'hel_zeroamp', # matching / merging - 'ickkw', 'xqcut', 'ktdurham', 'dparameter', 'ptlund', 'highestmult', + # (ickkw, xqcut and auto_ptj_mjj are translated: see MLM merging) + 'ktdurham', 'dparameter', 'ptlund', 'highestmult', 'ktscheme', 'alpsfact', 'chcluster', 'pdfwgt', 'asrwgtflavor', - 'clusinfo', 'auto_ptj_mjj', 'pdgs_for_merging_cut', + 'clusinfo', 'pdgs_for_merging_cut', # bias 'bias_module', 'bias_parameters', # unsupported cuts: needs the opposite-hemisphere condition on the two @@ -8775,6 +8808,31 @@ def nondefault(name): elif dyn not in (-1, 10) and nondefault('dynamical_scale_choice'): dropped.append('dynamical_scale_choice=%s (no mg7 equivalent)' % dyn) + # --- MLM merging --- + # madevent's ickkw = 1 back-clusters every event and vetoes it at + # xqcut; with its default dynamical_scale_choice -1 the scales are + # setclscales', which is mg7's "mlm" choice under scale_scheme + # "madevent". mg7 has no MLM with a closed-form scale choice: the + # clustering scales are used whatever dynamical_scale_choice said. + ickkw = int(lo['ickkw']) if 'ickkw' in lo else 0 + if ickkw == 1: + xqcut = float(lo['xqcut']) if 'xqcut' in lo else 0. + if xqcut > 0: + mg7.set('beam.dynamical_scale_choice', 'mlm') + mg7.set('beam.scale_scheme', 'madevent') + mg7.set('phasespace.xqcut', xqcut) + if 'auto_ptj_mjj' in lo: + mg7.set('phasespace.auto_ptj_mjj', bool(lo['auto_ptj_mjj'])) + if dyn != -1: + dropped.append('dynamical_scale_choice=%s with ickkw=1 ' + '(mg7 uses the MLM clustering scales)' % dyn) + else: + dropped.append('ickkw=1 with xqcut=%s (no merging cut: not ' + 'merged)' % xqcut) + elif nondefault('xqcut'): + dropped.append('xqcut=%s without ickkw=1 (no MLM merging)' + % lo['xqcut']) + # --- SDE strategy --- sde = lo['SDE_strategy'] if 'SDE_strategy' in lo else 1 mg7.set('phasespace.sde_strategy', @@ -8803,6 +8861,7 @@ def nondefault(name): if 'maxjetflavor' in lo: mg7.dynamic_sections['multiparticles']['jet'] = \ cls._lo_jet_group(int(lo['maxjetflavor'])) + mg7.set('beam.max_jet_flavor', int(lo['maxjetflavor'])) # --- cuts (rebuild from the LO card) --- cuts = collections.OrderedDict() diff --git a/tests/unit_tests/various/test_banner.py b/tests/unit_tests/various/test_banner.py index b70f16976e..4d1328125c 100755 --- a/tests/unit_tests/various/test_banner.py +++ b/tests/unit_tests/various/test_banner.py @@ -2043,6 +2043,61 @@ def test_from_LO_conversion(self): mg7.write(buf, template=self.template) tomllib.loads(buf.getvalue()) + def test_legacy_keys_report_mlm_merging(self): + """The madevent shower interface picks MLM jet matching from ickkw + and sets JetMatching:qCut from xqcut and nQmatch from maxjetflavor. + An mg7 MLM sample has to report them, or Pythia8 showers it with no + matching and double counts the jets.""" + card = bannermod.RunCardMG7() + # not merged: the plain shower path, as before + self.assertEqual(card['ickkw'], 0) + self.assertEqual(card['xqcut'], 0.0) + self.assertEqual(card['maxjetflavor'], 5) + card.set('beam.dynamical_scale_choice', 'mlm') + card.set('phasespace.xqcut', 30.0) + card.set('beam.max_jet_flavor', 4) + self.assertTrue(card.is_mlm_merged()) + self.assertEqual(card['ickkw'], 1) + self.assertEqual(card['xqcut'], 30.0) + self.assertEqual(card['maxjetflavor'], 4) + self.assertEqual(card['ktdurham'], -1.0) + # no merging cut, or no beams to merge on: not a merged sample + card.set('phasespace.xqcut', 0.0) + self.assertEqual(card['ickkw'], 0) + card.set('phasespace.xqcut', 30.0) + card.set('beam.leptonic', True) + self.assertEqual(card['ickkw'], 0) + self.assertEqual(card['xqcut'], 0.0) + + def test_set_maxjetflavor_sets_the_mlm_jet_flavours(self): + rc = bannermod.RunCardMG7() + rc.set_lo_param('maxjetflavor', '5') + self.assertEqual(rc['beam']['max_jet_flavor'], 5) + + def test_from_LO_conversion_of_mlm_merging(self): + """ickkw = 1 with xqcut is madevent's MLM: mg7's "mlm" scale choice + with madevent's scale scheme, the same xqcut and jet flavours""" + lo = bannermod.RunCardLO() + lo['ickkw'] = 1 + lo['xqcut'] = 20 + lo['maxjetflavor'] = 5 + lo['auto_ptj_mjj'] = False + mg7, dropped = bannermod.RunCardMG7.from_LO(lo, warn=False) + self.assertEqual(mg7['beam']['dynamical_scale_choice'], 'mlm') + self.assertEqual(mg7['beam']['scale_scheme'], 'madevent') + self.assertEqual(mg7['phasespace']['xqcut'], 20.0) + self.assertFalse(mg7['phasespace']['auto_ptj_mjj']) + self.assertEqual(mg7['beam']['max_jet_flavor'], 5) + joined = ' '.join(dropped) + self.assertNotIn('ickkw', joined) + self.assertNotIn('xqcut', joined) + self.assertEqual(mg7['ickkw'], 1) + # without a merging cut there is nothing to merge + lo['xqcut'] = 0 + mg7, dropped = bannermod.RunCardMG7.from_LO(lo, warn=False) + self.assertNotEqual(mg7['beam']['dynamical_scale_choice'], 'mlm') + self.assertIn('ickkw=1', ' '.join(dropped)) + def test_from_LO_conversion_per_beam(self): """asymmetric beam energies and PDFs are kept per beam""" lo = bannermod.RunCardLO() From 461b00edb39146d8468bb3a6178dd8c0afe6b587 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Mon, 26 Oct 2026 15:43:32 +0100 Subject: [PATCH 63/81] Native systematics: vary MLM alpha_s vertices as madevent does An MLM event with per-vertex alpha_s carries alpha_s(mu_R)^(n - n_v) prod_v alpha_s(q_v), but the native systematics moved all n powers to k mu_R. madevent's systematics.py moves only the n - n_v powers with mu_R, and every vertex with its own emission scale factor (alpsfact, which madevent sets to "0.5 1 2" for a matched process), written as an "Emission scale variation" group. - The integrand hands the vertex scales on (alphas_scales, one fewer than the outgoing particles), and the channel generator stores vertex i on outgoing particle 2 + i (ParticleRecord::f_alphas_scales). Only the combined buffer the systematics read has them; no event file does. The combined layout takes the flag from any channel, since channel 0 may be the 2 -> 1 sample, and an event from a channel without vertices gets 0. Padding slots of a lower-multiplicity event are zeroed (cluster_scale too), as the buffer is reused. - The kernel now hands 0, not mu_R, for a vertex it does not reweight: under clustering_mean a single clustering IS mu_R, so "q_v == mu_R" could not tell a reweighted vertex from one left at mu_R. mlm_weights maps 0 back to mu_R; the event weight is unchanged. - SystematicsCalculator: config.alps / Variation.alps / is_emission. Each variation's alpha_s ratio gets prod_v alpha'(alps q_v) / alpha(q_v) * (alpha(mu_R) / alpha'(k mu_R))^n_v which turns the (alpha'(k mu_R) / alpha(mu_R))^n of the mu_R variation, or of the matrix element re-evaluation, into madevent's form. The summary gets an "emission" envelope, the log an "Emission scale variation" row. - [systematics] alps = [0.5, 1.0, 2.0], used under dynamical_scale_choice = "mlm" with alphas_reweighting != "none". The ladder PDF ratios are not varied with alpsfact (madevent's systematics.py does take them at min(alpsfact q_i, muF)): the record would need the whole ladder. p p > z + 0,1j (one alpha_s power, at the clustering vertex), 2000 events: on every 1-jet event exactly one of MUR=2 and ALPSFACT=2 moves the weight (336 events the vertex, 106 mu_R), the 0-jet weights stay at one; scale band +10.7/-11.6 %, emission band +2.2/-1.8 %. Before, the 2 -> 1 events picked up garbage vertex scales from the record and MUR moved every 1-jet event. --- madgraph/iolibs/template_files/mg7/launch.py | 13 +- .../iolibs/template_files/mg7/run_card.toml | 1 + .../template_files/mg7/systematics_summary.py | 14 +- madgraph/various/banner.py | 5 + .../madspace/driver/channel_generator.hpp | 1 + madspace/include/madspace/driver/io.hpp | 10 + .../include/madspace/driver/systematics.hpp | 19 +- madspace/src/driver/channel_generator.cpp | 24 +++ madspace/src/driver/event_generator.cpp | 41 +++- madspace/src/driver/systematics.cpp | 178 +++++++++++++++--- madspace/src/kernels/mlm.hpp | 6 +- madspace/src/phasespace/integrand.cpp | 61 +++++- madspace/src/python/madspace.cpp | 44 ++++- madspace/tests/test_mlm.py | 24 ++- madspace/tests/test_systematics.py | 54 +++++- 15 files changed, 441 insertions(+), 54 deletions(-) diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index db337ad104..0327906bdc 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -1141,6 +1141,13 @@ def build_systematics(self): config = ms.SystematicsConfig() config.mur = [float(v) for v in syst["mur"]] config.muf = [float(v) for v in syst["muf"]] + # the emission scale of the MLM alpha_s vertices, which only an MLM + # sample with per-vertex alpha_s has + if (self.mlm_clustering and not self.is_decay + and self.run_card["beam"]["alphas_reweighting"] != "none"): + config.alps = [float(v) for v in syst["alps"]] + else: + config.alps = [1.0] config.together = bool(syst["together"]) config.dyn_scales = self.resolve_dynamical_scales() # alternative dynamical scales get the same factor as the nominal one @@ -1356,7 +1363,8 @@ def format_variation(up, down): # values line up across rows, with extra spacing between the two return f"{'+%.3g' % up:>6}% {'-%.3g' % down:>6}%" - scale_count = sum(1 for v in self.systematics.variations if v.is_scale) + scale_count = sum(1 for v in self.systematics.variations + if v.is_scale or v.is_emission) member_count = len(self.systematics.members) rows = [("Variations per event:", f"{self.systematics.weight_count} ({scale_count} scale, {member_count} PDF members)")] @@ -1366,6 +1374,9 @@ def format_variation(up, down): scale = systematics_summary.scale_percentages(summary) if scale is not None: rows.append(("Scale variation:", format_variation(*scale))) + emission = systematics_summary.emission_percentages(summary) + if emission is not None: + rows.append(("Emission scale variation:", format_variation(*emission))) for _entry, up, down in systematics_summary.pdf_percentages(summary): rows.append(("PDF variation:", format_variation(up, down))) if self.event_generator_config.verbosity == ms.Verbosity.pretty: diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index 47f3c04b24..8159b0bd40 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -133,6 +133,7 @@ interference_helicity = %(generation.interference_helicity)s # options: exact, s enable = %(systematics.enable)s mur = %(systematics.mur)s # renormalization scale factors muf = %(systematics.muf)s # factorization scale factors +alps = %(systematics.alps)s # MLM emission scale factors (dynamical_scale_choice = "mlm" only) together = %(systematics.together)s # true: all mur x muf combinations, false: one at a time # alternative dynamical scale choices, as for [beam] dynamical_scale_choice dynamical_scale = %(systematics.dynamical_scale)s diff --git a/madgraph/iolibs/template_files/mg7/systematics_summary.py b/madgraph/iolibs/template_files/mg7/systematics_summary.py index 4fa8526b35..948594d778 100644 --- a/madgraph/iolibs/template_files/mg7/systematics_summary.py +++ b/madgraph/iolibs/template_files/mg7/systematics_summary.py @@ -46,17 +46,18 @@ def nominal_cross_section(summary): return value or None -def scale_percentages(summary): +def scale_percentages(summary, group='scale'): """``(up, down)`` percent for the scale envelope, or None. Positive magnitudes: ``up`` is how far above the nominal the envelope - reaches, ``down`` how far below. + reaches, ``down`` how far below. ``group`` names the envelope in the + summary: 'scale', or 'emission' (see emission_percentages). """ nominal = nominal_cross_section(summary) if nominal is None: return None - band = summary.get('scale') + band = summary.get(group) if not band: return None low, high = band.get('min'), band.get('max') @@ -66,6 +67,13 @@ def scale_percentages(summary): (nominal - low) / abs(nominal) * 100.) +def emission_percentages(summary): + """``(up, down)`` percent for the MLM emission scale envelope (madevent's + alpsfact), or None, with the conventions of scale_percentages.""" + + return scale_percentages(summary, 'emission') + + def pdf_percentages(summary): """``[(entry, up, down), ...]``, one per PDF set that has an uncertainty. diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index 6bba17abd3..69a8e994c7 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6966,6 +6966,11 @@ def default_setup(self): comment="renormalization scale factors") self.add_toml_param('systematics', 'muf', [0.5, 1.0, 2.0], typelist=float, gridpack=True, comment="factorization scale factors") + # madevent's sys_alpsfact, which it sets to "0.5 1 2" for a matched + # process: the alpha_s of every MLM clustering vertex at the factor + # times the vertex scale, in an "Emission scale variation" group + self.add_toml_param('systematics', 'alps', [0.5, 1.0, 2.0], typelist=float, gridpack=True, + comment="MLM emission scale factors (dynamical_scale_choice = mlm only)") self.add_toml_param('systematics', 'together', True, gridpack=True, comment="true: all mur x muf combinations; false: vary one scale at a time") self.add_toml_param('systematics', 'dynamical_scale', [], typelist=str, gridpack=True, diff --git a/madspace/include/madspace/driver/channel_generator.hpp b/madspace/include/madspace/driver/channel_generator.hpp index 32c9ff5caf..5bd32edada 100644 --- a/madspace/include/madspace/driver/channel_generator.hpp +++ b/madspace/include/madspace/driver/channel_generator.hpp @@ -220,6 +220,7 @@ class ChannelEventGenerator { int ren_scale, alpha_qcd; int x1, fact_scale1, x2, fact_scale2, partial_weight_product; int cluster_scales; + int alphas_scales; int subprocess_index; int bias; int random, rest; diff --git a/madspace/include/madspace/driver/io.hpp b/madspace/include/madspace/driver/io.hpp index 052fb692e6..413b98cd48 100644 --- a/madspace/include/madspace/driver/io.hpp +++ b/madspace/include/madspace/driver/io.hpp @@ -58,6 +58,10 @@ class ParticleRecord { static constexpr int f_particle_data = 1; static constexpr int f_lhe_particle = 2; static constexpr int f_clustering = 4; + // MLM alpha_s reweighting: the scale of the clustering vertex stored on + // outgoing particle 2 + i is vertex i's. Only kept in the combined buffer + // the systematics read, never written to an event file. + static constexpr int f_alphas_scales = 8; static std::vector layout(int fields) { std::vector ret; @@ -94,6 +98,9 @@ class ParticleRecord { if (fields & f_clustering) { ret.push_back({"cluster_scale", FieldLayout::f64, 1}); } + if (fields & f_alphas_scales) { + ret.push_back({"alphas_scale", FieldLayout::f64, 2}); + } return ret; } @@ -123,6 +130,9 @@ class ParticleRecord { // clustering data UnalignedRef cluster_scale() { return &_data[_offsets[0] + 0]; } + // scale alpha_s was evaluated at for one MLM clustering vertex, see + // f_alphas_scales + UnalignedRef alphas_scale() { return &_data[_offsets[1] + 0]; } void from_lhe_particle(const LHEParticle& particle, bool has_clustering = false) { lhe_pdg_id() = particle.pdg_id; diff --git a/madspace/include/madspace/driver/systematics.hpp b/madspace/include/madspace/driver/systematics.hpp index 59431c6616..842550cacc 100644 --- a/madspace/include/madspace/driver/systematics.hpp +++ b/madspace/include/madspace/driver/systematics.hpp @@ -44,6 +44,12 @@ struct SystematicsConfig { std::vector mur{1.}; /// Factorization scale factors; `1.0` is the nominal point. std::vector muf{1.}; + /// Emission scale factors of an MLM merged sample, madevent's `alpsfact`: + /// the alpha_s of every clustering vertex is evaluated at the factor times + /// the vertex scale. `1.0` is the nominal point; the other values are + /// written in an "Emission scale variation" group. Only for events whose + /// record carries the vertex scales (ParticleRecord::f_alphas_scales). + std::vector alps{1.}; /// If true, vary every `(mur, muf)` combination; if false, vary one /// factor at a time. bool together = true; @@ -97,13 +103,21 @@ struct Variation { /// Dynamical scale choice; `-1` for the scale the events were generated /// with. int dyn = -1; + /// Emission scale factor of the MLM alpha_s vertices (madevent's + /// `alpsfact`); `1` for the nominal vertex scales. + double alps = 1.; /// Whether this is a scale variation (a non-nominal `mur`, `muf` or `dyn` /// at the nominal PDF member). bool is_scale() const { return pdf_index == -1 && (mur != 1. || muf != 1. || dyn != -1); } + /// Whether this is an emission scale variation (a non-nominal `alps` at + /// the nominal scales and PDF member). + bool is_emission() const { return pdf_index == -1 && !is_scale() && alps != 1.; } /// Whether this is the nominal point (nominal scale and PDF member). - bool is_nominal() const { return pdf_index == -1 && !is_scale(); } + bool is_nominal() const { + return pdf_index == -1 && !is_scale() && !is_emission(); + } }; /// The variations belonging to one PDF set, for computing an uncertainty band. @@ -186,6 +200,9 @@ class SystematicsCalculator { /// Indices into @ref variations of the scale variations (the envelope /// group). std::vector scale_variation_indices() const; + /// Indices into @ref variations of the MLM emission scale variations + /// (their own envelope group). + std::vector emission_variation_indices() const; /// The PDF groups, one per set with members among @ref variations. std::vector pdf_groups() const; diff --git a/madspace/src/driver/channel_generator.cpp b/madspace/src/driver/channel_generator.cpp index 950ee787f8..f692d2cce7 100644 --- a/madspace/src/driver/channel_generator.cpp +++ b/madspace/src/driver/channel_generator.cpp @@ -34,6 +34,9 @@ int particle_extra_flags( if (index_map.contains("cluster_scales")) { flags |= ParticleRecord::f_clustering; } + if (index_map.contains("alphas_scales")) { + flags |= ParticleRecord::f_alphas_scales; + } return flags; } @@ -326,6 +329,11 @@ void ChannelEventGenerator::init_field_indices() { } else { _field_indices.cluster_scales = -1; } + if (index_map.contains("alphas_scales")) { + _field_indices.alphas_scales = index_map.at("alphas_scales"); + } else { + _field_indices.alphas_scales = -1; + } if (index_map.contains("subprocess_index")) { _field_indices.subprocess_index = index_map.at("subprocess_index"); } else { @@ -826,6 +834,22 @@ void ChannelEventGenerator::write_events( } } + if (_field_indices.alphas_scales != -1) { + // vertex i on outgoing particle 2 + i; there is one vertex less than + // there are outgoing particles, the remaining slots hold zero + auto alphas_view = + unweighted_events.at(_field_indices.alphas_scales).view(); + for (std::size_t i = 0; i < w_view.size(); ++i) { + auto scales = alphas_view[i]; + std::size_t vertex_count = scales.size(); + for (std::size_t j = 0; j < _particle_count; ++j) { + auto particle = event_buffer.particle(i, j); + particle.alphas_scale() = + j >= 2 && j - 2 < vertex_count ? scales[j - 2] : 0.; + } + } + } + if (_field_indices.bias != -1) { auto bias_view = unweighted_events.at(_field_indices.bias).view(); for (std::size_t i = 0; i < bias_view.size(); ++i) { diff --git a/madspace/src/driver/event_generator.cpp b/madspace/src/driver/event_generator.cpp index 586d09989b..ae8497e7bb 100644 --- a/madspace/src/driver/event_generator.cpp +++ b/madspace/src/driver/event_generator.cpp @@ -916,6 +916,23 @@ void EventGenerator::combine_to_lhe( _event_histograms = nullptr; } +namespace { + +// The MLM alpha_s vertex scales of any channel: a merged run mixes a 2 -> 1 +// sample, which has no vertex, with the jet samples, which do, and channel 0 +// may be either. +template +int any_alphas_scales(const Channels& channels) { + for (auto& channel : channels) { + if (channel->particle_layout_extra_flags() & ParticleRecord::f_alphas_scales) { + return ParticleRecord::f_alphas_scales; + } + } + return 0; +} + +} // namespace + DataLayout EventGenerator::combined_layout() const { return DataLayout( EventRecord::layout( @@ -925,7 +942,7 @@ DataLayout EventGenerator::combined_layout() const { ), ParticleRecord::layout( ParticleRecord::f_particle_data | - _channels.at(0)->particle_layout_extra_flags() + _channels.at(0)->particle_layout_extra_flags() | any_alphas_scales(_channels) ) ); } @@ -946,10 +963,13 @@ EventGenerator::output_layout(int event_flags, std::size_t particle_flags) const extra_flags |= EventRecord::f_syst_weights; } } + // the alpha_s vertex scales only serve the systematics, which run on the + // combined buffer: no event file carries them return DataLayout( EventRecord::layout(event_flags | extra_flags, syst_ids), ParticleRecord::layout( - particle_flags | _channels.at(0)->particle_layout_extra_flags() + particle_flags | (_channels.at(0)->particle_layout_extra_flags() & + ~ParticleRecord::f_alphas_scales) ) ); } @@ -1186,6 +1206,7 @@ void EventGenerator::read_and_combine( _channels.at(0)->event_layout_extra_flags() & EventRecord::f_partial_weights; bool has_clustering = _channels.at(0)->particle_layout_extra_flags() & ParticleRecord::f_clustering; + bool has_alphas_scales = any_alphas_scales(_channels) != 0; bool has_subproc_index = _channels.at(0)->event_layout_extra_flags() & EventRecord::f_subproc_index; bool has_bias = _channels.at(0)->event_layout_extra_flags() & EventRecord::f_bias; @@ -1202,6 +1223,8 @@ void EventGenerator::read_and_combine( --chan.cum_count; }); auto& channel = _channels.at(sampled_chan - channel_data.begin()); + bool channel_has_alphas_scales = + channel->particle_layout_extra_flags() & ParticleRecord::f_alphas_scales; double weight = 0.; while (true) { @@ -1264,6 +1287,12 @@ void EventGenerator::read_and_combine( if (has_clustering) { particle_out.cluster_scale() = particle_in.cluster_scale(); } + if (has_alphas_scales) { + // zero from a channel without vertices (2 -> 1) + particle_out.alphas_scale() = channel_has_alphas_scales + ? particle_in.alphas_scale().value() + : 0.; + } } for (; i < buffer.particle_count(); ++i) { auto particle_out = buffer.particle(event_index, i); @@ -1271,6 +1300,14 @@ void EventGenerator::read_and_combine( particle_out.px() = 0.; particle_out.py() = 0.; particle_out.pz() = 0.; + // the buffer is reused: a padding slot must not keep the scales of + // an earlier, higher-multiplicity event + if (has_clustering) { + particle_out.cluster_scale() = 0.; + } + if (has_alphas_scales) { + particle_out.alphas_scale() = 0.; + } } ++sampled_chan->buffer_index; } diff --git a/madspace/src/driver/systematics.cpp b/madspace/src/driver/systematics.cpp index 9fd22e05ef..214609f419 100644 --- a/madspace/src/driver/systematics.cpp +++ b/madspace/src/driver/systematics.cpp @@ -48,12 +48,22 @@ struct AlphaSKey { std::size_t alpha_s_index; double mur; int dyn; + double alps; bool operator<(const AlphaSKey& other) const { - return std::tie(alpha_s_index, mur, dyn) < - std::tie(other.alpha_s_index, other.mur, other.dyn); + return std::tie(alpha_s_index, mur, dyn, alps) < + std::tie(other.alpha_s_index, other.mur, other.dyn, other.alps); } }; +bool particle_layout_has(const DataLayout& layout, const std::string& name) { + for (auto& field : layout.particle_layout()) { + if (field.name == name) { + return true; + } + } + return false; +} + } // namespace std::string SystematicsCalculator::format_number(double value) { @@ -380,6 +390,14 @@ void SystematicsCalculator::build_variations() { ); } + // emission scale variations of an MLM merged sample (madevent's alpsfact), + // at the nominal mu_R, mu_F and PDF, as systematics.py writes them + for (double alps : _config.alps) { + if (alps != 1.) { + _variations.push_back({id++, 1., 1., -1, -1, alps}); + } + } + // PDF variations: when members of the nominal set are requested, the group // starts with the nominal member itself so that the group is complete bool has_nominal_set_members = std::any_of( @@ -413,11 +431,21 @@ std::vector SystematicsCalculator::scale_variation_indices() const return indices; } +std::vector SystematicsCalculator::emission_variation_indices() const { + std::vector indices; + for (std::size_t k = 0; k < _variations.size(); ++k) { + if (_variations.at(k).is_emission()) { + indices.push_back(k); + } + } + return indices; +} + std::vector SystematicsCalculator::pdf_groups() const { std::map groups; for (std::size_t k = 0; k < _variations.size(); ++k) { auto& var = _variations.at(k); - if (var.is_scale()) { + if (var.is_scale() || var.is_emission()) { continue; } int set_lhaid, member; @@ -668,9 +696,13 @@ void SystematicsCalculator::compute( bool has_subproc = layout_has(layout, "subprocess_index"); bool use_pdf = _config.has_pdf && _nominal_pdf && (has_beam1 || has_beam2); bool has_dyn = !_config.dyn_scales.empty(); + bool has_vertices = particle_layout_has(layout, "alphas_scale"); // per-event inputs struct EventInput { + // the scales of the MLM clustering vertices whose alpha_s is evaluated + // at their own scale rather than at mu_R (empty if none) + std::vector vertex_scales; double w0, ren_scale, x1, x2, muf1, muf2, alpha_s, nominal_product; int subproc, qcd_power; me_int_t slot1, slot2; @@ -710,6 +742,16 @@ void SystematicsCalculator::compute( in.nominal_product = event.partial_weight_product(); } } + if (has_vertices) { + // A vertex the clustering did not reweight carries 0, as does an + // unused slot: its alpha_s is one of the mu_R powers. + for (std::size_t j = 2; j < buffer.particle_count(); ++j) { + double scale = buffer.particle(i, j).alphas_scale(); + if (scale > 0.) { + in.vertex_scales.push_back(scale); + } + } + } in.dyn_scale.fill(0.); if (has_dyn) { auto momenta = event_momenta(buffer, i); @@ -863,10 +905,10 @@ void SystematicsCalculator::compute( auto& var = _variations.at(k); std::size_t alpha_s_index = var.pdf_index >= 0 ? _member_pdfs.at(var.pdf_index).alpha_s_index : 0; - if (var.mur == 1. && var.dyn == -1 && alpha_s_index == 0) { + if (var.mur == 1. && var.dyn == -1 && alpha_s_index == 0 && var.alps == 1.) { continue; } - AlphaSKey key{alpha_s_index, var.mur, var.dyn}; + AlphaSKey key{alpha_s_index, var.mur, var.dyn, var.alps}; std::size_t pos = 0; for (; pos < keys.size(); ++pos) { if (!(keys[pos] < key) && !(key < keys[pos])) { @@ -887,7 +929,20 @@ void SystematicsCalculator::compute( me_events[inputs[i].subproc].push_back(i); } } - if (!keys.empty()) { + // alpha_s at the nominal vertex scales, with the nominal set: what the + // events were generated with + std::vector vertex_nominal; + if (has_vertices) { + std::vector q; + for (auto& in : inputs) { + q.insert(q.end(), in.vertex_scales.begin(), in.vertex_scales.end()); + } + vertex_nominal = evaluate_alpha_s(0, q); + } + bool needs_me = std::any_of(keys.begin(), keys.end(), [](auto& key) { + return key.mur != 1. || key.dyn != -1 || key.alpha_s_index != 0; + }); + if (needs_me) { for (auto& [subproc, indices] : me_events) { std::vector alpha_s(indices.size()); for (std::size_t n = 0; n < indices.size(); ++n) { @@ -902,24 +957,64 @@ void SystematicsCalculator::compute( for (std::size_t i = 0; i < count; ++i) { q[i] = key.mur * scale_of(inputs[i], key.dyn, inputs[i].ren_scale); } - auto varied = evaluate_alpha_s(key.alpha_s_index, q); + // an emission scale variation alone leaves the mu_R powers as they are + bool varies_mur = key.mur != 1. || key.dyn != -1 || key.alpha_s_index != 0; + auto varied = varies_mur ? evaluate_alpha_s(key.alpha_s_index, q) + : std::vector(count, 0.); std::vector ratio(count, 1.); - for (std::size_t i = 0; i < count; ++i) { - auto& in = inputs[i]; - if (in.use_me || in.qcd_power == 0) { - continue; + if (varies_mur) { + for (std::size_t i = 0; i < count; ++i) { + auto& in = inputs[i]; + if (in.use_me || in.qcd_power == 0) { + continue; + } + ratio[i] = in.alpha_s == 0. + ? 0. + : std::pow(varied[i] / in.alpha_s, in.qcd_power); + } + for (auto& [subproc, indices] : me_events) { + std::vector alpha_s(indices.size()); + for (std::size_t n = 0; n < indices.size(); ++n) { + alpha_s[n] = varied[indices[n]]; + } + auto me = matrix_elements(subproc, buffer, indices, alpha_s); + auto& nominal = me_nominal.at(subproc); + for (std::size_t n = 0; n < indices.size(); ++n) { + ratio[indices[n]] = nominal[n] == 0. ? 0. : me[n] / nominal[n]; + } } - ratio[i] = in.alpha_s == 0. ? 0. : std::pow(varied[i] / in.alpha_s, in.qcd_power); } - for (auto& [subproc, indices] : me_events) { - std::vector alpha_s(indices.size()); - for (std::size_t n = 0; n < indices.size(); ++n) { - alpha_s[n] = varied[indices[n]]; + // MLM vertices, as madevent's systematics.py: the weight is + // alpha_s(mu_R)^(n - n_v) prod_v alpha_s(q_v), and a variation takes the + // n - n_v powers at k mu_R but every vertex at alpsfact q_v. The ratio + // above moved all n powers to k mu_R, so the vertices are taken back + // out of it and put in at their own scales: + // prod_v alpha'(alps q_v) / alpha(q_v) * (alpha(mu_R) / alpha'(k mu_R))^n_v + // which holds for the matrix element re-evaluation too, since that + // ratio is the same (alpha'(k mu_R) / alpha(mu_R))^n. + if (has_vertices) { + std::vector vq; + for (auto& in : inputs) { + for (double scale : in.vertex_scales) { + vq.push_back(key.alps * scale); + } } - auto me = matrix_elements(subproc, buffer, indices, alpha_s); - auto& nominal = me_nominal.at(subproc); - for (std::size_t n = 0; n < indices.size(); ++n) { - ratio[indices[n]] = nominal[n] == 0. ? 0. : me[n] / nominal[n]; + auto vertex_varied = evaluate_alpha_s(key.alpha_s_index, vq); + for (std::size_t i = 0, n = 0; i < count; ++i) { + auto& in = inputs[i]; + if (in.vertex_scales.empty()) { + continue; + } + double factor = 1.; + for (std::size_t v = 0; v < in.vertex_scales.size(); ++v, ++n) { + factor *= vertex_nominal[n] == 0. + ? 0. + : vertex_varied[n] / vertex_nominal[n]; + if (varies_mur) { + factor *= varied[i] == 0. ? 0. : in.alpha_s / varied[i]; + } + } + ratio[i] *= factor; } } for (std::size_t k : key_vars[p]) { @@ -960,9 +1055,11 @@ void SystematicsCalculator::accumulate( std::string SystematicsCalculator::initrwgt() const { std::string text; bool in_scale = false; + bool in_emission = false; int in_pdf = -1; for (auto& var : _variations) { bool is_scale = var.is_scale(); + bool is_emission = var.is_emission(); if (is_scale) { if (!in_scale) { text += "\n"; + in_emission = true; + } + } else if (in_emission) { + text += " # ALPS\n"; + in_emission = false; + } std::string tag, info; int pdf_lhaid; - if (!is_scale) { + if (is_emission) { + pdf_lhaid = _config.nominal_lhaid; + info = std::format("ALPSFACT={} ", format_number(var.alps)); + } else if (!is_scale) { // PDF group: the nominal member (pdf_index -1) belongs to the // nominal set's group int set_lhaid; @@ -1031,10 +1142,13 @@ std::string SystematicsCalculator::initrwgt() const { if (var.dyn != -1) { tag += std::format("DYN_SCALE=\"{}\" ", var.dyn); } + if (var.alps != 1.) { + tag += std::format("ALPSFACT=\"{}\" ", format_number(var.alps)); + } tag += std::format("PDF=\"{}\" ", pdf_lhaid); text += std::format(" {} \n", var.id, tag, info); } - if (in_scale || in_pdf != -1) { + if (in_scale || in_emission || in_pdf != -1) { text += "\n"; } return text; @@ -1099,6 +1213,23 @@ json SystematicsCalculator::summary() const { }; } + // emission scale envelope (MLM alpsfact) + auto emission_indices = emission_variation_indices(); + if (!emission_indices.empty()) { + json ids = json::array(); + double low = nominal_xsec, high = nominal_xsec; + for (std::size_t k : emission_indices) { + ids.push_back(_variations.at(k).id); + low = std::min(low, variation_xsecs.at(k)); + high = std::max(high, variation_xsecs.at(k)); + } + result["emission"] = { + {"weight_ids", ids}, + {"min", low}, + {"max", high}, + }; + } + // PDF uncertainty per set json pdf_sets = json::array(); for (auto& group : pdf_groups()) { @@ -1162,6 +1293,7 @@ void madspace::to_json(json& j, const SystematicsConfig& config) { j = json{ {"mur", config.mur}, {"muf", config.muf}, + {"alps", config.alps}, {"together", config.together}, {"dyn_scales", config.dyn_scales}, {"scale_factor", config.scale_factor}, @@ -1179,6 +1311,7 @@ void madspace::to_json(json& j, const SystematicsConfig& config) { void madspace::from_json(const json& j, SystematicsConfig& config) { config.mur = j.at("mur").get>(); config.muf = j.at("muf").get>(); + config.alps = j.value("alps", std::vector{1.}); config.together = j.value("together", true); config.dyn_scales = j.value("dyn_scales", std::vector{}); config.scale_factor = j.value("scale_factor", 1.); @@ -1207,6 +1340,7 @@ void madspace::to_json(json& j, const Variation& variation) { {"mur", variation.mur}, {"muf", variation.muf}, {"dyn", variation.dyn}, + {"alps", variation.alps}, {"pdf_index", variation.pdf_index}, }; } diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 3d2c348daf..fa2db8b30a 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -1438,10 +1438,14 @@ KERNELSPEC int mlm_clustering_walk( } FVal mean_scale = reweighted > 0 ? exp(log_sum / reweighted) : ren_scale_val; + // A vertex left at mu_R is handed 0 rather than mu_R itself: a reweighted + // vertex can sit exactly at mu_R (one clustering under clustering_mean), and + // the systematics have to tell the two apart, since madevent varies a + // reweighted vertex with alpsfact and the others with mu_R. for (int i = 0; i < cluster_max; ++i) { bool is_qcd_vertex = (cluster_history[i] >> 27) & 1; if (alphas_scheme == ALPHAS_NONE || !is_qcd_vertex) { - alphas_scales[i] = ren_scale_val; + alphas_scales[i] = 0.0; } else if (alphas_scheme == ALPHAS_GEOMETRIC) { alphas_scales[i] = mean_scale; } else { diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index 19936af05b..f3b231a372 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -40,6 +40,18 @@ std::size_t final_channel_count( // differ from one. Without it the systematics take the nominal densities at // the factorisation scale themselves, and a variation becomes the ratio of // the leading densities there, the ladder ratios left at their nominal value. +// Whether the event carries the scales of its MLM alpha_s vertices, for the +// systematics to vary them the way madevent's do: the same condition under +// which mlm_weights applies them. A 2 -> 1 process has no vertex at all. +bool emits_alphas_scales( + const std::optional& energy_scale, + const std::optional& running_coupling, + std::size_t particle_count +) { + return energy_scale && energy_scale->mlm_alphas_reweighting() && + running_coupling && particle_count > 3; +} + bool emits_partial_weights( bool partial_weights, const std::optional& energy_scale ) { @@ -177,6 +189,14 @@ Integrand::Integrand( batch_float_array(mapping.particle_count() - 2) ); } + if (emits_alphas_scales( + energy_scale, running_coupling, mapping.particle_count() + )) { + ret_types.push_back( + "alphas_scales", + batch_float_array(mapping.particle_count() - 3) + ); + } // before "random": the outputs after it feed the discrete histograms if (custom_bias) { ret_types.push_back("bias", batch_float); @@ -493,6 +513,9 @@ NamedVector Integrand::compute_channel_part_ret_types() const { if (_energy_scale && _energy_scale->is_mlm()) { ret.push_back("cluster_scales_acc", acc_float_array(particle_count - 2)); } + if (emits_alphas_scales(_energy_scale, _running_coupling, particle_count)) { + ret.push_back("alphas_scales_acc", acc_float_array(particle_count - 3)); + } return ret; } @@ -856,6 +879,11 @@ NamedVector Integrand::build_channel_part( if (_energy_scale && _energy_scale->is_mlm()) { out.push_back("cluster_scales_acc", scales.at("outgoing_scales")); } + if (emits_alphas_scales( + _energy_scale, _running_coupling, _mapping.particle_count() + )) { + out.push_back("alphas_scales_acc", scales.at("alphas_scales")); + } return out; } @@ -881,19 +909,22 @@ ValueVec Integrand::mlm_weights( // alphas(mu_R)^n. Without it a merged sample is short by one factor // per emission, compounding with multiplicity. // - // The kernel hands back mu_R for any vertex it does not reweight, so - // that vertex's ratio is one and no mask is needed here. + // The kernel hands back 0 for any vertex it does not reweight; that + // vertex is evaluated at mu_R, so its ratio is exactly one. if (_energy_scale->mlm_alphas_reweighting() && _running_coupling) { auto vertex_scales = scales.at("alphas_scales"); std::size_t vertex_count = vertex_scales.type.shape.at(0); if (vertex_count > 0) { - auto reference = _running_coupling.value() - .build_function(fb, {scales.at("ren_scale")}) - .at(0); + auto ren_scale = scales.at("ren_scale"); + auto reference = + _running_coupling.value().build_function(fb, {ren_scale}).at(0); Value factor; for (std::size_t i = 0; i < vertex_count; ++i) { - auto [rest, one_scale] = fb.pop(vertex_scales); + auto [rest, vertex_scale] = fb.pop(vertex_scales); vertex_scales = rest; + // 0 (not reweighted) -> mu_R; a reweighted scale is > 2 GeV + auto at_mur = fb.cut_one(vertex_scale, -1., 0.); + auto one_scale = fb.add(vertex_scale, fb.mul(at_mur, ren_scale)); auto alpha = _running_coupling.value().build_function(fb, {one_scale}).at(0); auto ratio = fb.div(alpha, reference); @@ -1089,6 +1120,12 @@ NamedVector Integrand::build_common_part( if (_energy_scale && _energy_scale->is_mlm()) { cluster_scales_acc = args.at("cluster_scales_acc"); } + bool has_alphas_scales = + emits_alphas_scales(_energy_scale, _running_coupling, _mapping.particle_count()); + Value alphas_scales_acc; + if (has_alphas_scales) { + alphas_scales_acc = args.at("alphas_scales_acc"); + } // Evaluate differential cross section auto make_xs_args = [&](std::array& pdfs, Value alpha) { @@ -1210,6 +1247,9 @@ NamedVector Integrand::build_common_part( } pdfs_acc = evaluate_pdfs(fb, scales, x_acc, flavor_id); cluster_scales_acc = scales.at("outgoing_scales"); + if (has_alphas_scales) { + alphas_scales_acc = scales.at("alphas_scales"); + } alpha_qcd_acc = _running_coupling.value().build_function(fb, {ren_scale_acc}).at(0); dxs_vec = _diff_xs.at(0) @@ -1424,6 +1464,15 @@ NamedVector Integrand::build_common_part( ) ); } + if (has_alphas_scales) { + auto vertex_count = static_cast(_mapping.particle_count() - 3); + outputs.push_back( + "alphas_scales", + scatter_or_drop( + fb.full({0., batch_size_val, vertex_count}), alphas_scales_acc + ) + ); + } if (bias_acc) { outputs.push_back( "bias", scatter_or_drop(fb.full({1., batch_size_val}), bias_acc) diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index fafa0ef2f0..62378a09ad 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -3546,6 +3546,9 @@ PYBIND11_MODULE(_madspace_py, m) { .def_readwrite( "muf", &SystematicsConfig::muf, pydoc::doc("SystematicsConfig::muf") ) + .def_readwrite( + "alps", &SystematicsConfig::alps, pydoc::doc("SystematicsConfig::alps") + ) .def_readwrite( "together", &SystematicsConfig::together, @@ -3645,8 +3648,14 @@ PYBIND11_MODULE(_madspace_py, m) { "pdf_index", &Variation::pdf_index, pydoc::doc("Variation::pdf_index") ) .def_readonly("dyn", &Variation::dyn, pydoc::doc("Variation::dyn")) + .def_readonly("alps", &Variation::alps, pydoc::doc("Variation::alps")) .def_property_readonly( "is_scale", &Variation::is_scale, pydoc::doc("Variation::is_scale") + ) + .def_property_readonly( + "is_emission", + &Variation::is_emission, + pydoc::doc("Variation::is_emission") ); py::classh(m, "PdfGroupInfo", pydoc::doc("PdfGroupInfo")) .def_readonly( @@ -3696,6 +3705,11 @@ PYBIND11_MODULE(_madspace_py, m) { &SystematicsCalculator::scale_variation_indices, pydoc::doc("SystematicsCalculator::scale_variation_indices") ) + .def_property_readonly( + "emission_variation_indices", + &SystematicsCalculator::emission_variation_indices, + pydoc::doc("SystematicsCalculator::emission_variation_indices") + ) .def_property_readonly( "pdf_groups", &SystematicsCalculator::pdf_groups, @@ -3764,20 +3778,27 @@ PYBIND11_MODULE(_madspace_py, m) { const std::vector& fact_scale2, const std::vector& partial_weight_product, const nested_vector3& momenta, - const std::vector& alpha_qcd) { + const std::vector& alpha_qcd, + const nested_vector2& alphas_scales) { // build a combined-layout buffer from columns (testing / scripting) std::size_t count = event_weight.size(); std::size_t particle_count = momenta.empty() ? 0 : momenta.at(0).size(); + // MLM vertex v rides on outgoing particle 2 + v + for (auto& row : alphas_scales) { + particle_count = std::max(particle_count, row.size() + 3); + } + int particle_flags = + (momenta.empty() ? ParticleRecord::f_none + : ParticleRecord::f_particle_data) | + (alphas_scales.empty() ? ParticleRecord::f_none + : ParticleRecord::f_alphas_scales); DataLayout layout( EventRecord::layout( EventRecord::f_weight | EventRecord::f_subproc_index | EventRecord::f_event_data | EventRecord::f_beam1 | EventRecord::f_beam2 | EventRecord::f_partial_weights ), - ParticleRecord::layout( - particle_count > 0 ? ParticleRecord::f_particle_data - : ParticleRecord::f_none - ) + ParticleRecord::layout(particle_flags) ); EventBuffer buffer(count, particle_count, layout); for (std::size_t i = 0; i < count; ++i) { @@ -3795,7 +3816,9 @@ PYBIND11_MODULE(_madspace_py, m) { event.x2() = x2.at(i); event.fact_scale2() = fact_scale2.at(i); event.partial_weight_product() = partial_weight_product.at(i); - for (std::size_t j = 0; j < particle_count; ++j) { + for (std::size_t j = 0; + !momenta.empty() && j < momenta.at(i).size(); + ++j) { auto particle = buffer.particle(i, j); auto& p = momenta.at(i).at(j); particle.energy() = p.at(0); @@ -3803,6 +3826,12 @@ PYBIND11_MODULE(_madspace_py, m) { particle.py() = p.at(2); particle.pz() = p.at(3); } + for (std::size_t j = 0; !alphas_scales.empty() && j < particle_count; + ++j) { + auto& row = alphas_scales.at(i); + buffer.particle(i, j).alphas_scale() = + j >= 2 && j - 2 < row.size() ? row.at(j - 2) : 0.; + } } std::vector weights; calc.compute(buffer, weights); @@ -3826,7 +3855,8 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("fact_scale2"), py::arg("partial_weight_product"), py::arg("momenta") = nested_vector3{}, - py::arg("alpha_qcd") = std::vector{} + py::arg("alpha_qcd") = std::vector{}, + py::arg("alphas_scales") = nested_vector2{} ); py::classh( m, "EventHistogramSpec", pydoc::doc("EventHistogramSpec") diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 55d151f3ac..fe187849d2 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -1456,23 +1456,27 @@ def t_channel_momenta(): return sample_momenta(t_channel_diagram(), batch_size=2000) -def test_alphas_scales_are_clustering_scales_or_the_renormalisation_scale( - process, -): - """Every vertex is handed either its own scale or mu_R, the latter meaning - "not reweighted": its ratio against mu_R is then exactly one and the - consumer needs no mask.""" +def test_alphas_scales_are_clustering_scales_or_zero(process): + """Every vertex is handed either its own scale or 0, the latter meaning + "not reweighted" (evaluated at mu_R). Not mu_R itself: a reweighted vertex + can sit exactly at mu_R, and the systematics vary the two differently.""" _, diagrams, pdg_ids = process momenta = sample_momenta(diagrams) clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) - ren_scale = run(clustering, momenta)[0] scales, weight = alphas_outputs(clustering, momenta) assert np.all(np.isfinite(scales)) - assert np.all(scales > 0.0) + assert np.all(scales >= 0.0) assert np.all((weight == 0.0) | (weight == 1.0)) # gluon-only processes: every vertex is a QCD one, so few of them should - # have been left at mu_R - assert np.mean(np.isclose(scales, ren_scale[:, None])) < 0.5 + # have been left out + assert np.mean(scales == 0.0) < 0.5 + # without alpha_s reweighting none is + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, + alphas_scheme=ms.MLMClustering.AlphasScheme.none, + ) + scales, weight = alphas_outputs(clustering, momenta) + assert np.all(scales == 0.0) and np.all(weight == 1.0) def test_pdf_reweighting_off_leaves_the_density_where_the_scale_is( diff --git a/madspace/tests/test_systematics.py b/madspace/tests/test_systematics.py index bdc49e2476..bc5a272611 100644 --- a/madspace/tests/test_systematics.py +++ b/madspace/tests/test_systematics.py @@ -289,13 +289,14 @@ def test_lhe_event_rwgt_format(): def test_config_json_roundtrip(): grid_file, info_file = grid_files() - config = make_config(write_inputs=True, together=False) + config = make_config(write_inputs=True, together=False, alps=[0.5, 1.0, 2.0]) config.pdf_members = [ ms.PdfMemberSpec(PDF_SET, 338500, 1, grid_file, info_file, "replicas", "d") ] text = config.to_json() back = ms.SystematicsConfig.from_json(text) assert back.mur == config.mur and back.muf == config.muf + assert back.alps == [0.5, 1.0, 2.0] assert back.write_inputs and not back.together assert back.pdf_members[0].grid_file == grid_file args = ms.SubprocessSystArgs(qcd_power=2, beam_pdgs=[[2, 2], [21, 2]]) @@ -460,3 +461,54 @@ def test_event_histograms(grids, ref): for lo, nom, hi in zip(env["low"], data2[0]["bin_values"], env["high"]): assert lo <= nom + 1e-12 and hi >= nom - 1e-12 assert [w["id"] for w in data2[0]["weights"]] == [1, 2] + + +def test_mlm_vertices_follow_the_emission_scale(grids, ref): + """An MLM event carries alpha_s(mu_R)^(n - n_v) prod_v alpha_s(q_v). As in + madevent's systematics.py, a mu_R variation moves only the n - n_v powers, + and an emission scale variation (alpsfact) only the vertices. A vertex + carrying 0 is one the clustering did not reweight: it is one of the mu_R + powers. A reweighted vertex can sit exactly at mu_R (one clustering under + clustering_mean) and is still a vertex.""" + grid, alpha_s = grids + config = make_config(mur=[0.5, 1.0, 2.0], muf=[1.0], alps=[0.5, 1.0, 2.0]) + n = 4 + args = [ms.SubprocessSystArgs(qcd_power=n, beam_pdgs=[[2, 2]])] + calc = ms.SystematicsCalculator(config, args, grid, alpha_s) + variations = calc.variations + assert [(v.mur, v.alps) for v in variations] == [ + (0.5, 1.0), (2.0, 1.0), (1.0, 0.5), (1.0, 2.0) + ] + assert [v.is_emission for v in variations] == [False, False, True, True] + assert calc.emission_variation_indices == [2, 3] + assert 'name="Emission scale variation"' in calc.initrwgt() + assert 'ALPSFACT="0.5"' in calc.initrwgt() + + events = make_events(ref, n=30) + rng = np.random.default_rng(7) + vertices = [] + for i, mu in enumerate(events["ren_scale"]): + # two reweighted vertices, the second at mu_R on every other event, + # and one that is not reweighted + second = mu if i % 2 else mu * rng.uniform(0.05, 0.9) + vertices.append([mu * rng.uniform(0.05, 0.9), second, 0.0]) + weights = calc.weights(**events, alphas_scales=vertices) + for i, row in enumerate(weights): + w0 = events["event_weight"][i] + mu = events["ren_scale"][i] + q = np.array(vertices[i][:2]) + nominal = ref.alphas(mu) ** (n - 2) * np.prod(ref.alphas(q)) + for var, w in zip(variations, row): + varied = ref.alphas(var.mur * mu) ** (n - 2) * np.prod( + ref.alphas(var.alps * q) + ) + assert w == approx(w0 * varied / nominal, rel=1e-12) + + # without the vertex scales in the record: every power follows mu_R, and + # an emission variation is the nominal weight + for w0, mu, row in zip( + events["event_weight"], events["ren_scale"], calc.weights(**events) + ): + assert row[0] == approx(w0 * (ref.alphas(0.5 * mu) / ref.alphas(mu)) ** n, + rel=1e-12) + assert row[2] == approx(w0, rel=1e-15) From f1c150dad082f59e3a0b0232c48b2a233210b62a Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Mon, 26 Oct 2026 18:59:44 +0100 Subject: [PATCH 64/81] Native systematics: vary the MLM pdf ladder with alpsfact and mu_F Under scale_scheme = "madevent" with pdf reweighting an MLM event carries f(x_b, Q_lead) for each beam times f(x z, Q_num) / f(x z, Q_den) for each step of the clustering ladder, every Q a clustering scale capped at mu_F or mu_F itself. madevent's systematics.py varies all of it: a clustering scale q goes to min(alpsfact q, k mu_F), mu_F to k mu_F, and a PDF member evaluates the whole product. The native systematics only varied the leading densities at mu_F (e080c807e6), and alpsfact not at all. - The kernel gains a pdf_rw_mu_f output: whether a step's numerator, and each beam's leading density, is mu_F itself. Telling that from the value would fail again exactly where a capped clustering scale equals mu_F, which is how the alpha_s vertices went wrong. - The integrand hands the ladder on as it applied it (x, the resolved pdg, the clamped scales, beam and flags per step, and the leading scales); the channel generator stores step s on particle s (ParticleRecord::f_pdf_ladder), the combined buffer only, as for the vertex scales, with zeros for an event of a channel without it. - SystematicsCalculator: for an event with a ladder, every variation that touches the PDFs (mu_F, dynamical scale, member, alpsfact) gets W(variation) / W(nominal), both from the same formula, so the central point stays exactly one; a varied denominator under 1e-10 vetoes, as at generation. The ladder scales are clamped to the generation range (config.ladder_min_scale / ladder_max_scale), and every parton of the set is made available (config.pdf_ladder), since a step can carry any. Test: test_mlm_pdf_ladder_follows_alpsfact_and_muf, against the formula evaluated directly, including an event without a ladder. p p > z j + p p > z j j, scale_scheme = madevent, 2000 events: emission band +21 / -16 % (vertex alpha_s alone gave about +-2 %), scale band +9.2 / -11 %, PDF +-4.7 %. --- madgraph/iolibs/template_files/mg7/launch.py | 10 ++ .../madspace/driver/channel_generator.hpp | 3 + madspace/include/madspace/driver/io.hpp | 27 ++++ .../include/madspace/driver/systematics.hpp | 9 ++ .../include/madspace/phasespace/integrand.hpp | 9 +- madspace/instruction_set.yaml | 6 + madspace/src/driver/channel_generator.cpp | 42 +++++ madspace/src/driver/event_generator.cpp | 46 ++++-- madspace/src/driver/systematics.cpp | 144 ++++++++++++++++++ madspace/src/kernels/mlm.hpp | 32 +++- madspace/src/phasespace/integrand.cpp | 125 ++++++++++++++- madspace/src/phasespace/mlm_clustering.cpp | 8 +- madspace/src/python/madspace.cpp | 50 +++++- madspace/tests/test_mlm.py | 1 + madspace/tests/test_systematics.py | 64 ++++++++ 15 files changed, 551 insertions(+), 25 deletions(-) diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index 0327906bdc..5540da8991 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -1148,6 +1148,16 @@ def build_systematics(self): config.alps = [float(v) for v in syst["alps"]] else: config.alps = [1.0] + # the pdf reweighting ladder of an MLM sample under madevent's scale + # scheme, varied as madevent's systematics vary it; its scales are + # clamped to the range the generation clamped them to + beam = self.run_card["beam"] + config.pdf_ladder = bool( + self.mlm_clustering and not self.is_decay and not self.leptonic + and beam["scale_scheme"] == "madevent" and beam["pdf_reweighting"]) + if config.pdf_ladder: + config.ladder_min_scale = float(beam["min_scale"]) + config.ladder_max_scale = float(self.max_scale) config.together = bool(syst["together"]) config.dyn_scales = self.resolve_dynamical_scales() # alternative dynamical scales get the same factor as the nominal one diff --git a/madspace/include/madspace/driver/channel_generator.hpp b/madspace/include/madspace/driver/channel_generator.hpp index 5bd32edada..19836eb378 100644 --- a/madspace/include/madspace/driver/channel_generator.hpp +++ b/madspace/include/madspace/driver/channel_generator.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -221,6 +222,8 @@ class ChannelEventGenerator { int x1, fact_scale1, x2, fact_scale2, partial_weight_product; int cluster_scales; int alphas_scales; + // x, pdg, q_num, q_den, info, lead_q, lead_mu_f of the pdf ladder + std::array ladder; int subprocess_index; int bias; int random, rest; diff --git a/madspace/include/madspace/driver/io.hpp b/madspace/include/madspace/driver/io.hpp index 413b98cd48..d1316ad345 100644 --- a/madspace/include/madspace/driver/io.hpp +++ b/madspace/include/madspace/driver/io.hpp @@ -62,6 +62,10 @@ class ParticleRecord { // outgoing particle 2 + i is vertex i's. Only kept in the combined buffer // the systematics read, never written to an event file. static constexpr int f_alphas_scales = 8; + // MLM pdf reweighting ladder: step s (each one that can carry a pdf + // ratio) on particle s, each beam's leading density on particle 0 and 1. + // Like f_alphas_scales only in the combined buffer, for the systematics. + static constexpr int f_pdf_ladder = 16; static std::vector layout(int fields) { std::vector ret; @@ -101,6 +105,20 @@ class ParticleRecord { if (fields & f_alphas_scales) { ret.push_back({"alphas_scale", FieldLayout::f64, 2}); } + if (fields & f_pdf_ladder) { + ret.insert( + ret.end(), + { + {"ladder_x", FieldLayout::f64, 3}, + {"ladder_pdg", FieldLayout::f64, 3}, + {"ladder_q_num", FieldLayout::f64, 3}, + {"ladder_q_den", FieldLayout::f64, 3}, + {"ladder_info", FieldLayout::f64, 3}, + {"ladder_lead_q", FieldLayout::f64, 3}, + {"ladder_lead_mu_f", FieldLayout::f64, 3}, + } + ); + } return ret; } @@ -133,6 +151,15 @@ class ParticleRecord { // scale alpha_s was evaluated at for one MLM clustering vertex, see // f_alphas_scales UnalignedRef alphas_scale() { return &_data[_offsets[1] + 0]; } + // pdf reweighting ladder, see f_pdf_ladder. ladder_info is + // active + 2 beam + 4 (the numerator scale is mu_F itself). + UnalignedRef ladder_x() { return &_data[_offsets[2] + 0]; } + UnalignedRef ladder_pdg() { return &_data[_offsets[2] + 8]; } + UnalignedRef ladder_q_num() { return &_data[_offsets[2] + 16]; } + UnalignedRef ladder_q_den() { return &_data[_offsets[2] + 24]; } + UnalignedRef ladder_info() { return &_data[_offsets[2] + 32]; } + UnalignedRef ladder_lead_q() { return &_data[_offsets[2] + 40]; } + UnalignedRef ladder_lead_mu_f() { return &_data[_offsets[2] + 48]; } void from_lhe_particle(const LHEParticle& particle, bool has_clustering = false) { lhe_pdg_id() = particle.pdg_id; diff --git a/madspace/include/madspace/driver/systematics.hpp b/madspace/include/madspace/driver/systematics.hpp index 842550cacc..3d667f1443 100644 --- a/madspace/include/madspace/driver/systematics.hpp +++ b/madspace/include/madspace/driver/systematics.hpp @@ -50,6 +50,15 @@ struct SystematicsConfig { /// written in an "Emission scale variation" group. Only for events whose /// record carries the vertex scales (ParticleRecord::f_alphas_scales). std::vector alps{1.}; + /// If true, the events may carry an MLM pdf reweighting ladder + /// (ParticleRecord::f_pdf_ladder): every parton of the PDF set is then + /// made available, since a ladder step can be any of them. + bool pdf_ladder = false; + /// Range the scales of the pdf ladder are clamped to, as the integrand + /// does (EnergyScale's min_scale / max_scale); `0` disables a bound. + double ladder_min_scale = 0.; + /// See @ref ladder_min_scale. + double ladder_max_scale = 0.; /// If true, vary every `(mur, muf)` combination; if false, vary one /// factor at a time. bool together = true; diff --git a/madspace/include/madspace/phasespace/integrand.hpp b/madspace/include/madspace/phasespace/integrand.hpp index 9f717e9e9c..decca9daf4 100644 --- a/madspace/include/madspace/phasespace/integrand.hpp +++ b/madspace/include/madspace/phasespace/integrand.hpp @@ -258,12 +258,15 @@ class Integrand : public FunctionGenerator { Value flavor_id ) const; // The factors an MLM clustering puts on the event weight: the merging cut, - // the alpha_s and the pdf reweighting, each with its veto. + // the alpha_s and the pdf reweighting, each with its veto. With `ladder`, + // also the pdf reweighting ladder itself, as the systematics need it to + // vary it (see emits_pdf_ladder in integrand.cpp). ValueVec mlm_weights( FunctionBuilder& fb, const NamedVector& scales, const std::array& x, - Value flavor_id + Value flavor_id, + NamedVector* ladder = nullptr ) const; PhaseSpaceMapping _mapping; @@ -286,6 +289,8 @@ class Integrand : public FunctionGenerator { // with the class offsets alongside so that the two can be added as ints. std::vector _pdf_rw_table; std::vector _pdf_rw_class_offsets; + // pdg id of each parton of _pdf_rw, as a float for gather + std::vector _pdf_rw_pdgs; std::optional _running_coupling; std::optional _energy_scale; // One row with a 1 for every matrix-element diagram the MLM clustering has diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index 649b12ffad..3cec0b6065 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -2856,6 +2856,9 @@ mlm_clustering_hadronic: - name: pdf_rw_beam type: [float, n-1] desc: which beam each step sits on, as 0 or 1 + - name: pdf_rw_mu_f + type: [float, n+1] + desc: one where a scale is mu_F itself rather than a clustering scale, for each step's numerator, then each beam's leading density vectorized: False @@ -2976,6 +2979,9 @@ mlm_clustering_leptonic: - name: pdf_rw_beam type: [float, n-1] desc: which beam each step sits on, as 0 or 1 + - name: pdf_rw_mu_f + type: [float, n+1] + desc: one where a scale is mu_F itself rather than a clustering scale, for each step's numerator, then each beam's leading density vectorized: False diff --git a/madspace/src/driver/channel_generator.cpp b/madspace/src/driver/channel_generator.cpp index f692d2cce7..4ce2f98fef 100644 --- a/madspace/src/driver/channel_generator.cpp +++ b/madspace/src/driver/channel_generator.cpp @@ -37,6 +37,9 @@ int particle_extra_flags( if (index_map.contains("alphas_scales")) { flags |= ParticleRecord::f_alphas_scales; } + if (index_map.contains("ladder_x")) { + flags |= ParticleRecord::f_pdf_ladder; + } return flags; } @@ -334,6 +337,22 @@ void ChannelEventGenerator::init_field_indices() { } else { _field_indices.alphas_scales = -1; } + if (index_map.contains("ladder_x")) { + const char* names[] = { + "ladder_x", + "ladder_pdg", + "ladder_q_num", + "ladder_q_den", + "ladder_info", + "ladder_lead_q", + "ladder_lead_mu_f", + }; + for (std::size_t k = 0; k < _field_indices.ladder.size(); ++k) { + _field_indices.ladder[k] = index_map.at(names[k]); + } + } else { + _field_indices.ladder.fill(-1); + } if (index_map.contains("subprocess_index")) { _field_indices.subprocess_index = index_map.at("subprocess_index"); } else { @@ -850,6 +869,29 @@ void ChannelEventGenerator::write_events( } } + if (_field_indices.ladder[0] != -1) { + auto slot_view = [&](std::size_t k) { + return unweighted_events.at(_field_indices.ladder[k]).view(); + }; + auto x_view = slot_view(0), pdg_view = slot_view(1), num_view = slot_view(2), + den_view = slot_view(3), info_view = slot_view(4), + lead_q_view = slot_view(5), lead_mu_f_view = slot_view(6); + for (std::size_t i = 0; i < w_view.size(); ++i) { + std::size_t slot_count = x_view[i].size(); + for (std::size_t j = 0; j < _particle_count; ++j) { + auto particle = event_buffer.particle(i, j); + bool has_slot = j < slot_count; + particle.ladder_x() = has_slot ? x_view[i][j] : 0.; + particle.ladder_pdg() = has_slot ? pdg_view[i][j] : 0.; + particle.ladder_q_num() = has_slot ? num_view[i][j] : 0.; + particle.ladder_q_den() = has_slot ? den_view[i][j] : 0.; + particle.ladder_info() = has_slot ? info_view[i][j] : 0.; + particle.ladder_lead_q() = j < 2 ? lead_q_view[i][j] : 0.; + particle.ladder_lead_mu_f() = j < 2 ? lead_mu_f_view[i][j] : 0.; + } + } + } + if (_field_indices.bias != -1) { auto bias_view = unweighted_events.at(_field_indices.bias).view(); for (std::size_t i = 0; i < bias_view.size(); ++i) { diff --git a/madspace/src/driver/event_generator.cpp b/madspace/src/driver/event_generator.cpp index ae8497e7bb..4845250b89 100644 --- a/madspace/src/driver/event_generator.cpp +++ b/madspace/src/driver/event_generator.cpp @@ -918,17 +918,28 @@ void EventGenerator::combine_to_lhe( namespace { -// The MLM alpha_s vertex scales of any channel: a merged run mixes a 2 -> 1 -// sample, which has no vertex, with the jet samples, which do, and channel 0 -// may be either. +// The MLM alpha_s vertex scales and pdf ladder of any channel: a merged run +// mixes a 2 -> 1 sample, which has no vertex, with the jet samples, which do, +// and channel 0 may be either. template int any_alphas_scales(const Channels& channels) { + int flags = 0; for (auto& channel : channels) { - if (channel->particle_layout_extra_flags() & ParticleRecord::f_alphas_scales) { - return ParticleRecord::f_alphas_scales; - } + flags |= channel->particle_layout_extra_flags() & + (ParticleRecord::f_alphas_scales | ParticleRecord::f_pdf_ladder); } - return 0; + return flags; +} + +// the pdf ladder fields of one particle, or zeros (an inactive slot) +void copy_ladder(ParticleRecord in, ParticleRecord out, bool from_in) { + out.ladder_x() = from_in ? in.ladder_x().value() : 0.; + out.ladder_pdg() = from_in ? in.ladder_pdg().value() : 0.; + out.ladder_q_num() = from_in ? in.ladder_q_num().value() : 0.; + out.ladder_q_den() = from_in ? in.ladder_q_den().value() : 0.; + out.ladder_info() = from_in ? in.ladder_info().value() : 0.; + out.ladder_lead_q() = from_in ? in.ladder_lead_q().value() : 0.; + out.ladder_lead_mu_f() = from_in ? in.ladder_lead_mu_f().value() : 0.; } } // namespace @@ -963,13 +974,14 @@ EventGenerator::output_layout(int event_flags, std::size_t particle_flags) const extra_flags |= EventRecord::f_syst_weights; } } - // the alpha_s vertex scales only serve the systematics, which run on the - // combined buffer: no event file carries them + // the alpha_s vertex scales and the pdf ladder only serve the systematics, + // which run on the combined buffer: no event file carries them return DataLayout( EventRecord::layout(event_flags | extra_flags, syst_ids), ParticleRecord::layout( - particle_flags | (_channels.at(0)->particle_layout_extra_flags() & - ~ParticleRecord::f_alphas_scales) + particle_flags | + (_channels.at(0)->particle_layout_extra_flags() & + ~(ParticleRecord::f_alphas_scales | ParticleRecord::f_pdf_ladder)) ) ); } @@ -1206,7 +1218,9 @@ void EventGenerator::read_and_combine( _channels.at(0)->event_layout_extra_flags() & EventRecord::f_partial_weights; bool has_clustering = _channels.at(0)->particle_layout_extra_flags() & ParticleRecord::f_clustering; - bool has_alphas_scales = any_alphas_scales(_channels) != 0; + int mlm_flags = any_alphas_scales(_channels); + bool has_alphas_scales = mlm_flags & ParticleRecord::f_alphas_scales; + bool has_ladder = mlm_flags & ParticleRecord::f_pdf_ladder; bool has_subproc_index = _channels.at(0)->event_layout_extra_flags() & EventRecord::f_subproc_index; bool has_bias = _channels.at(0)->event_layout_extra_flags() & EventRecord::f_bias; @@ -1225,6 +1239,8 @@ void EventGenerator::read_and_combine( auto& channel = _channels.at(sampled_chan - channel_data.begin()); bool channel_has_alphas_scales = channel->particle_layout_extra_flags() & ParticleRecord::f_alphas_scales; + bool channel_has_ladder = + channel->particle_layout_extra_flags() & ParticleRecord::f_pdf_ladder; double weight = 0.; while (true) { @@ -1293,6 +1309,9 @@ void EventGenerator::read_and_combine( ? particle_in.alphas_scale().value() : 0.; } + if (has_ladder) { + copy_ladder(particle_in, particle_out, channel_has_ladder); + } } for (; i < buffer.particle_count(); ++i) { auto particle_out = buffer.particle(event_index, i); @@ -1308,6 +1327,9 @@ void EventGenerator::read_and_combine( if (has_alphas_scales) { particle_out.alphas_scale() = 0.; } + if (has_ladder) { + copy_ladder(particle_out, particle_out, false); + } } ++sampled_chan->buffer_index; } diff --git a/madspace/src/driver/systematics.cpp b/madspace/src/driver/systematics.cpp index 214609f419..c485999682 100644 --- a/madspace/src/driver/systematics.cpp +++ b/madspace/src/driver/systematics.cpp @@ -230,6 +230,15 @@ SystematicsCalculator::SystematicsCalculator( } } } + if (_config.pdf_ladder && nominal_pdf) { + // a ladder step carries whichever parton the beam line had there + for (int pdg : nominal_pdf->pids) { + bool parton = pdg == 21 || (pdg != 0 && std::abs(pdg) <= 5); + if (parton && std::find(pids.begin(), pids.end(), pdg) == pids.end()) { + pids.push_back(pdg); + } + } + } std::sort(pids.begin(), pids.end()); if (_config.has_pdf) { _nominal_pdf = make_pdf_evaluator(nominal_pdf.value(), pids, "nominal", 0); @@ -697,12 +706,25 @@ void SystematicsCalculator::compute( bool use_pdf = _config.has_pdf && _nominal_pdf && (has_beam1 || has_beam2); bool has_dyn = !_config.dyn_scales.empty(); bool has_vertices = particle_layout_has(layout, "alphas_scale"); + bool has_ladder = particle_layout_has(layout, "ladder_x"); // per-event inputs struct EventInput { // the scales of the MLM clustering vertices whose alpha_s is evaluated // at their own scale rather than at mu_R (empty if none) std::vector vertex_scales; + // the MLM pdf reweighting ladder: each beam's leading density (its + // scale, and whether that is mu_F itself), then every active step + struct LadderStep { + double x, q_num, q_den; + me_int_t slot; + int beam; + bool num_mu_f; + }; + std::vector ladder; + std::array lead_q{0., 0.}; + std::array lead_mu_f{true, true}; + bool has_ladder = false; double w0, ren_scale, x1, x2, muf1, muf2, alpha_s, nominal_product; int subproc, qcd_power; me_int_t slot1, slot2; @@ -752,6 +774,30 @@ void SystematicsCalculator::compute( } } } + if (has_ladder && use_pdf) { + for (int b = 0; b < 2; ++b) { + auto particle = buffer.particle(i, b); + in.lead_q[b] = particle.ladder_lead_q(); + in.lead_mu_f[b] = particle.ladder_lead_mu_f() != 0.; + } + // a 2 -> 1 event of a merged run has no ladder: all zero + in.has_ladder = in.lead_q[0] > 0. || in.lead_q[1] > 0.; + for (std::size_t j = 0; in.has_ladder && j < buffer.particle_count(); ++j) { + auto particle = buffer.particle(i, j); + int info = static_cast(particle.ladder_info()); + if (!(info & 1)) { + continue; + } + in.ladder.push_back({ + .x = particle.ladder_x(), + .q_num = particle.ladder_q_num(), + .q_den = particle.ladder_q_den(), + .slot = pid_slot(static_cast(particle.ladder_pdg())), + .beam = (info >> 1) & 1, + .num_mu_f = static_cast((info >> 2) & 1), + }); + } + } in.dyn_scale.fill(0.); if (has_dyn) { auto momenta = event_momenta(buffer, i); @@ -896,6 +942,98 @@ void SystematicsCalculator::compute( } } + // MLM pdf reweighting ladder, as madevent's systematics.py: the event + // carries f(x_b, Q_lead) for each beam times f(x_s, Q_num) / f(x_s, Q_den) + // for each step of the ladder, every Q a clustering scale capped at mu_F + // or mu_F itself. A variation takes a clustering scale q to + // min(alpsfact q, k S) and mu_F to k S, S being mu_F or the alternative + // dynamical scale, and a PDF member evaluates the whole product. The + // nominal product is recomputed the same way, so that the central point + // is exactly one. + std::vector ladder_events; + for (std::size_t i = 0; use_pdf && i < count; ++i) { + if (inputs[i].has_ladder) { + ladder_events.push_back(i); + } + } + if (!ladder_events.empty()) { + double low = _config.ladder_min_scale; + double high = _config.ladder_max_scale > 0. ? _config.ladder_max_scale : 1e30; + // W for every ladder event at one variation; 0 where madevent vetoes + // (a density divided by under 1e-10) + auto ladder_weights = [&](const PdfEvaluator& evaluator, const Variation& var) { + std::vector x, q; + std::vector slots; + for (std::size_t i : ladder_events) { + auto& in = inputs[i]; + std::array top{ + var.muf * scale_of(in, var.dyn, in.muf1), + var.muf * scale_of(in, var.dyn, in.muf2) + }; + auto vary = [&](double scale, bool is_mu_f, int beam) { + double varied = + is_mu_f ? top[beam] : std::min(var.alps * scale, top[beam]); + return std::min(std::max(varied, low), high); + }; + std::array beam_x{in.x1, in.x2}; + std::array beam_slot{in.slot1, in.slot2}; + std::array has_beam{has_beam1, has_beam2}; + for (int b = 0; b < 2; ++b) { + if (has_beam[b]) { + x.push_back(beam_x[b]); + q.push_back(vary(in.lead_q[b], in.lead_mu_f[b], b)); + slots.push_back(beam_slot[b]); + } + } + for (auto& step : in.ladder) { + x.push_back(step.x); + q.push_back(vary(step.q_num, step.num_mu_f, step.beam)); + slots.push_back(step.slot); + x.push_back(step.x); + q.push_back(vary(step.q_den, false, step.beam)); + slots.push_back(step.slot); + } + } + auto values = evaluate_pdf(evaluator, x, q, slots); + std::vector result; + std::size_t n = 0; + for (std::size_t i : ladder_events) { + auto& in = inputs[i]; + double w = 1.; + if (has_beam1) { + w *= values[n++]; + } + if (has_beam2) { + w *= values[n++]; + } + for (std::size_t s = 0; s < in.ladder.size(); ++s) { + double numerator = values[n++]; + double denominator = values[n++]; + w = denominator >= 1e-10 ? w * numerator / denominator : 0.; + } + result.push_back(w); + } + return result; + }; + Variation central{0, 1., 1., -1, -1, 1.}; + auto nominal = ladder_weights(_nominal_pdf.value(), central); + for (std::size_t k = 0; k < var_count; ++k) { + auto& var = _variations.at(k); + bool varies_pdf = var.pdf_index >= 0 || var.muf != 1. || var.dyn != -1 || + var.alps != 1.; + if (!varies_pdf) { + continue; + } + auto& evaluator = var.pdf_index >= 0 ? _member_pdfs.at(var.pdf_index) + : _nominal_pdf.value(); + auto varied = ladder_weights(evaluator, var); + for (std::size_t n = 0; n < ladder_events.size(); ++n) { + r_pdf[ladder_events[n]][k] = + nominal[n] == 0. ? 0. : varied[n] / nominal[n]; + } + } + } + // alpha_s part: the distinct (alpha_s grid, mur, dyn) combinations, one // batched alpha_s call each; the matrix element is re-evaluated for the // events of the mixed-order subprocesses @@ -1294,6 +1432,9 @@ void madspace::to_json(json& j, const SystematicsConfig& config) { {"mur", config.mur}, {"muf", config.muf}, {"alps", config.alps}, + {"pdf_ladder", config.pdf_ladder}, + {"ladder_min_scale", config.ladder_min_scale}, + {"ladder_max_scale", config.ladder_max_scale}, {"together", config.together}, {"dyn_scales", config.dyn_scales}, {"scale_factor", config.scale_factor}, @@ -1312,6 +1453,9 @@ void madspace::from_json(const json& j, SystematicsConfig& config) { config.mur = j.at("mur").get>(); config.muf = j.at("muf").get>(); config.alps = j.value("alps", std::vector{1.}); + config.pdf_ladder = j.value("pdf_ladder", false); + config.ladder_min_scale = j.value("ladder_min_scale", 0.); + config.ladder_max_scale = j.value("ladder_max_scale", 0.); config.together = j.value("together", true); config.dyn_scales = j.value("dyn_scales", std::vector{}); config.scale_factor = j.value("scale_factor", 1.); diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index fa2db8b30a..500dd1a01c 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -309,6 +309,7 @@ KERNELSPEC int mlm_clustering_walk( FOut pdf_rw_q_den, FOut pdf_rw_active, FOut pdf_rw_beam, + FOut pdf_rw_mu_f, int start_state, bool hadronic ) { @@ -788,6 +789,13 @@ KERNELSPEC int mlm_clustering_walk( // for the rest it depends on which clustering won, so it has to travel // with the slot. FVal rw_beam[N_EXT_MAX - 1]; + // Which scales are mu_F itself rather than a clustering scale capped at + // it: the numerator of the step that lands on mu_F, and a beam density + // that was never lowered. The systematics vary the two differently, as + // madevent's do: mu_F with the factorisation scale, a clustering scale + // with alpsfact. + FVal rw_mu_f[N_EXT_MAX - 1]; + FVal lead_mu_f[2] = {1.0, 1.0}; for (int i = 0; i < pdf_slot_count; ++i) { rw_flavor[i] = 0; rw_x[i] = 1.0; @@ -795,6 +803,7 @@ KERNELSPEC int mlm_clustering_walk( rw_q_den[i] = 1.0; rw_active[i] = 0.0; rw_beam[i] = 0.0; + rw_mu_f[i] = 0.0; } FVal pdf_scale_val[2] = {-1.0, -1.0}; @@ -1303,6 +1312,7 @@ KERNELSPEC int mlm_clustering_walk( for (int j = 0; j < 2; ++j) { if (jlast[j] >= 0 && jfirst[j] >= 0 && jfirst[j] <= jlast[j]) { pdf_scale_val[j] = min(pt_step[jfirst[j]], q_central[j]); + lead_mu_f[j] = 0.0; } } // Where each beam's line has got to: its flavour class, the @@ -1351,6 +1361,7 @@ KERNELSPEC int mlm_clustering_walk( rw_q_den[slot] = pt_pdf[j]; rw_active[slot] = 1.0; rw_beam[slot] = j; + rw_mu_f[slot] = i == jlast[j] ? 1.0 : 0.0; pt_pdf[j] = q_now; } // and the line becomes the mother; at the root that is the @@ -1482,7 +1493,10 @@ KERNELSPEC int mlm_clustering_walk( pdf_rw_q_den[i] = rw_q_den[i]; pdf_rw_active[i] = rw_active[i]; pdf_rw_beam[i] = rw_beam[i]; + pdf_rw_mu_f[i] = rw_mu_f[i]; } + pdf_rw_mu_f[pdf_slot_count] = lead_mu_f[0]; + pdf_rw_mu_f[pdf_slot_count + 1] = lead_mu_f[1]; int diag_count = state_machine[state]; int rand_index = static_cast(FVal(random) * diag_count); @@ -1571,6 +1585,7 @@ KERNELSPEC void mlm_clustering( FOut pdf_rw_q_den, FOut pdf_rw_active, FOut pdf_rw_beam, + FOut pdf_rw_mu_f, bool hadronic ) { auto walk = [&](int start) { @@ -1611,6 +1626,7 @@ KERNELSPEC void mlm_clustering( pdf_rw_q_den, pdf_rw_active, pdf_rw_beam, + pdf_rw_mu_f, start, hadronic ); @@ -1634,7 +1650,7 @@ KERNELSPEC void mlm_clustering( IVal kept_rw_flavor[N_EXT_MAX]; FVal kept_rw_x[N_EXT_MAX], kept_rw_q_num[N_EXT_MAX], kept_rw_q_den[N_EXT_MAX], kept_rw_active[N_EXT_MAX], - kept_rw_beam[N_EXT_MAX]; + kept_rw_beam[N_EXT_MAX], kept_rw_mu_f[N_EXT_MAX + 1]; for (int i = 0; i < n_part - 2; ++i) { kept_outgoing[i] = outgoing_scales[i]; } @@ -1649,6 +1665,9 @@ KERNELSPEC void mlm_clustering( kept_rw_active[i] = pdf_rw_active[i]; kept_rw_beam[i] = pdf_rw_beam[i]; } + for (int i = 0; i < n_part + 1; ++i) { + kept_rw_mu_f[i] = pdf_rw_mu_f[i]; + } if (walk(0) != diagram_jets) { ren_scale = kept_ren; fact_scale1 = kept_fact1; @@ -1672,6 +1691,9 @@ KERNELSPEC void mlm_clustering( pdf_rw_active[i] = kept_rw_active[i]; pdf_rw_beam[i] = kept_rw_beam[i]; } + for (int i = 0; i < n_part + 1; ++i) { + pdf_rw_mu_f[i] = kept_rw_mu_f[i]; + } } } else { walk(start); @@ -1717,7 +1739,8 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( FOut pdf_rw_q_num, FOut pdf_rw_q_den, FOut pdf_rw_active, - FOut pdf_rw_beam + FOut pdf_rw_beam, + FOut pdf_rw_mu_f ) { mlm_clustering( momenta, @@ -1758,6 +1781,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( pdf_rw_q_den, pdf_rw_active, pdf_rw_beam, + pdf_rw_mu_f, true ); } @@ -1801,7 +1825,8 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( FOut pdf_rw_q_num, FOut pdf_rw_q_den, FOut pdf_rw_active, - FOut pdf_rw_beam + FOut pdf_rw_beam, + FOut pdf_rw_mu_f ) { mlm_clustering( momenta, @@ -1842,6 +1867,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( pdf_rw_q_den, pdf_rw_active, pdf_rw_beam, + pdf_rw_mu_f, false ); } diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index f3b231a372..73e92cee3a 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -52,6 +52,24 @@ bool emits_alphas_scales( running_coupling && particle_count > 3; } +// Whether the event carries the MLM pdf reweighting ladder, for the +// systematics to vary it as madevent's do: every clustering scale with +// alpsfact, mu_F with the factorisation scale, and the whole ladder with each +// PDF member. +bool emits_pdf_ladder( + const std::optional& energy_scale, bool has_pdf, bool has_pid_options +) { + return energy_scale && energy_scale->mlm_pdf_reweighting() && has_pdf && + has_pid_options; +} + +// the per-slot ladder outputs (one entry per slot that can carry a ratio, +// slot 0 never does) and the two per-beam ones +const std::array ladder_slot_names{ + "ladder_x", "ladder_pdg", "ladder_q_num", "ladder_q_den", "ladder_info" +}; +const std::array ladder_beam_names{"ladder_lead_q", "ladder_lead_mu_f"}; + bool emits_partial_weights( bool partial_weights, const std::optional& energy_scale ) { @@ -197,6 +215,20 @@ Integrand::Integrand( batch_float_array(mapping.particle_count() - 3) ); } + if (emits_pdf_ladder( + energy_scale, + !std::holds_alternative(pdf_grid), + flav_count > 0 + )) { + for (auto name : ladder_slot_names) { + ret_types.push_back( + name, batch_float_array(mapping.particle_count() - 2) + ); + } + for (auto name : ladder_beam_names) { + ret_types.push_back(name, batch_float_array(2)); + } + } // before "random": the outputs after it feed the discrete histograms if (custom_bias) { ret_types.push_back("bias", batch_float); @@ -355,6 +387,7 @@ Integrand::Integrand( ); } _pdf_rw = PartonDensity(*grid, rw_pids, true); + _pdf_rw_pdgs.assign(rw_pids.begin(), rw_pids.end()); } if (custom_bias) { if (custom_bias->type() != CustomFunction::bias) { @@ -516,6 +549,14 @@ NamedVector Integrand::compute_channel_part_ret_types() const { if (emits_alphas_scales(_energy_scale, _running_coupling, particle_count)) { ret.push_back("alphas_scales_acc", acc_float_array(particle_count - 3)); } + if (_pdf_rw && _pid_options.size() > 0) { + for (auto name : ladder_slot_names) { + ret.push_back(std::format("{}_acc", name), acc_float_array(particle_count - 2)); + } + for (auto name : ladder_beam_names) { + ret.push_back(std::format("{}_acc", name), acc_float_array(2)); + } + } return ret; } @@ -815,8 +856,9 @@ NamedVector Integrand::build_channel_part( } } + NamedVector mlm_ladder; if (_energy_scale && _energy_scale->is_mlm() && !mlm_history_per_diagram) { - for (auto& weight : mlm_weights(fb, scales, x_acc, flavor_id)) { + for (auto& weight : mlm_weights(fb, scales, x_acc, flavor_id, &mlm_ladder)) { weights_after_cuts.push_back(weight); } } @@ -884,6 +926,27 @@ NamedVector Integrand::build_channel_part( )) { out.push_back("alphas_scales_acc", scales.at("alphas_scales")); } + if (_pdf_rw && _pid_options.size() > 0) { + // with the history picked per diagram the ladder is only known in + // the common part: zeros here, never read + auto batch_size = fb.batch_size({momenta_acc}); + auto slot_count = static_cast(_mapping.particle_count() - 2); + for (auto name : ladder_slot_names) { + out.push_back( + std::format("{}_acc", name), + mlm_ladder.size() > 0 ? mlm_ladder.at(name) + : fb.full({0., batch_size, slot_count}) + ); + } + for (auto name : ladder_beam_names) { + out.push_back( + std::format("{}_acc", name), + mlm_ladder.size() > 0 + ? mlm_ladder.at(name) + : fb.full({0., batch_size, static_cast(2)}) + ); + } + } return out; } @@ -892,7 +955,8 @@ ValueVec Integrand::mlm_weights( FunctionBuilder& fb, const NamedVector& scales, const std::array& x, - Value flavor_id + Value flavor_id, + NamedVector* ladder ) const { ValueVec weights; auto batch_size_acc = fb.batch_size({flavor_id}); @@ -953,6 +1017,8 @@ ValueVec Integrand::mlm_weights( : 1e30; auto ones = fb.full({1., batch_size_acc}); Value factor, veto; + ValueVec ladder_x, ladder_pdg, ladder_q_num, ladder_q_den, ladder_info; + auto mu_f_flags = scales.at("pdf_rw_mu_f"); // Slot 0 is the first clustering of the history, which can only ever // record where a beam line's density already sits, so it never // carries a ratio. @@ -994,6 +1060,18 @@ ValueVec Integrand::mlm_weights( }; auto numerator = density("pdf_rw_q_num"); auto denominator = density("pdf_rw_q_den"); + if (ladder) { + // active + 2 beam + 4 (numerator is mu_F itself) + auto mu_f = fb.squeeze(fb.select(mu_f_flags, column)); + ladder_x.push_back(x); + ladder_pdg.push_back(fb.gather(index, _pdf_rw_pdgs)); + ladder_q_num.push_back(scale_of("pdf_rw_q_num")); + ladder_q_den.push_back(scale_of("pdf_rw_q_den")); + ladder_info.push_back(fb.add( + active, + fb.add(fb.mul(beam, 2.), fb.mul(mu_f, 4.)) + )); + } // madevent drops the event outright when the density it is // dividing by falls under 1e-10, where the grid is no longer // saying anything (reweight.f, rewgt=0 for pdfj2 < 1e-10). Same @@ -1019,6 +1097,22 @@ ValueVec Integrand::mlm_weights( weights.push_back(factor); weights.push_back(veto); } + if (ladder && !ladder_x.empty()) { + ladder->push_back("ladder_x", fb.stack(ladder_x)); + ladder->push_back("ladder_pdg", fb.stack(ladder_pdg)); + ladder->push_back("ladder_q_num", fb.stack(ladder_q_num)); + ladder->push_back("ladder_q_den", fb.stack(ladder_q_den)); + ladder->push_back("ladder_info", fb.stack(ladder_info)); + // each beam's leading density: its scale (as evaluated, clamped + // by apply_scale_range), and whether that is mu_F itself + std::vector leads{ + static_cast(slot_count), static_cast(slot_count + 1) + }; + ladder->push_back( + "ladder_lead_q", fb.stack({scales.at("pdf_scale1"), scales.at("pdf_scale2")}) + ); + ladder->push_back("ladder_lead_mu_f", fb.select(mu_f_flags, leads)); + } } return weights; } @@ -1126,6 +1220,16 @@ NamedVector Integrand::build_common_part( if (has_alphas_scales) { alphas_scales_acc = args.at("alphas_scales_acc"); } + bool has_ladder = _pdf_rw && _pid_options.size() > 0; + NamedVector ladder_acc; + if (has_ladder) { + for (auto name : ladder_slot_names) { + ladder_acc.push_back(name, args.at(std::format("{}_acc", name))); + } + for (auto name : ladder_beam_names) { + ladder_acc.push_back(name, args.at(std::format("{}_acc", name))); + } + } // Evaluate differential cross section auto make_xs_args = [&](std::array& pdfs, Value alpha) { @@ -1235,7 +1339,12 @@ NamedVector Integrand::build_common_part( _mlm_leg_flavors ); std::array x_acc{x1_acc, x2_acc}; - mlm_history_weights = mlm_weights(fb, scales, x_acc, flavor_id); + NamedVector history_ladder; + mlm_history_weights = + mlm_weights(fb, scales, x_acc, flavor_id, &history_ladder); + for (auto& name : history_ladder.keys()) { + ladder_acc.at(name) = history_ladder.at(name); + } if (_energy_scale->has_scale_veto()) { mlm_history_weights.push_back(scales.at("scale_weight")); } @@ -1464,6 +1573,16 @@ NamedVector Integrand::build_common_part( ) ); } + if (has_ladder) { + for (auto& name : ladder_acc.keys()) { + auto& value = ladder_acc.at(name); + auto width = static_cast(value.type.shape.at(0)); + outputs.push_back( + name, + scatter_or_drop(fb.full({0., batch_size_val, width}), value) + ); + } + } if (has_alphas_scales) { auto vertex_count = static_cast(_mapping.particle_count() - 3); outputs.push_back( diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index 4a5f5dde75..7d7d44805e 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -655,7 +655,11 @@ MLMClustering::MLMClustering( {"pdf_rw_active", batch_float_array(topologies.at(0).outgoing_masses().size() + 1)}, {"pdf_rw_beam", - batch_float_array(topologies.at(0).outgoing_masses().size() + 1)}} + batch_float_array(topologies.at(0).outgoing_masses().size() + 1)}, + // whether a scale is mu_F itself rather than a clustering scale: + // each step's numerator, then each beam's leading density + {"pdf_rw_mu_f", + batch_float_array(topologies.at(0).outgoing_masses().size() + 3)}} ), _cm_energy(cm_energy), _jet_scale_scheme(jet_scale_scheme), @@ -1065,7 +1069,7 @@ NamedVector MLMClustering::build_kernel( Value flavor_index, const std::vector& leg_flavors ) const { - std::array mlm_out; + std::array mlm_out; Value random = fb.squeeze(fb.random(fb.batch_size({momenta}), 1)); // Without the flavours of the event a single row of zeros, which leaves // every clustering allowed: the history over the union of all diagrams. diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index 62378a09ad..aba6e2ac65 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -3549,6 +3549,21 @@ PYBIND11_MODULE(_madspace_py, m) { .def_readwrite( "alps", &SystematicsConfig::alps, pydoc::doc("SystematicsConfig::alps") ) + .def_readwrite( + "pdf_ladder", + &SystematicsConfig::pdf_ladder, + pydoc::doc("SystematicsConfig::pdf_ladder") + ) + .def_readwrite( + "ladder_min_scale", + &SystematicsConfig::ladder_min_scale, + pydoc::doc("SystematicsConfig::ladder_min_scale") + ) + .def_readwrite( + "ladder_max_scale", + &SystematicsConfig::ladder_max_scale, + pydoc::doc("SystematicsConfig::ladder_max_scale") + ) .def_readwrite( "together", &SystematicsConfig::together, @@ -3779,7 +3794,9 @@ PYBIND11_MODULE(_madspace_py, m) { const std::vector& partial_weight_product, const nested_vector3& momenta, const std::vector& alpha_qcd, - const nested_vector2& alphas_scales) { + const nested_vector2& alphas_scales, + const nested_vector3& ladder, + const nested_vector2& ladder_lead) { // build a combined-layout buffer from columns (testing / scripting) std::size_t count = event_weight.size(); std::size_t particle_count = momenta.empty() ? 0 : momenta.at(0).size(); @@ -3787,11 +3804,21 @@ PYBIND11_MODULE(_madspace_py, m) { for (auto& row : alphas_scales) { particle_count = std::max(particle_count, row.size() + 3); } + // the pdf ladder: step s ([x, pdg, q_num, q_den, info]) on + // particle s, and [q_lead1, q_lead2, mu_f1, mu_f2] per event + for (auto& steps : ladder) { + particle_count = std::max(particle_count, steps.size()); + } + if (!ladder_lead.empty()) { + particle_count = std::max(particle_count, 2); + } int particle_flags = (momenta.empty() ? ParticleRecord::f_none : ParticleRecord::f_particle_data) | (alphas_scales.empty() ? ParticleRecord::f_none - : ParticleRecord::f_alphas_scales); + : ParticleRecord::f_alphas_scales) | + (ladder_lead.empty() ? ParticleRecord::f_none + : ParticleRecord::f_pdf_ladder); DataLayout layout( EventRecord::layout( EventRecord::f_weight | EventRecord::f_subproc_index | @@ -3832,6 +3859,21 @@ PYBIND11_MODULE(_madspace_py, m) { buffer.particle(i, j).alphas_scale() = j >= 2 && j - 2 < row.size() ? row.at(j - 2) : 0.; } + for (std::size_t j = 0; !ladder_lead.empty() && j < particle_count; + ++j) { + auto particle = buffer.particle(i, j); + auto& lead = ladder_lead.at(i); + particle.ladder_lead_q() = j < 2 ? lead.at(j) : 0.; + particle.ladder_lead_mu_f() = j < 2 ? lead.at(j + 2) : 0.; + bool has_step = !ladder.empty() && j < ladder.at(i).size(); + auto step = has_step ? ladder.at(i).at(j) + : std::vector(5, 0.); + particle.ladder_x() = step.at(0); + particle.ladder_pdg() = step.at(1); + particle.ladder_q_num() = step.at(2); + particle.ladder_q_den() = step.at(3); + particle.ladder_info() = step.at(4); + } } std::vector weights; calc.compute(buffer, weights); @@ -3856,7 +3898,9 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("partial_weight_product"), py::arg("momenta") = nested_vector3{}, py::arg("alpha_qcd") = std::vector{}, - py::arg("alphas_scales") = nested_vector2{} + py::arg("alphas_scales") = nested_vector2{}, + py::arg("ladder") = nested_vector3{}, + py::arg("ladder_lead") = nested_vector2{} ); py::classh( m, "EventHistogramSpec", pydoc::doc("EventHistogramSpec") diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index fe187849d2..ce02814a87 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -145,6 +145,7 @@ def alphas_outputs(clustering, momenta): "pdf_rw_q_den", "pdf_rw_active", "pdf_rw_beam", + "pdf_rw_mu_f", ] diff --git a/madspace/tests/test_systematics.py b/madspace/tests/test_systematics.py index bc5a272611..3e0e2a9515 100644 --- a/madspace/tests/test_systematics.py +++ b/madspace/tests/test_systematics.py @@ -512,3 +512,67 @@ def test_mlm_vertices_follow_the_emission_scale(grids, ref): assert row[0] == approx(w0 * (ref.alphas(0.5 * mu) / ref.alphas(mu)) ** n, rel=1e-12) assert row[2] == approx(w0, rel=1e-15) + + +def test_mlm_pdf_ladder_follows_alpsfact_and_muf(grids, ref): + """The pdf reweighting ladder of an MLM event, varied as madevent's + systematics.py does: the event carries f(x_b, Q_lead) per beam times + f(x_s, Q_num) / f(x_s, Q_den) per step, every Q a clustering scale q capped + at mu_F or mu_F itself. A variation takes q to min(alpsfact q, k mu_F) and + mu_F to k mu_F; the ladder scales stay clamped to the generation range. An + event without a ladder (the 2 -> 1 sample) keeps the plain beam ratio.""" + grid, alpha_s = grids + low = 2.0 + config = make_config( + mur=[1.0], muf=[0.5, 1.0, 2.0], alps=[0.5, 1.0, 2.0], + pdf_ladder=True, ladder_min_scale=low, + ) + # no alpha_s: the weights move through the PDFs alone + args = [ms.SubprocessSystArgs(qcd_power=0, beam_pdgs=[[2, 2]])] + calc = ms.SystematicsCalculator(config, args, grid, alpha_s) + variations = calc.variations + assert [(v.muf, v.alps) for v in variations] == [ + (0.5, 1.0), (2.0, 1.0), (1.0, 0.5), (1.0, 2.0) + ] + + events = make_events(ref, n=20) + rng = np.random.default_rng(11) + ladder, lead = [], [] + for i, (x1, mu) in enumerate(zip(events["x1"], events["ren_scale"])): + if i == 0: + ladder.append([]) + lead.append([0.0, 0.0, 0.0, 0.0]) # no ladder at all + continue + q1 = max(mu * rng.uniform(0.02, 0.8), low) + z = rng.uniform(0.3, 0.9) + # beam 1 lowered to q1, beam 2 at mu_F; one step on beam 1, from q1 + # up to mu_F (info: active + 2 * beam 0 + 4 * numerator is mu_F) + lead.append([q1, mu, 0.0, 1.0]) + ladder.append([[0.0] * 5, [x1 * z, 21, mu, q1, 5.0]]) + weights = calc.weights(**events, ladder=ladder, ladder_lead=lead) + + def ladder_weight(i, k, a): + x1, x2, mu = events["x1"][i], events["x2"][i], events["ren_scale"][i] + top = k * mu + clamp = lambda q: max(q, low) + lowered = lambda q: clamp(min(a * q, top)) + q1 = lead[i][0] + x_step = ladder[i][1][0] + return ( + ref.pdf(2, x1, lowered(q1)) * ref.pdf(2, x2, clamp(top)) + * ref.pdf(21, x_step, clamp(top)) / ref.pdf(21, x_step, lowered(q1)) + ) + + for i, row in enumerate(weights): + w0 = events["event_weight"][i] + x1, x2, mu = events["x1"][i], events["x2"][i], events["ren_scale"][i] + for var, w in zip(variations, row): + if i == 0: + if var.alps != 1.0: + assert w == approx(w0, rel=1e-15) + continue + expected = (ref.pdf(2, x1, var.muf * mu) * ref.pdf(2, x2, var.muf * mu) + / events["partial_weight_product"][i]) + else: + expected = ladder_weight(i, var.muf, var.alps) / ladder_weight(i, 1.0, 1.0) + assert w == approx(w0 * expected, rel=1e-12) From 35bd96eb384f53e6f8d013dede8179765e809a8b Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Mon, 26 Oct 2026 21:38:22 +0100 Subject: [PATCH 65/81] MLM systematics: rebuild the pdf ladder as madevent's systematics.py does Validated against madevent. f1c150dad0 varied the ladder in the form reweight.f applies it (f(x z, Q_num) / f(x z, Q_den) per step, with flags for which scales are mu_F). madevent's systematics.py does not: it rebuilds the weight from the entries each beam books - the beam parton at x_beam and its leading scale, then per step the line's flavour, its x before the step's z (xnow / zcl) and the step's scale - as f(x_N-1, k S) prod_i block now carries the ladder ( with every entry) and the alpha_s vertices (, with the power reduced by their number), as madevent writes it. madevent's systematics.py can then reweight an mg7 MLM sample itself. Validation, p p > z + 0,1,2j, xqcut 20, NNPDF23_lo_as_0130_qed, 30k events, scale_scheme / clustering_measure / clustering_history madevent: - Same events: madevent's systematics.py run on the mg7 LHE (with the new ) against the native weights: all 3.33M weight ratios (111 variations: mu_R x mu_F, alpsfact, 100 PDF members, at 0, 1 and 2 jets) agree to < 1e-7; the summaries are identical (scale +11.4 / -12.1 %, emission +8.44 / -5.58 %, PDF +-2.74 %). - Against a madevent sample: 0-jet variations identical once s/c-initiated events are left out - madevent books the group's representative flavour (u, d) in for those under ickkw = 1, so its own systematics vary them with the wrong PDF. Emission envelope in agreement (1.0833 vs 1.0823, 0.9447 vs 0.9457). The remaining 1-2 % differences in the mu_F / mu_R variations at 1-2 jets come from the clustering histories themselves (e.g. q g > z q: the alpha_s vertex left at mu_R in 14 % of mg7 events, 7 % of madevent's), not from the systematics. --- madgraph/iolibs/template_files/mg7/launch.py | 8 +- .../madspace/driver/channel_generator.hpp | 4 +- madspace/include/madspace/driver/io.hpp | 18 +- .../include/madspace/driver/lhe_output.hpp | 14 ++ .../include/madspace/driver/systematics.hpp | 5 +- madspace/instruction_set.yaml | 12 +- madspace/src/driver/channel_generator.cpp | 13 +- madspace/src/driver/event_generator.cpp | 4 +- madspace/src/driver/lhe_output.cpp | 46 +++-- madspace/src/driver/systematics.cpp | 159 +++++++++++------- madspace/src/kernels/mlm.hpp | 45 +++-- madspace/src/phasespace/integrand.cpp | 35 ++-- madspace/src/phasespace/mlm_clustering.cpp | 8 +- madspace/src/python/madspace.cpp | 12 +- madspace/tests/test_mlm.py | 2 +- madspace/tests/test_systematics.py | 47 +++--- 16 files changed, 243 insertions(+), 189 deletions(-) diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index 5540da8991..0dedb05c70 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -971,6 +971,8 @@ def init_beam(self) -> None: grids = [grid for grid in (self.pdf_grid, self.pdf_grid2) if isinstance(grid, ms.PdfGrid)] self.max_scale = min(max(grid.q) for grid in grids) if grids else 0.0 + # the bottom of the same range, where the densities freeze + self.min_pdf_scale = max(min(grid.q) for grid in grids) if grids else 0.0 for context in self.contexts: if not isinstance(self.pdf_grid, ms.CustomPdf): self.pdf_grid.initialize_globals(context) @@ -1149,14 +1151,14 @@ def build_systematics(self): else: config.alps = [1.0] # the pdf reweighting ladder of an MLM sample under madevent's scale - # scheme, varied as madevent's systematics vary it; its scales are - # clamped to the range the generation clamped them to + # scheme, varied as madevent's systematics vary it; a varied scale is + # kept inside the PDF grid, where LHAPDF freezes it for madevent beam = self.run_card["beam"] config.pdf_ladder = bool( self.mlm_clustering and not self.is_decay and not self.leptonic and beam["scale_scheme"] == "madevent" and beam["pdf_reweighting"]) if config.pdf_ladder: - config.ladder_min_scale = float(beam["min_scale"]) + config.ladder_min_scale = float(self.min_pdf_scale) config.ladder_max_scale = float(self.max_scale) config.together = bool(syst["together"]) config.dyn_scales = self.resolve_dynamical_scales() diff --git a/madspace/include/madspace/driver/channel_generator.hpp b/madspace/include/madspace/driver/channel_generator.hpp index 19836eb378..9833469de1 100644 --- a/madspace/include/madspace/driver/channel_generator.hpp +++ b/madspace/include/madspace/driver/channel_generator.hpp @@ -222,8 +222,8 @@ class ChannelEventGenerator { int x1, fact_scale1, x2, fact_scale2, partial_weight_product; int cluster_scales; int alphas_scales; - // x, pdg, q_num, q_den, info, lead_q, lead_mu_f of the pdf ladder - std::array ladder; + // x, pdg, q, info, lead_q of the pdf ladder + std::array ladder; int subprocess_index; int bias; int random, rest; diff --git a/madspace/include/madspace/driver/io.hpp b/madspace/include/madspace/driver/io.hpp index d1316ad345..728c359a88 100644 --- a/madspace/include/madspace/driver/io.hpp +++ b/madspace/include/madspace/driver/io.hpp @@ -111,11 +111,9 @@ class ParticleRecord { { {"ladder_x", FieldLayout::f64, 3}, {"ladder_pdg", FieldLayout::f64, 3}, - {"ladder_q_num", FieldLayout::f64, 3}, - {"ladder_q_den", FieldLayout::f64, 3}, + {"ladder_q", FieldLayout::f64, 3}, {"ladder_info", FieldLayout::f64, 3}, {"ladder_lead_q", FieldLayout::f64, 3}, - {"ladder_lead_mu_f", FieldLayout::f64, 3}, } ); } @@ -151,15 +149,15 @@ class ParticleRecord { // scale alpha_s was evaluated at for one MLM clustering vertex, see // f_alphas_scales UnalignedRef alphas_scale() { return &_data[_offsets[1] + 0]; } - // pdf reweighting ladder, see f_pdf_ladder. ladder_info is - // active + 2 beam + 4 (the numerator scale is mu_F itself). + // pdf reweighting ladder, see f_pdf_ladder, as madevent books it for its + // systematics: per step the line's flavour, its momentum fraction before + // the step's z and the scale the step takes it to; ladder_info is + // active + 2 beam. UnalignedRef ladder_x() { return &_data[_offsets[2] + 0]; } UnalignedRef ladder_pdg() { return &_data[_offsets[2] + 8]; } - UnalignedRef ladder_q_num() { return &_data[_offsets[2] + 16]; } - UnalignedRef ladder_q_den() { return &_data[_offsets[2] + 24]; } - UnalignedRef ladder_info() { return &_data[_offsets[2] + 32]; } - UnalignedRef ladder_lead_q() { return &_data[_offsets[2] + 40]; } - UnalignedRef ladder_lead_mu_f() { return &_data[_offsets[2] + 48]; } + UnalignedRef ladder_q() { return &_data[_offsets[2] + 16]; } + UnalignedRef ladder_info() { return &_data[_offsets[2] + 24]; } + UnalignedRef ladder_lead_q() { return &_data[_offsets[2] + 32]; } void from_lhe_particle(const LHEParticle& particle, bool has_clustering = false) { lhe_pdg_id() = particle.pdg_id; diff --git a/madspace/include/madspace/driver/lhe_output.hpp b/madspace/include/madspace/driver/lhe_output.hpp index 57a16ad622..b224372195 100644 --- a/madspace/include/madspace/driver/lhe_output.hpp +++ b/madspace/include/madspace/driver/lhe_output.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -112,6 +113,19 @@ struct LOReweightInfo { double x1, x2; /// Factorization scales of the two beams, in GeV. double fact_scale1, fact_scale2; + /// MLM pdf reweighting ladder of each beam, as madevent's `` + /// books it: the beam parton at its leading scale, then one entry per + /// ladder step (line flavour, momentum fraction before the step, scale). + /// Empty: a single entry from pdg/x/fact_scale. + std::array, 2> ladder_pdgs{}; + /// See @ref ladder_pdgs. + std::array, 2> ladder_x{}; + /// See @ref ladder_pdgs. + std::array, 2> ladder_q{}; + /// Scales of the MLM alpha_s vertices (``); `qcd_power` counts + /// them too, the `` power is what is left at the + /// renormalisation scale. + std::vector asrwt{}; }; /** diff --git a/madspace/include/madspace/driver/systematics.hpp b/madspace/include/madspace/driver/systematics.hpp index 3d667f1443..25321c57c0 100644 --- a/madspace/include/madspace/driver/systematics.hpp +++ b/madspace/include/madspace/driver/systematics.hpp @@ -54,8 +54,9 @@ struct SystematicsConfig { /// (ParticleRecord::f_pdf_ladder): every parton of the PDF set is then /// made available, since a ladder step can be any of them. bool pdf_ladder = false; - /// Range the scales of the pdf ladder are clamped to, as the integrand - /// does (EnergyScale's min_scale / max_scale); `0` disables a bound. + /// Range the varied scales of the pdf ladder are clamped to, normally + /// the Q range of the PDF grid, where LHAPDF freezes the density for + /// madevent; `0` disables a bound. double ladder_min_scale = 0.; /// See @ref ladder_min_scale. double ladder_max_scale = 0.; diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index 3cec0b6065..129dba4774 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -2856,9 +2856,9 @@ mlm_clustering_hadronic: - name: pdf_rw_beam type: [float, n-1] desc: which beam each step sits on, as 0 or 1 - - name: pdf_rw_mu_f - type: [float, n+1] - desc: one where a scale is mu_F itself rather than a clustering scale, for each step's numerator, then each beam's leading density + - name: pdf_rw_x_prev + type: [float, n-1] + desc: momentum fraction of the beam line before each step's z, relative to the beam vectorized: False @@ -2979,9 +2979,9 @@ mlm_clustering_leptonic: - name: pdf_rw_beam type: [float, n-1] desc: which beam each step sits on, as 0 or 1 - - name: pdf_rw_mu_f - type: [float, n+1] - desc: one where a scale is mu_F itself rather than a clustering scale, for each step's numerator, then each beam's leading density + - name: pdf_rw_x_prev + type: [float, n-1] + desc: momentum fraction of the beam line before each step's z, relative to the beam vectorized: False diff --git a/madspace/src/driver/channel_generator.cpp b/madspace/src/driver/channel_generator.cpp index 4ce2f98fef..42eb88efb7 100644 --- a/madspace/src/driver/channel_generator.cpp +++ b/madspace/src/driver/channel_generator.cpp @@ -341,11 +341,9 @@ void ChannelEventGenerator::init_field_indices() { const char* names[] = { "ladder_x", "ladder_pdg", - "ladder_q_num", - "ladder_q_den", + "ladder_q", "ladder_info", "ladder_lead_q", - "ladder_lead_mu_f", }; for (std::size_t k = 0; k < _field_indices.ladder.size(); ++k) { _field_indices.ladder[k] = index_map.at(names[k]); @@ -873,9 +871,8 @@ void ChannelEventGenerator::write_events( auto slot_view = [&](std::size_t k) { return unweighted_events.at(_field_indices.ladder[k]).view(); }; - auto x_view = slot_view(0), pdg_view = slot_view(1), num_view = slot_view(2), - den_view = slot_view(3), info_view = slot_view(4), - lead_q_view = slot_view(5), lead_mu_f_view = slot_view(6); + auto x_view = slot_view(0), pdg_view = slot_view(1), q_view = slot_view(2), + info_view = slot_view(3), lead_q_view = slot_view(4); for (std::size_t i = 0; i < w_view.size(); ++i) { std::size_t slot_count = x_view[i].size(); for (std::size_t j = 0; j < _particle_count; ++j) { @@ -883,11 +880,9 @@ void ChannelEventGenerator::write_events( bool has_slot = j < slot_count; particle.ladder_x() = has_slot ? x_view[i][j] : 0.; particle.ladder_pdg() = has_slot ? pdg_view[i][j] : 0.; - particle.ladder_q_num() = has_slot ? num_view[i][j] : 0.; - particle.ladder_q_den() = has_slot ? den_view[i][j] : 0.; + particle.ladder_q() = has_slot ? q_view[i][j] : 0.; particle.ladder_info() = has_slot ? info_view[i][j] : 0.; particle.ladder_lead_q() = j < 2 ? lead_q_view[i][j] : 0.; - particle.ladder_lead_mu_f() = j < 2 ? lead_mu_f_view[i][j] : 0.; } } } diff --git a/madspace/src/driver/event_generator.cpp b/madspace/src/driver/event_generator.cpp index 4845250b89..da22f8d241 100644 --- a/madspace/src/driver/event_generator.cpp +++ b/madspace/src/driver/event_generator.cpp @@ -935,11 +935,9 @@ int any_alphas_scales(const Channels& channels) { void copy_ladder(ParticleRecord in, ParticleRecord out, bool from_in) { out.ladder_x() = from_in ? in.ladder_x().value() : 0.; out.ladder_pdg() = from_in ? in.ladder_pdg().value() : 0.; - out.ladder_q_num() = from_in ? in.ladder_q_num().value() : 0.; - out.ladder_q_den() = from_in ? in.ladder_q_den().value() : 0.; + out.ladder_q() = from_in ? in.ladder_q().value() : 0.; out.ladder_info() = from_in ? in.ladder_info().value() : 0.; out.ladder_lead_q() = from_in ? in.ladder_lead_q().value() : 0.; - out.ladder_lead_mu_f() = from_in ? in.ladder_lead_mu_f().value() : 0.; } } // namespace diff --git a/madspace/src/driver/lhe_output.cpp b/madspace/src/driver/lhe_output.cpp index cf8fc7d5d4..6a93d5fcbc 100644 --- a/madspace/src/driver/lhe_output.cpp +++ b/madspace/src/driver/lhe_output.cpp @@ -166,21 +166,45 @@ void LHEEvent::format_to(std::string& buffer) const { } if (lo_info && lo_info->qcd_power >= 0 && lo_info->has_beam1 && lo_info->has_beam2) { + // as madevent's unwgt.f writes it: the alpha_s power left at mu_R and + // the vertex scales, then per beam n, the n pdgs, x and scales + int n_vertices = static_cast(lo_info->asrwt.size()); std::format_to( insert_iter, - "\n {} {:.8e}\n0\n" - " 1 {} {:.8e} {:.8e}\n" - " 1 {} {:.8e} {:.8e}\n" - " 1.0\n\n", - lo_info->qcd_power, + "\n {} {:.8e}\n {}", + lo_info->qcd_power - n_vertices, lo_info->ren_scale, - lo_info->pdg1, - lo_info->x1, - lo_info->fact_scale1, - lo_info->pdg2, - lo_info->x2, - lo_info->fact_scale2 + n_vertices ); + for (double scale : lo_info->asrwt) { + std::format_to(insert_iter, " {:.8e}", scale); + } + buffer += "\n"; + std::array pdg{lo_info->pdg1, lo_info->pdg2}; + std::array x{lo_info->x1, lo_info->x2}; + std::array q{lo_info->fact_scale1, lo_info->fact_scale2}; + for (std::size_t b = 0; b < 2; ++b) { + bool ladder = !lo_info->ladder_pdgs[b].empty(); + std::size_t n = ladder ? lo_info->ladder_pdgs[b].size() : 1; + std::format_to(insert_iter, " {}", b + 1, n); + for (std::size_t e = 0; e < n; ++e) { + std::format_to( + insert_iter, " {}", ladder ? lo_info->ladder_pdgs[b][e] : pdg[b] + ); + } + for (std::size_t e = 0; e < n; ++e) { + std::format_to( + insert_iter, " {:.8e}", ladder ? lo_info->ladder_x[b][e] : x[b] + ); + } + for (std::size_t e = 0; e < n; ++e) { + std::format_to( + insert_iter, " {:.8e}", ladder ? lo_info->ladder_q[b][e] : q[b] + ); + } + buffer += "\n"; + } + buffer += " 1.0\n\n"; } if (!rwgt.empty()) { buffer += "\n"; diff --git a/madspace/src/driver/systematics.cpp b/madspace/src/driver/systematics.cpp index c485999682..8e3bf523dc 100644 --- a/madspace/src/driver/systematics.cpp +++ b/madspace/src/driver/systematics.cpp @@ -680,11 +680,49 @@ SystematicsCalculator::reweight_info(EventBuffer& buffer, std::size_t event_inde .fact_scale1 = has_beam1 ? event.fact_scale1().value() : 0., .fact_scale2 = has_beam2 ? event.fact_scale2().value() : 0., }; + // the MLM alpha_s vertices and pdf ladder, as madevent books them + if (particle_layout_has(layout, "alphas_scale")) { + for (std::size_t j = 2; j < buffer.particle_count(); ++j) { + double scale = buffer.particle(event_index, j).alphas_scale(); + if (scale > 0.) { + info.asrwt.push_back(scale); + } + } + } + if (particle_layout_has(layout, "ladder_x") && has_beam1 && has_beam2) { + std::array lead_q{ + buffer.particle(event_index, 0).ladder_lead_q(), + buffer.particle(event_index, 1).ladder_lead_q() + }; + if (lead_q[0] > 0. || lead_q[1] > 0.) { + std::array pdg{info.pdg1, info.pdg2}; + std::array x{info.x1, info.x2}; + for (std::size_t b = 0; b < 2; ++b) { + info.ladder_pdgs[b].push_back(pdg[b]); + info.ladder_x[b].push_back(x[b]); + info.ladder_q[b].push_back(lead_q[b]); + } + for (std::size_t j = 0; j < buffer.particle_count(); ++j) { + auto particle = buffer.particle(event_index, j); + int step_info = static_cast(particle.ladder_info()); + if (!(step_info & 1)) { + continue; + } + int b = (step_info >> 1) & 1; + info.ladder_pdgs[b].push_back(static_cast(particle.ladder_pdg())); + info.ladder_x[b].push_back(particle.ladder_x()); + info.ladder_q[b].push_back(particle.ladder_q()); + } + } + } // beam 1 moves along +z: leg 1 of a mirrored event belongs to beam 2 if (has_beam1 && has_beam2 && buffer.particle(event_index, 0).pz() < 0.) { std::swap(info.pdg1, info.pdg2); std::swap(info.x1, info.x2); std::swap(info.fact_scale1, info.fact_scale2); + std::swap(info.ladder_pdgs[0], info.ladder_pdgs[1]); + std::swap(info.ladder_x[0], info.ladder_x[1]); + std::swap(info.ladder_q[0], info.ladder_q[1]); } return info; } @@ -713,17 +751,15 @@ void SystematicsCalculator::compute( // the scales of the MLM clustering vertices whose alpha_s is evaluated // at their own scale rather than at mu_R (empty if none) std::vector vertex_scales; - // the MLM pdf reweighting ladder: each beam's leading density (its - // scale, and whether that is mu_F itself), then every active step - struct LadderStep { - double x, q_num, q_den; + // the MLM pdf reweighting ladder of each beam, as madevent books it + // for its systematics (pdfrwt): the beam parton at x_beam and its + // leading scale, then for every step the line's flavour, its momentum + // fraction before the step and the scale the step takes it to + struct LadderEntry { + double x, q; me_int_t slot; - int beam; - bool num_mu_f; }; - std::vector ladder; - std::array lead_q{0., 0.}; - std::array lead_mu_f{true, true}; + std::array, 2> ladder; bool has_ladder = false; double w0, ren_scale, x1, x2, muf1, muf2, alpha_s, nominal_product; int subproc, qcd_power; @@ -775,26 +811,26 @@ void SystematicsCalculator::compute( } } if (has_ladder && use_pdf) { - for (int b = 0; b < 2; ++b) { - auto particle = buffer.particle(i, b); - in.lead_q[b] = particle.ladder_lead_q(); - in.lead_mu_f[b] = particle.ladder_lead_mu_f() != 0.; + std::array lead_q{ + buffer.particle(i, 0).ladder_lead_q(), buffer.particle(i, 1).ladder_lead_q() + }; + // an event of a channel without the ladder carries zeros + in.has_ladder = lead_q[0] > 0. || lead_q[1] > 0.; + if (in.has_ladder) { + in.ladder[0].push_back({in.x1, lead_q[0], in.slot1}); + in.ladder[1].push_back({in.x2, lead_q[1], in.slot2}); } - // a 2 -> 1 event of a merged run has no ladder: all zero - in.has_ladder = in.lead_q[0] > 0. || in.lead_q[1] > 0.; + // the steps in clustering order, which is slot order for (std::size_t j = 0; in.has_ladder && j < buffer.particle_count(); ++j) { auto particle = buffer.particle(i, j); int info = static_cast(particle.ladder_info()); if (!(info & 1)) { continue; } - in.ladder.push_back({ - .x = particle.ladder_x(), - .q_num = particle.ladder_q_num(), - .q_den = particle.ladder_q_den(), - .slot = pid_slot(static_cast(particle.ladder_pdg())), - .beam = (info >> 1) & 1, - .num_mu_f = static_cast((info >> 2) & 1), + in.ladder[(info >> 1) & 1].push_back({ + particle.ladder_x(), + particle.ladder_q(), + pid_slot(static_cast(particle.ladder_pdg())), }); } } @@ -942,14 +978,13 @@ void SystematicsCalculator::compute( } } - // MLM pdf reweighting ladder, as madevent's systematics.py: the event - // carries f(x_b, Q_lead) for each beam times f(x_s, Q_num) / f(x_s, Q_den) - // for each step of the ladder, every Q a clustering scale capped at mu_F - // or mu_F itself. A variation takes a clustering scale q to - // min(alpsfact q, k S) and mu_F to k S, S being mu_F or the alternative - // dynamical scale, and a PDF member evaluates the whole product. The - // nominal product is recomputed the same way, so that the central point - // is exactly one. + // MLM pdf reweighting ladder, as madevent's systematics.py rebuilds it + // from the pdfrwt entries (q_0..q_N-1, x_0..x_N-1) of each beam: + // f(x_N-1, k S) prod_i ladder_events; for (std::size_t i = 0; use_pdf && i < count; ++i) { if (inputs[i].has_ladder) { @@ -959,39 +994,32 @@ void SystematicsCalculator::compute( if (!ladder_events.empty()) { double low = _config.ladder_min_scale; double high = _config.ladder_max_scale > 0. ? _config.ladder_max_scale : 1e30; - // W for every ladder event at one variation; 0 where madevent vetoes - // (a density divided by under 1e-10) + auto clamp = [&](double q) { return std::min(std::max(q, low), high); }; + std::array has_beam{has_beam1, has_beam2}; auto ladder_weights = [&](const PdfEvaluator& evaluator, const Variation& var) { std::vector x, q; std::vector slots; for (std::size_t i : ladder_events) { auto& in = inputs[i]; - std::array top{ - var.muf * scale_of(in, var.dyn, in.muf1), - var.muf * scale_of(in, var.dyn, in.muf2) - }; - auto vary = [&](double scale, bool is_mu_f, int beam) { - double varied = - is_mu_f ? top[beam] : std::min(var.alps * scale, top[beam]); - return std::min(std::max(varied, low), high); - }; - std::array beam_x{in.x1, in.x2}; - std::array beam_slot{in.slot1, in.slot2}; - std::array has_beam{has_beam1, has_beam2}; for (int b = 0; b < 2; ++b) { - if (has_beam[b]) { - x.push_back(beam_x[b]); - q.push_back(vary(in.lead_q[b], in.lead_mu_f[b], b)); - slots.push_back(beam_slot[b]); + auto& entries = in.ladder[b]; + if (!has_beam[b] || entries.empty()) { + continue; + } + double top = var.muf * + (var.dyn == -1 ? entries.back().q : in.dyn_scale[var.dyn]); + x.push_back(entries.back().x); + q.push_back(clamp(top)); + slots.push_back(entries.back().slot); + for (std::size_t e = 0; e + 1 < entries.size(); ++e) { + double scale = clamp(std::min(var.alps * entries[e].q, top)); + x.push_back(entries[e].x); + q.push_back(scale); + slots.push_back(entries[e].slot); + x.push_back(entries[e + 1].x); + q.push_back(scale); + slots.push_back(entries[e].slot); } - } - for (auto& step : in.ladder) { - x.push_back(step.x); - q.push_back(vary(step.q_num, step.num_mu_f, step.beam)); - slots.push_back(step.slot); - x.push_back(step.x); - q.push_back(vary(step.q_den, false, step.beam)); - slots.push_back(step.slot); } } auto values = evaluate_pdf(evaluator, x, q, slots); @@ -1000,16 +1028,17 @@ void SystematicsCalculator::compute( for (std::size_t i : ladder_events) { auto& in = inputs[i]; double w = 1.; - if (has_beam1) { - w *= values[n++]; - } - if (has_beam2) { + for (int b = 0; b < 2; ++b) { + auto& entries = in.ladder[b]; + if (!has_beam[b] || entries.empty()) { + continue; + } w *= values[n++]; - } - for (std::size_t s = 0; s < in.ladder.size(); ++s) { - double numerator = values[n++]; - double denominator = values[n++]; - w = denominator >= 1e-10 ? w * numerator / denominator : 0.; + for (std::size_t e = 0; e + 1 < entries.size(); ++e) { + double numerator = values[n++]; + double denominator = values[n++]; + w = denominator >= 1e-10 ? w * numerator / denominator : 0.; + } } result.push_back(w); } diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 500dd1a01c..510a317c54 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -309,7 +309,7 @@ KERNELSPEC int mlm_clustering_walk( FOut pdf_rw_q_den, FOut pdf_rw_active, FOut pdf_rw_beam, - FOut pdf_rw_mu_f, + FOut pdf_rw_x_prev, int start_state, bool hadronic ) { @@ -789,13 +789,10 @@ KERNELSPEC int mlm_clustering_walk( // for the rest it depends on which clustering won, so it has to travel // with the slot. FVal rw_beam[N_EXT_MAX - 1]; - // Which scales are mu_F itself rather than a clustering scale capped at - // it: the numerator of the step that lands on mu_F, and a beam density - // that was never lowered. The systematics vary the two differently, as - // madevent's do: mu_F with the factorisation scale, a clustering scale - // with alpsfact. - FVal rw_mu_f[N_EXT_MAX - 1]; - FVal lead_mu_f[2] = {1.0, 1.0}; + // The beam line's momentum fraction before the step's own z, which is + // what madevent books for its systematics (s_xpdf = xnow / zcl): they + // rebuild the ladder as f(x_i, Q_i) / f(x_i+1, Q_i) from those. + FVal rw_x_prev[N_EXT_MAX - 1]; for (int i = 0; i < pdf_slot_count; ++i) { rw_flavor[i] = 0; rw_x[i] = 1.0; @@ -803,7 +800,7 @@ KERNELSPEC int mlm_clustering_walk( rw_q_den[i] = 1.0; rw_active[i] = 0.0; rw_beam[i] = 0.0; - rw_mu_f[i] = 0.0; + rw_x_prev[i] = 1.0; } FVal pdf_scale_val[2] = {-1.0, -1.0}; @@ -1312,7 +1309,6 @@ KERNELSPEC int mlm_clustering_walk( for (int j = 0; j < 2; ++j) { if (jlast[j] >= 0 && jfirst[j] >= 0 && jfirst[j] <= jlast[j]) { pdf_scale_val[j] = min(pt_step[jfirst[j]], q_central[j]); - lead_mu_f[j] = 0.0; } } // Where each beam's line has got to: its flavour class, the @@ -1345,6 +1341,7 @@ KERNELSPEC int mlm_clustering_walk( // zcl is 1 at the root, so the momentum fraction is only // ever rescaled by the recorded clusterings. FVal z = at_root ? FVal(1.0) : cluster_z[i]; + FVal x_prev = x_frac[j]; if (z > 0.0 && z < 1.0) { x_frac[j] *= z; } @@ -1361,7 +1358,7 @@ KERNELSPEC int mlm_clustering_walk( rw_q_den[slot] = pt_pdf[j]; rw_active[slot] = 1.0; rw_beam[slot] = j; - rw_mu_f[slot] = i == jlast[j] ? 1.0 : 0.0; + rw_x_prev[slot] = x_prev; pt_pdf[j] = q_now; } // and the line becomes the mother; at the root that is the @@ -1493,10 +1490,8 @@ KERNELSPEC int mlm_clustering_walk( pdf_rw_q_den[i] = rw_q_den[i]; pdf_rw_active[i] = rw_active[i]; pdf_rw_beam[i] = rw_beam[i]; - pdf_rw_mu_f[i] = rw_mu_f[i]; + pdf_rw_x_prev[i] = rw_x_prev[i]; } - pdf_rw_mu_f[pdf_slot_count] = lead_mu_f[0]; - pdf_rw_mu_f[pdf_slot_count + 1] = lead_mu_f[1]; int diag_count = state_machine[state]; int rand_index = static_cast(FVal(random) * diag_count); @@ -1585,7 +1580,7 @@ KERNELSPEC void mlm_clustering( FOut pdf_rw_q_den, FOut pdf_rw_active, FOut pdf_rw_beam, - FOut pdf_rw_mu_f, + FOut pdf_rw_x_prev, bool hadronic ) { auto walk = [&](int start) { @@ -1626,7 +1621,7 @@ KERNELSPEC void mlm_clustering( pdf_rw_q_den, pdf_rw_active, pdf_rw_beam, - pdf_rw_mu_f, + pdf_rw_x_prev, start, hadronic ); @@ -1650,7 +1645,7 @@ KERNELSPEC void mlm_clustering( IVal kept_rw_flavor[N_EXT_MAX]; FVal kept_rw_x[N_EXT_MAX], kept_rw_q_num[N_EXT_MAX], kept_rw_q_den[N_EXT_MAX], kept_rw_active[N_EXT_MAX], - kept_rw_beam[N_EXT_MAX], kept_rw_mu_f[N_EXT_MAX + 1]; + kept_rw_beam[N_EXT_MAX], kept_rw_x_prev[N_EXT_MAX]; for (int i = 0; i < n_part - 2; ++i) { kept_outgoing[i] = outgoing_scales[i]; } @@ -1665,8 +1660,8 @@ KERNELSPEC void mlm_clustering( kept_rw_active[i] = pdf_rw_active[i]; kept_rw_beam[i] = pdf_rw_beam[i]; } - for (int i = 0; i < n_part + 1; ++i) { - kept_rw_mu_f[i] = pdf_rw_mu_f[i]; + for (int i = 0; i < n_part - 1; ++i) { + kept_rw_x_prev[i] = pdf_rw_x_prev[i]; } if (walk(0) != diagram_jets) { ren_scale = kept_ren; @@ -1691,8 +1686,8 @@ KERNELSPEC void mlm_clustering( pdf_rw_active[i] = kept_rw_active[i]; pdf_rw_beam[i] = kept_rw_beam[i]; } - for (int i = 0; i < n_part + 1; ++i) { - pdf_rw_mu_f[i] = kept_rw_mu_f[i]; + for (int i = 0; i < n_part - 1; ++i) { + pdf_rw_x_prev[i] = kept_rw_x_prev[i]; } } } else { @@ -1740,7 +1735,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( FOut pdf_rw_q_den, FOut pdf_rw_active, FOut pdf_rw_beam, - FOut pdf_rw_mu_f + FOut pdf_rw_x_prev ) { mlm_clustering( momenta, @@ -1781,7 +1776,7 @@ KERNELSPEC void kernel_mlm_clustering_hadronic( pdf_rw_q_den, pdf_rw_active, pdf_rw_beam, - pdf_rw_mu_f, + pdf_rw_x_prev, true ); } @@ -1826,7 +1821,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( FOut pdf_rw_q_den, FOut pdf_rw_active, FOut pdf_rw_beam, - FOut pdf_rw_mu_f + FOut pdf_rw_x_prev ) { mlm_clustering( momenta, @@ -1867,7 +1862,7 @@ KERNELSPEC void kernel_mlm_clustering_leptonic( pdf_rw_q_den, pdf_rw_active, pdf_rw_beam, - pdf_rw_mu_f, + pdf_rw_x_prev, false ); } diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index 73e92cee3a..630d63c651 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -65,10 +65,10 @@ bool emits_pdf_ladder( // the per-slot ladder outputs (one entry per slot that can carry a ratio, // slot 0 never does) and the two per-beam ones -const std::array ladder_slot_names{ - "ladder_x", "ladder_pdg", "ladder_q_num", "ladder_q_den", "ladder_info" +const std::array ladder_slot_names{ + "ladder_x", "ladder_pdg", "ladder_q", "ladder_info" }; -const std::array ladder_beam_names{"ladder_lead_q", "ladder_lead_mu_f"}; +const std::array ladder_beam_names{"ladder_lead_q"}; bool emits_partial_weights( bool partial_weights, const std::optional& energy_scale @@ -1017,8 +1017,7 @@ ValueVec Integrand::mlm_weights( : 1e30; auto ones = fb.full({1., batch_size_acc}); Value factor, veto; - ValueVec ladder_x, ladder_pdg, ladder_q_num, ladder_q_den, ladder_info; - auto mu_f_flags = scales.at("pdf_rw_mu_f"); + ValueVec ladder_x, ladder_pdg, ladder_q, ladder_info; // Slot 0 is the first clustering of the history, which can only ever // record where a beam line's density already sits, so it never // carries a ratio. @@ -1061,16 +1060,13 @@ ValueVec Integrand::mlm_weights( auto numerator = density("pdf_rw_q_num"); auto denominator = density("pdf_rw_q_den"); if (ladder) { - // active + 2 beam + 4 (numerator is mu_F itself) - auto mu_f = fb.squeeze(fb.select(mu_f_flags, column)); - ladder_x.push_back(x); + // what madevent books for its systematics at a step: the line's + // flavour, its momentum fraction before the step's z, and the + // scale the step takes the density to; info is active + 2 beam + ladder_x.push_back(fb.mul(x_beam, pick("pdf_rw_x_prev"))); ladder_pdg.push_back(fb.gather(index, _pdf_rw_pdgs)); - ladder_q_num.push_back(scale_of("pdf_rw_q_num")); - ladder_q_den.push_back(scale_of("pdf_rw_q_den")); - ladder_info.push_back(fb.add( - active, - fb.add(fb.mul(beam, 2.), fb.mul(mu_f, 4.)) - )); + ladder_q.push_back(scale_of("pdf_rw_q_num")); + ladder_info.push_back(fb.add(active, fb.mul(beam, 2.))); } // madevent drops the event outright when the density it is // dividing by falls under 1e-10, where the grid is no longer @@ -1100,18 +1096,13 @@ ValueVec Integrand::mlm_weights( if (ladder && !ladder_x.empty()) { ladder->push_back("ladder_x", fb.stack(ladder_x)); ladder->push_back("ladder_pdg", fb.stack(ladder_pdg)); - ladder->push_back("ladder_q_num", fb.stack(ladder_q_num)); - ladder->push_back("ladder_q_den", fb.stack(ladder_q_den)); + ladder->push_back("ladder_q", fb.stack(ladder_q)); ladder->push_back("ladder_info", fb.stack(ladder_info)); - // each beam's leading density: its scale (as evaluated, clamped - // by apply_scale_range), and whether that is mu_F itself - std::vector leads{ - static_cast(slot_count), static_cast(slot_count + 1) - }; + // each beam's leading density, at the scale it was evaluated at + // (clamped by apply_scale_range) ladder->push_back( "ladder_lead_q", fb.stack({scales.at("pdf_scale1"), scales.at("pdf_scale2")}) ); - ladder->push_back("ladder_lead_mu_f", fb.select(mu_f_flags, leads)); } } return weights; diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index 7d7d44805e..f6bb479174 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -656,10 +656,10 @@ MLMClustering::MLMClustering( batch_float_array(topologies.at(0).outgoing_masses().size() + 1)}, {"pdf_rw_beam", batch_float_array(topologies.at(0).outgoing_masses().size() + 1)}, - // whether a scale is mu_F itself rather than a clustering scale: - // each step's numerator, then each beam's leading density - {"pdf_rw_mu_f", - batch_float_array(topologies.at(0).outgoing_masses().size() + 3)}} + // the beam line's momentum fraction before each step's z, which + // madevent books for its systematics + {"pdf_rw_x_prev", + batch_float_array(topologies.at(0).outgoing_masses().size() + 1)}} ), _cm_energy(cm_energy), _jet_scale_scheme(jet_scale_scheme), diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index aba6e2ac65..ccbab96d9b 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -3804,8 +3804,8 @@ PYBIND11_MODULE(_madspace_py, m) { for (auto& row : alphas_scales) { particle_count = std::max(particle_count, row.size() + 3); } - // the pdf ladder: step s ([x, pdg, q_num, q_den, info]) on - // particle s, and [q_lead1, q_lead2, mu_f1, mu_f2] per event + // the pdf ladder: step s ([x, pdg, q, info]) on particle s, + // and [q_lead1, q_lead2] per event for (auto& steps : ladder) { particle_count = std::max(particle_count, steps.size()); } @@ -3864,15 +3864,13 @@ PYBIND11_MODULE(_madspace_py, m) { auto particle = buffer.particle(i, j); auto& lead = ladder_lead.at(i); particle.ladder_lead_q() = j < 2 ? lead.at(j) : 0.; - particle.ladder_lead_mu_f() = j < 2 ? lead.at(j + 2) : 0.; bool has_step = !ladder.empty() && j < ladder.at(i).size(); auto step = has_step ? ladder.at(i).at(j) - : std::vector(5, 0.); + : std::vector(4, 0.); particle.ladder_x() = step.at(0); particle.ladder_pdg() = step.at(1); - particle.ladder_q_num() = step.at(2); - particle.ladder_q_den() = step.at(3); - particle.ladder_info() = step.at(4); + particle.ladder_q() = step.at(2); + particle.ladder_info() = step.at(3); } } std::vector weights; diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index ce02814a87..f985a6976b 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -145,7 +145,7 @@ def alphas_outputs(clustering, momenta): "pdf_rw_q_den", "pdf_rw_active", "pdf_rw_beam", - "pdf_rw_mu_f", + "pdf_rw_x_prev", ] diff --git a/madspace/tests/test_systematics.py b/madspace/tests/test_systematics.py index 3e0e2a9515..39b55f4264 100644 --- a/madspace/tests/test_systematics.py +++ b/madspace/tests/test_systematics.py @@ -516,11 +516,15 @@ def test_mlm_vertices_follow_the_emission_scale(grids, ref): def test_mlm_pdf_ladder_follows_alpsfact_and_muf(grids, ref): """The pdf reweighting ladder of an MLM event, varied as madevent's - systematics.py does: the event carries f(x_b, Q_lead) per beam times - f(x_s, Q_num) / f(x_s, Q_den) per step, every Q a clustering scale q capped - at mu_F or mu_F itself. A variation takes q to min(alpsfact q, k mu_F) and - mu_F to k mu_F; the ladder scales stay clamped to the generation range. An - event without a ladder (the 2 -> 1 sample) keeps the plain beam ratio.""" + systematics.py rebuilds it from the pdfrwt entries of each beam: the beam + parton at x_beam and its leading scale, then per step the line's flavour, + its momentum fraction before the step and the step's scale. With S the + last entry's scale, a variation is + f(x_N-1, k S) prod_i 1 + sample) keeps the plain beam ratio.""" grid, alpha_s = grids low = 2.0 config = make_config( @@ -541,27 +545,32 @@ def test_mlm_pdf_ladder_follows_alpsfact_and_muf(grids, ref): for i, (x1, mu) in enumerate(zip(events["x1"], events["ren_scale"])): if i == 0: ladder.append([]) - lead.append([0.0, 0.0, 0.0, 0.0]) # no ladder at all + lead.append([0.0, 0.0]) # no ladder at all continue q1 = max(mu * rng.uniform(0.02, 0.8), low) - z = rng.uniform(0.3, 0.9) - # beam 1 lowered to q1, beam 2 at mu_F; one step on beam 1, from q1 - # up to mu_F (info: active + 2 * beam 0 + 4 * numerator is mu_F) - lead.append([q1, mu, 0.0, 1.0]) - ladder.append([[0.0] * 5, [x1 * z, 21, mu, q1, 5.0]]) + q2 = max(mu * rng.uniform(0.02, 0.8), q1) + z1, z2 = rng.uniform(0.3, 0.9, 2) + # beam 1 lowered to q1, with two steps (a gluon line from q1 to q2, + # then a quark line up to mu); beam 2 left at mu with no step. + # Step rows: [x before the step, pdg, scale, active + 2 beam] + lead.append([q1, mu]) + ladder.append([[0.0] * 4, [x1, 21, q2, 1.0], [x1 * z1, 2, mu, 1.0]]) + del z2 weights = calc.weights(**events, ladder=ladder, ladder_lead=lead) def ladder_weight(i, k, a): x1, x2, mu = events["x1"][i], events["x2"][i], events["ren_scale"][i] - top = k * mu clamp = lambda q: max(q, low) - lowered = lambda q: clamp(min(a * q, top)) - q1 = lead[i][0] - x_step = ladder[i][1][0] - return ( - ref.pdf(2, x1, lowered(q1)) * ref.pdf(2, x2, clamp(top)) - * ref.pdf(21, x_step, clamp(top)) / ref.pdf(21, x_step, lowered(q1)) - ) + # beam 1: entries (2, x1, q1), (21, x1, q2), (2, x1 z1, mu) + entries = [(2, x1, lead[i][0]), (21, x1, ladder[i][1][2]), + (2, ladder[i][2][0], ladder[i][2][2])] + top = k * entries[-1][2] + w = ref.pdf(entries[-1][0], entries[-1][1], clamp(top)) + for (pdg, x, q), (_, x_next, _) in zip(entries, entries[1:]): + scale = clamp(min(a * q, top)) + w *= ref.pdf(pdg, x, scale) / ref.pdf(pdg, x_next, scale) + # beam 2: its leading density alone, at k mu + return w * ref.pdf(2, x2, clamp(k * lead[i][1])) for i, row in enumerate(weights): w0 = events["event_weight"][i] From 0717a42d2a7323c954f9e05a1799bc5e64fa2340 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Tue, 27 Oct 2026 14:45:20 +0100 Subject: [PATCH 66/81] reweight.f: book the event's flavours as leading entry under ickkw>0 After setclscales, ipdgcl of the external legs of a grouped subprocess holds the flavour class (cluster.f maps the GET_FLAVOR index onto the legs), so the leading pdfrwt entry was 1 for s s~ > z and 2 for c c~ > z. madevent's systematics then varied every s/c-initiated MLM event with the d/u PDF (up to 9% for mu_F=2 on p p > z). Book idup(j,ipsel,iproc), the flavour combination DSIG uses. Ladder entries were already right (the jet legs are reset to the IPSEL row before the ladder). Checked on p p > z + 0,1,2j, ickkw=1, xqcut=20, lhaid 247000, same seed: every 0-jet MUF=2 weight matches LHAPDF with the event's pdgs to 2e-5 (s/c were off by 6.6%/9.3%); all 16525 g/u/d events are byte-identical (kinematics, mgrwt, all weights). Upstream 3.8.2 is not affected. --- Template/LO/SubProcesses/reweight.f | 10 +++++++++- UpdateNotes.txt | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Template/LO/SubProcesses/reweight.f b/Template/LO/SubProcesses/reweight.f index 9f2141f170..e2bf934897 100644 --- a/Template/LO/SubProcesses/reweight.f +++ b/Template/LO/SubProcesses/reweight.f @@ -1548,10 +1548,18 @@ double precision function rewgt(p, flavor, ivec) c Store pdf information for systematics studies (initial) c need to be done after setclscales since that one clean the syscalc value +c The pdg is the one of the selected flavour combination (IPSEL), as in +c DSIG: after setclscales, ipdgcl of the external legs of a grouped +c subprocess holds the flavour class of the event (cluster maps it from +c the GET_FLAVOR index), e.g. 2 for c c~ > z or 1 for s s~ > z. if(use_syst)then do j=1,2 n_pdfrw(j,ivec)=1 - i_pdgpdf(1,j,ivec)=ipdgcl(j,igraphs(1),iproc) + if(abs(idup(j,ipsel,iproc)).ne.81)then + i_pdgpdf(1,j,ivec)=idup(j,ipsel,iproc) + else + i_pdgpdf(1,j,ivec)=ipdgcl(j,igraphs(1),iproc) + endif s_xpdf(1,j,ivec)=xbk(ib(j)) s_qpdf(1,j,ivec)=sqrt(q2fact(j)) enddo diff --git a/UpdateNotes.txt b/UpdateNotes.txt index 4ddf7d67e6..e4833e2ef2 100644 --- a/UpdateNotes.txt +++ b/UpdateNotes.txt @@ -5,6 +5,10 @@ ANNOUNCEMENT: A new LTS (based on the current 3.5.X) is starting now and will act as the stable release for the coming years. 0.3.0 (XX/XX/XX): + OM: madevent MLM (ickkw=1) with use_syst: the entry of the incoming partons + used the flavour class of the grouped subprocess instead of the event's flavour + (pdg 1 for s s~, 2 for c c~), so the systematics (PDF and mu_F variations) of every + s/c-initiated event were computed with the d/u PDF (up to 9% for mu_F=2 on p p > z). OM: The default PDF follows the process when a photon is needed (issue #311): a photon in the initial state of a proton beam (LO, NLO and the mg7 [beam] pdf) or NLO EW corrections ([QED]) select From 31029d180f96219c333eece12c6ab64240839beb Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Tue, 27 Oct 2026 15:08:23 +0100 Subject: [PATCH 67/81] reweight.f: set the event's flavours on the clustered graph once, after setclscales Review follow-ups to the pdfrwt fix: - rewgt now sets every external identity of the clustered graph to row IPSEL right after setclscales (the jet-only reset further down and the pre-setclscales writes on a possibly stale igraphs(1) are gone), and the leading pdfrwt entry is read from it. - drop the 81 branches that used FLAVOR(:) as a pdg (ickkw<=0 booking and s_rwfact): FLAVOR is the position of the coupling-class representative, and leshouche rows always carry physical pdgs, so ickkw<=0 books idup. - cluster.f: say what the FLAVOR mapping really gives (the class representative, not the event's flavour). - acceptance test test_mlm_pdfrwt_flavour (p p > z + 0,1j, ickkw=1), run in acceptancetest_50; it fails on the old code (5720 wrong entries / 20k). Same-seed samples are identical to the previous commit for ickkw=1 (p p > z + 0,1,2j) and ickkw=0 (p p > z + 0,1j): kinematics, mgrwt and every weight. --- .github/workflows/acceptancetest_madevent.yml | 1 + Template/LO/SubProcesses/cluster.f | 8 ++- Template/LO/SubProcesses/reweight.f | 54 ++++++------------- tests/acceptance_tests/test_cmd_madevent.py | 49 +++++++++++++++++ 4 files changed, 74 insertions(+), 38 deletions(-) diff --git a/.github/workflows/acceptancetest_madevent.yml b/.github/workflows/acceptancetest_madevent.yml index 457bb7c452..169d1e7aa8 100644 --- a/.github/workflows/acceptancetest_madevent.yml +++ b/.github/workflows/acceptancetest_madevent.yml @@ -89,6 +89,7 @@ jobs: cd $GITHUB_WORKSPACE ./tests/test_manager.py test_group_subprocess -pA -t0 -l INFO ./tests/test_manager.py test_group_subprocess_permuted_scale -pA -t0 -l INFO + ./tests/test_manager.py test_mlm_pdfrwt_flavour -pA -t0 -l INFO # ---- acceptancetest_contur ---- - name: "acceptancetest_contur: run the test" diff --git a/Template/LO/SubProcesses/cluster.f b/Template/LO/SubProcesses/cluster.f index 8d069ea5d3..e9d81f3bf8 100644 --- a/Template/LO/SubProcesses/cluster.f +++ b/Template/LO/SubProcesses/cluster.f @@ -658,7 +658,13 @@ logical function cluster(p, flavor, ivec) enddo mt2last=0 minpt2ij=1.0d37 -c Map merged flavor particles (PDG=81) to their actual quark flavors +c Map the quark legs (and merged 81 legs) to the representative of the +c coupling class of this event: FLAVOR(i) is the position of that +c representative in the merged quark group, which is its pdg since the +c quarks are always merged in pdg order (1,2,...). It is NOT the flavour +c of the event (s s~ > z gives 1, c c~ > z gives 2): the flavour +c combination (IPSEL) is only drawn after the scales are set, and rewgt +c sets the external identities to it after clustering. do i=1,nexternal if(flavor(i).ge.1.and.flavor(i).le.5)then do igraph=1,n_max_cg diff --git a/Template/LO/SubProcesses/reweight.f b/Template/LO/SubProcesses/reweight.f index e2bf934897..b8e59ef534 100644 --- a/Template/LO/SubProcesses/reweight.f +++ b/Template/LO/SubProcesses/reweight.f @@ -1504,28 +1504,17 @@ double precision function rewgt(p, flavor, ivec) rewgt = 0d0 return endif -c Set incoming particle identities - ipdgcl(1,igraphs(1),iproc)=idup(1,ipsel,iproc) - ipdgcl(2,igraphs(1),iproc)=idup(2,ipsel,iproc) - if (btest(mlevel,2)) then - write(*,*) 'Set particle identities: ', - $ 1,ipdgcl(1,igraphs(1),iproc), - $ ' and ', - $ 2,ipdgcl(2,igraphs(1),iproc) - - endif - +c The flavours of this event are those of row IPSEL of leshouche.inc, +c as in DSIG. The rows always carry physical pdg codes (merged legs such +c as 81 are expanded per flavour), while FLAVOR(:) only holds the +c position of the coupling-class representative (see cluster). if(ickkw.le.0)then c Store pdf information for systematics studies (initial) if(use_syst)then do j=1,2 n_pdfrw(j,ivec)=1 - if(abs(ipdgcl(j,igraphs(1),iproc)).eq.81) then - i_pdgpdf(1,j,ivec)=abs(flavor(j))*sign(1,ipdgcl(j,igraphs(1),iproc)) - else - i_pdgpdf(1,j,ivec)=ipdgcl(j,igraphs(1),iproc) - endif + i_pdgpdf(1,j,ivec)=idup(j,ipsel,iproc) s_xpdf(1,j,ivec)=xbk(ib(j)) s_qpdf(1,j,ivec)=sqrt(q2fact(j)) enddo @@ -1545,21 +1534,24 @@ double precision function rewgt(p, flavor, ivec) endif vec_igraph(ivec) = igraphs(1) ! save MLM-matched graph for this event +c Set the external identities of the clustered graph to the flavours of +c this event: setclscales leaves there the representative of the +c coupling class of a grouped subprocess (e.g. d for s s~ > z, u for +c c c~ > z), which would otherwise enter the PDF ratios, the Sudakov +c factors and the pdfrwt information below. + do i=1,nexternal + ipdgcl(ishft(1,i-1),igraphs(1),iproc)=idup(i,ipsel,iproc) + if (btest(mlevel,2)) + $ write(*,*) 'Set particle identities: ', + $ i,ipdgcl(ishft(1,i-1),igraphs(1),iproc) + enddo c Store pdf information for systematics studies (initial) c need to be done after setclscales since that one clean the syscalc value -c The pdg is the one of the selected flavour combination (IPSEL), as in -c DSIG: after setclscales, ipdgcl of the external legs of a grouped -c subprocess holds the flavour class of the event (cluster maps it from -c the GET_FLAVOR index), e.g. 2 for c c~ > z or 1 for s s~ > z. if(use_syst)then do j=1,2 n_pdfrw(j,ivec)=1 - if(abs(idup(j,ipsel,iproc)).ne.81)then - i_pdgpdf(1,j,ivec)=idup(j,ipsel,iproc) - else - i_pdgpdf(1,j,ivec)=ipdgcl(j,igraphs(1),iproc) - endif + i_pdgpdf(1,j,ivec)=ipdgcl(j,igraphs(1),iproc) s_xpdf(1,j,ivec)=xbk(ib(j)) s_qpdf(1,j,ivec)=sqrt(q2fact(j)) enddo @@ -1616,12 +1608,6 @@ double precision function rewgt(p, flavor, ivec) c Prepare checking for parton vertices do i=1,nexternal j=ishft(1,i-1) -c Set jet identities according to chosen subprocess - if(isjet(idup(i,ipsel,iproc))) - $ ipdgcl(j,igraphs(1),iproc)=idup(i,ipsel,iproc) - if (btest(mlevel,2)) - $ write(*,*) 'Set particle identities: ', - $ i,ipdgcl(j,igraphs(1),iproc) if(i.le.2)then goodjet(j)=isparton(ipdgcl(j,igraphs(1),iproc)) elseif(iqjets(i).gt.0) then @@ -1891,15 +1877,9 @@ double precision function rewgt(p, flavor, ivec) c factor in front of matrix element do i=1,2 if (lpp(IB(i)).ne.0) then - if( abs(i_pdgpdf(1,i,ivec)).ne.81)then s_rwfact(ivec)=s_rwfact(ivec)*pdg2pdf(abs(lpp(IB(i))), $ i_pdgpdf(1,i,ivec)*sign(1,lpp(IB(i))),IB(i), $ s_xpdf(1,i,ivec),s_qpdf(1,i,ivec)) - else - s_rwfact(ivec)=s_rwfact(ivec)*pdg2pdf(abs(lpp(IB(i))), - $ abs(flavor(i))*sign(1, i_pdgpdf(1,i,ivec))*sign(1,lpp(IB(i))),IB(i), - $ s_xpdf(1,i,ivec),s_qpdf(1,i,ivec)) - endif endif enddo if (asref.gt.0d0.and.n_qcd(ivec).le.nexternal)then diff --git a/tests/acceptance_tests/test_cmd_madevent.py b/tests/acceptance_tests/test_cmd_madevent.py index e02a5212c6..4680f59a47 100755 --- a/tests/acceptance_tests/test_cmd_madevent.py +++ b/tests/acceptance_tests/test_cmd_madevent.py @@ -2083,6 +2083,55 @@ def test_flavor_grouping_consistency_mlm(self): ) + def test_mlm_pdfrwt_flavour(self): + """With MLM (ickkw=1) and use_syst, the leading entry of each + beam must be the event's incoming parton. A grouped subprocess + (q q~ > z with q = d s / u c) clusters with the representative of its + coupling class: s s~ > z was booked as 1 and c c~ > z as 2, so the + systematics of every s/c-initiated event used the d/u PDF.""" + + run_dir = pjoin(self.path, 'MGPROC_pdfrwt') + mg_cmd = MGCmd.MasterCmd() + mg_cmd.no_notification() + mg_cmd.exec_cmd('set automatic_html_opening False --no_save') + mg_cmd.exec_cmd('import model sm') + mg_cmd.exec_cmd('generate p p > z @0') + mg_cmd.exec_cmd('add process p p > z j @1') + mg_cmd.exec_cmd('output madevent %s' % run_dir) + + run_card = banner.RunCardLO(pjoin(run_dir, 'Cards', 'run_card.dat')) + run_card.set('nevents', 2000, user=True) + run_card.set('iseed', 7, user=True) + run_card.set('ickkw', 1, user=True) + run_card.set('xqcut', 20.0, user=True) + run_card.set('use_syst', True, user=True) + run_card.set('systematics_program', 'none', user=True) + run_card.write(pjoin(run_dir, 'Cards', 'run_card.dat')) + + self.cmd_line = MECmd.MadEventCmdShell(me_dir=run_dir) + self.cmd_line.no_notification() + self.cmd_line.exec_cmd('set automatic_html_opening False') + self.do('generate_events -f') + + import gzip + path = pjoin(run_dir, 'Events', 'run_01', 'unweighted_events.lhe.gz') + text = gzip.open(path, 'rt').read() + checked = collections.Counter() + for event in re.findall(r']*>(.*?)', text, re.S): + lines = [l.split() for l in event.split('<')[0].strip().splitlines()] + incoming = [int(l[0]) for l in lines[1:] if l[1] == '-1'] + pdfrwt = dict(re.findall(r'\s*\d+\s+(-?\d+)', event)) + self.assertEqual(sorted(pdfrwt), ['1', '2'], event) + for beam in (1, 2): + self.assertEqual(int(pdfrwt[str(beam)]), incoming[beam-1], + 'leading pdfrwt entry of beam %s is not the ' + 'incoming parton:\n%s' % (beam, event)) + checked[max(abs(p) for p in incoming if p != 21) if any( + p != 21 for p in incoming) else 21] += 1 + # the check is only meaningful with s and c initiated events + self.assertGreater(checked[3], 0, checked) + self.assertGreater(checked[4], 0, checked) + def test_t_channel_survey_grid(self): """check that refining on the survey grid is not biased for u q > z u q (all channels pure t-channel chains). With the grid From f39ddde7644b5167eb67e32df9fb8fe0759d71c3 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Tue, 27 Oct 2026 17:31:41 +0100 Subject: [PATCH 68/81] reweight.f/cluster.f: second review follow-ups - rewgt: set only the incoming partons and the jets of the clustered graph to row IPSEL (as before the previous commit), so non-QCD legs no longer carry the previous event's flavours into the next event's clustering. - rewgt: stop with a clear message if a leshouche row ever holds a merged id (81-83) for an incoming leg, instead of handing it to pdg2pdf. - cluster.f: map flavour position 6 as well (top merged into 81 when massless), consistent with the comment. - test_mlm_pdfrwt_flavour: identify the beams by the pz sign and check that every ladder quark flavour is present in the event. Same-seed p p > z + 0,1,2j (ickkw=1) sample identical to the previous commit (kinematics, mgrwt, every weight); the acceptance test passes. --- Template/LO/SubProcesses/cluster.f | 6 +- Template/LO/SubProcesses/reweight.f | 23 +++-- tests/acceptance_tests/test_cmd_madevent.py | 99 +++++++++++++-------- 3 files changed, 80 insertions(+), 48 deletions(-) diff --git a/Template/LO/SubProcesses/cluster.f b/Template/LO/SubProcesses/cluster.f index e9d81f3bf8..57b832abc9 100644 --- a/Template/LO/SubProcesses/cluster.f +++ b/Template/LO/SubProcesses/cluster.f @@ -666,12 +666,12 @@ logical function cluster(p, flavor, ivec) c combination (IPSEL) is only drawn after the scales are set, and rewgt c sets the external identities to it after clustering. do i=1,nexternal - if(flavor(i).ge.1.and.flavor(i).le.5)then + if(flavor(i).ge.1.and.flavor(i).le.6)then do igraph=1,n_max_cg tmppdg=ipdgcl(ishft(1,i-1),igraph,iproc) - if(tmppdg.eq.81.or.(tmppdg.gt.0.and.tmppdg.le.5)) then + if(tmppdg.eq.81.or.(tmppdg.gt.0.and.tmppdg.le.6)) then ipdgcl(ishft(1,i-1),igraph,iproc)=flavor(i) - else if(tmppdg.eq.-81.or.(tmppdg.lt.0.and.tmppdg.ge.-5))then + else if(tmppdg.eq.-81.or.(tmppdg.lt.0.and.tmppdg.ge.-6))then ipdgcl(ishft(1,i-1),igraph,iproc)=-flavor(i) endif enddo diff --git a/Template/LO/SubProcesses/reweight.f b/Template/LO/SubProcesses/reweight.f index b8e59ef534..13168a9f62 100644 --- a/Template/LO/SubProcesses/reweight.f +++ b/Template/LO/SubProcesses/reweight.f @@ -1508,6 +1508,15 @@ double precision function rewgt(p, flavor, ivec) c as in DSIG. The rows always carry physical pdg codes (merged legs such c as 81 are expanded per flavour), while FLAVOR(:) only holds the c position of the coupling-class representative (see cluster). + do j=1,2 + if(abs(idup(j,ipsel,iproc)).ge.81.and. + $ abs(idup(j,ipsel,iproc)).le.83)then + write(*,*) 'Error: rewgt: merged particle ',idup(j,ipsel,iproc), + $ ' in leshouche.inc (row ',ipsel,'): no physical pdg for', + $ ' the PDF of beam ',j + stop 1 + endif + enddo if(ickkw.le.0)then c Store pdf information for systematics studies (initial) @@ -1534,13 +1543,15 @@ double precision function rewgt(p, flavor, ivec) endif vec_igraph(ivec) = igraphs(1) ! save MLM-matched graph for this event -c Set the external identities of the clustered graph to the flavours of -c this event: setclscales leaves there the representative of the -c coupling class of a grouped subprocess (e.g. d for s s~ > z, u for -c c c~ > z), which would otherwise enter the PDF ratios, the Sudakov -c factors and the pdfrwt information below. +c Set the identities of the incoming partons and of the jets of the +c clustered graph to the flavours of this event: setclscales leaves there +c the representative of the coupling class of a grouped subprocess (e.g. +c d for s s~ > z, u for c c~ > z), which would otherwise enter the PDF +c ratios, the Sudakov factors and the pdfrwt information below. Other +c legs are left as set by cluster (nothing below reads their flavour). do i=1,nexternal - ipdgcl(ishft(1,i-1),igraphs(1),iproc)=idup(i,ipsel,iproc) + if(i.le.2.or.isjet(idup(i,ipsel,iproc))) + $ ipdgcl(ishft(1,i-1),igraphs(1),iproc)=idup(i,ipsel,iproc) if (btest(mlevel,2)) $ write(*,*) 'Set particle identities: ', $ i,ipdgcl(ishft(1,i-1),igraphs(1),iproc) diff --git a/tests/acceptance_tests/test_cmd_madevent.py b/tests/acceptance_tests/test_cmd_madevent.py index 4680f59a47..f7d9a5ffb2 100755 --- a/tests/acceptance_tests/test_cmd_madevent.py +++ b/tests/acceptance_tests/test_cmd_madevent.py @@ -2084,53 +2084,74 @@ def test_flavor_grouping_consistency_mlm(self): def test_mlm_pdfrwt_flavour(self): - """With MLM (ickkw=1) and use_syst, the leading entry of each - beam must be the event's incoming parton. A grouped subprocess - (q q~ > z with q = d s / u c) clusters with the representative of its - coupling class: s s~ > z was booked as 1 and c c~ > z as 2, so the - systematics of every s/c-initiated event used the d/u PDF.""" + """With MLM (ickkw=1) and use_syst, the information must carry + the event's flavours. A grouped + subprocess (q q~ > z with q = d s / u c) clusters with the + representative of its coupling class: s s~ > z was booked as 1 and + c c~ > z as 2, so the systematics of every s/c-initiated event used + the d/u PDF.""" - run_dir = pjoin(self.path, 'MGPROC_pdfrwt') + import gzip mg_cmd = MGCmd.MasterCmd() mg_cmd.no_notification() mg_cmd.exec_cmd('set automatic_html_opening False --no_save') mg_cmd.exec_cmd('import model sm') mg_cmd.exec_cmd('generate p p > z @0') mg_cmd.exec_cmd('add process p p > z j @1') - mg_cmd.exec_cmd('output madevent %s' % run_dir) - - run_card = banner.RunCardLO(pjoin(run_dir, 'Cards', 'run_card.dat')) - run_card.set('nevents', 2000, user=True) - run_card.set('iseed', 7, user=True) - run_card.set('ickkw', 1, user=True) - run_card.set('xqcut', 20.0, user=True) - run_card.set('use_syst', True, user=True) - run_card.set('systematics_program', 'none', user=True) - run_card.write(pjoin(run_dir, 'Cards', 'run_card.dat')) - - self.cmd_line = MECmd.MadEventCmdShell(me_dir=run_dir) - self.cmd_line.no_notification() - self.cmd_line.exec_cmd('set automatic_html_opening False') - self.do('generate_events -f') + # scalar mode only: vector_size>1 with grouped flavours does not run + # (DSIG_VEC reads the per-warp IFLAV_VEC per event) + for vector_size in [1]: + run_dir = pjoin(self.path, 'MGPROC_pdfrwt_%d' % vector_size) + mg_cmd.exec_cmd('output madevent %s' % run_dir) - import gzip - path = pjoin(run_dir, 'Events', 'run_01', 'unweighted_events.lhe.gz') - text = gzip.open(path, 'rt').read() - checked = collections.Counter() - for event in re.findall(r']*>(.*?)', text, re.S): - lines = [l.split() for l in event.split('<')[0].strip().splitlines()] - incoming = [int(l[0]) for l in lines[1:] if l[1] == '-1'] - pdfrwt = dict(re.findall(r'\s*\d+\s+(-?\d+)', event)) - self.assertEqual(sorted(pdfrwt), ['1', '2'], event) - for beam in (1, 2): - self.assertEqual(int(pdfrwt[str(beam)]), incoming[beam-1], - 'leading pdfrwt entry of beam %s is not the ' - 'incoming parton:\n%s' % (beam, event)) - checked[max(abs(p) for p in incoming if p != 21) if any( - p != 21 for p in incoming) else 21] += 1 - # the check is only meaningful with s and c initiated events - self.assertGreater(checked[3], 0, checked) - self.assertGreater(checked[4], 0, checked) + run_card = banner.RunCardLO(pjoin(run_dir, 'Cards', 'run_card.dat')) + run_card.set('nevents', 2000, user=True) + run_card.set('iseed', 7, user=True) + run_card.set('ickkw', 1, user=True) + run_card.set('xqcut', 20.0, user=True) + run_card.set('use_syst', True, user=True) + run_card.set('systematics_program', 'none', user=True) + if vector_size > 1: + run_card.set('vector_size', vector_size, user=True) + run_card.write(pjoin(run_dir, 'Cards', 'run_card.dat')) + + self.cmd_line = MECmd.MadEventCmdShell(me_dir=run_dir) + self.cmd_line.no_notification() + self.cmd_line.exec_cmd('set automatic_html_opening False') + self.do('generate_events -f') + + path = pjoin(run_dir, 'Events', 'run_01', 'unweighted_events.lhe.gz') + text = gzip.open(path, 'rt').read() + checked = collections.Counter() + for event in re.findall(r']*>(.*?)', text, re.S): + lines = [l.split() for l in event.split('<')[0].strip().splitlines()] + partons = [(int(l[0]), int(l[1]), float(l[8])) for l in lines[1:]] + # beam 1 moves along +z, beam 2 along -z + beam = dict((1 if pz > 0 else 2, pdg) + for pdg, status, pz in partons if status == -1) + flavours = set(abs(pdg) for pdg, status, pz in partons + if abs(pdg) <= 6) + pdfrwt = dict((int(b), [int(x) for x in body.split()[1:1+int(body.split()[0])]]) + for b, body in re.findall( + r'(.*?)', event)) + self.assertEqual(sorted(pdfrwt), [1, 2], event) + for b in (1, 2): + # leading entry: the incoming parton of this beam + self.assertEqual(pdfrwt[b][0], beam[b], + 'vector_size=%s: leading pdfrwt entry of beam %s is ' + 'not the incoming parton:\n%s' % (vector_size, b, event)) + # ladder: only quark flavours present in the event + for pdg in pdfrwt[b][1:]: + if abs(pdg) <= 6: + self.assertIn(abs(pdg), flavours, + 'vector_size=%s: pdfrwt ladder of beam %s has a ' + 'flavour absent from the event:\n%s' + % (vector_size, b, event)) + quarks = [abs(pdg) for pdg in beam.values() if pdg != 21] + checked[max(quarks) if quarks else 21] += 1 + # the check is only meaningful with s and c initiated events + self.assertGreater(checked[3], 0, checked) + self.assertGreater(checked[4], 0, checked) def test_t_channel_survey_grid(self): """check that refining on the survey grid is not biased for From caec51025e91262966e6589f970219ad66657962 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Tue, 27 Oct 2026 17:54:41 +0100 Subject: [PATCH 69/81] MLM per-diagram history: rescale the first matrix element, don't re-evaluate Under clustering_history = "diagram" or "madevent" every accepted event went through the matrix element twice: once at the scales of the history over every diagram (which also gives the diagram weights the history is picked from), and again at the scales of the history it settles on. Only the scales change between the two, and the matrix element sees them only through alpha_s. With one power n of alpha_s for every diagram, |M|^2 = C alpha_s^n and the cross section is linear in each beam density, so the second evaluation is the first one times (alpha_s' / alpha_s)^n f1' f2' / (f1 f2) - to rounding, and exactly where the history did not change, which under "madevent" is most events. The colour, helicity and diagram weights of the first call stay valid: their probabilities are ratios of terms of the same power. Integrand takes the power as mlm_history_qcd_power (launch.py passes the subprocess's qcd_power); -1 (mixed powers, e.g. gluon fusion with VBF) keeps the second evaluation. A first density of exactly zero keeps its zero weight. t t~ + 0,1,2j, 100k events, wall time: madevent history 80 s -> 46 s (all_diagrams: 40 s). Cross sections unchanged: t t~ nominal 500k 2157.10 -> 2157.23 pb, W+-+0,1,2j madevent-like 14656.8 -> 14656.9 pb (MC errors 0.04-0.1 %). No unit test compares the two paths event by event: the Integrand draws its own random numbers, unseeded from Python. --- madgraph/iolibs/template_files/mg7/launch.py | 6 +++ .../include/madspace/phasespace/integrand.hpp | 14 ++++- madspace/src/phasespace/integrand.cpp | 54 ++++++++++++++++--- madspace/src/python/madspace.cpp | 4 +- 4 files changed, 68 insertions(+), 10 deletions(-) diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index 0dedb05c70..d0c6f20432 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -3670,6 +3670,11 @@ def build_integrands( partial_weights = self.process.systematics_enabled() madnis_args = self.process.run_card["madnis"] custom_bias = self.process.custom_function("bias", self.particle_count, flavors) + # With an MLM history picked per diagram, the cross section at the + # scales of that history is the first matrix-element evaluation rescaled + # by alpha_s and PDF ratios when every diagram has the same power of + # alpha_s (-1: mixed powers, evaluate the matrix element again) + mlm_history_qcd_power = int(self.meta.get("qcd_power", -1)) integrands = [] for channel in phasespace.channels: integrands.append(ms.Integrand( @@ -3703,6 +3708,7 @@ def build_integrands( madnis_args["compressed_channel_weight_count"], custom_bias, None if self.process.leptonic else self.process.pdf_grid2, + mlm_history_qcd_power, )) #print(integrands[1].function()) #for i in integrands: print(i.function()) diff --git a/madspace/include/madspace/phasespace/integrand.hpp b/madspace/include/madspace/phasespace/integrand.hpp index decca9daf4..30611319fe 100644 --- a/madspace/include/madspace/phasespace/integrand.hpp +++ b/madspace/include/madspace/phasespace/integrand.hpp @@ -146,6 +146,15 @@ class Integrand : public FunctionGenerator { * the prefix @ref pdf2_prefix. * Not allowed with a @ref CustomPdf, * which receives the beam index. + * @param mlm_history_qcd_power Power of alpha_s in |M|^2 when it + * is the same for every diagram, + * -1 otherwise. With an MLM history + * picked per diagram, the matrix + * element at the scales of that + * history is then the first + * evaluation rescaled by the alpha_s + * and PDF ratios instead of a second + * evaluation. */ Integrand( const PhaseSpaceMapping& mapping, @@ -177,7 +186,8 @@ class Integrand : public FunctionGenerator { const std::vector& flavor_per_subproc_remap = {}, std::size_t compressed_channel_weight_count = 50, const std::optional& custom_bias = std::nullopt, - const std::optional& pdf_grid2 = std::nullopt + const std::optional& pdf_grid2 = std::nullopt, + int mlm_history_qcd_power = -1 ); /// Globals prefix under which the second beam's PDF grid is registered /// (PdfGrid::initialize_globals) when `pdf_grid2` is given. @@ -299,6 +309,8 @@ class Integrand : public FunctionGenerator { // MLMClustering::diagram_start_states padded to the matrix element's // diagram count, so that any diagram the pick can return has an entry. std::vector _mlm_start_states; + // see mlm_history_qcd_power in the constructor; -1 re-evaluates + int _mlm_history_qcd_power = -1; // abs pdg id of every external leg per flavour option, row-major, for the // clusterings that only exist for some flavours of a merged subprocess. // Empty when the clustering does not depend on the flavours. diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index 630d63c651..49ac1eb841 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -110,7 +110,8 @@ Integrand::Integrand( const std::vector& flavor_per_subproc_remap, std::size_t compressed_channel_weight_count, const std::optional& custom_bias, - const std::optional& pdf_grid2 + const std::optional& pdf_grid2, + int mlm_history_qcd_power ) : FunctionGenerator( "Integrand", @@ -285,6 +286,7 @@ Integrand::Integrand( flavor_per_subproc_remap.begin(), flavor_per_subproc_remap.end() ), _custom_bias(custom_bias) { + _mlm_history_qcd_power = mlm_history_qcd_power; if (energy_scale && energy_scale->mlm_flavor_dependent() && pid_options.size() > 1) { for (auto& option : pid_options) { @@ -1308,10 +1310,11 @@ NamedVector Integrand::build_common_part( // |A_i|^2 / sum_j |A_j|^2 (and the channel weights, which stay as they // are). One diagram is picked from them - a choice between histories, not // an importance sample, so it puts no factor on the weight - the event is - // clustered along it, and the matrix element is evaluated again at the - // scales that history gives. Evaluating it again rather than rescaling by - // alpha_s ratios keeps this exact when coupling orders mix, as they do for - // gluon fusion and VBF in one process. + // clustered along it, and the cross section is taken at the scales that + // history gives: by rescaling the first evaluation when every diagram has + // the same power of alpha_s (mlm_history_qcd_power), by evaluating the + // matrix element again when coupling orders mix, as they do for gluon + // fusion and VBF in one process. ValueVec mlm_history_weights; if (_energy_scale && _energy_scale->mlm_history_per_diagram()) { auto batch_size_acc = fb.batch_size({momenta_acc}); @@ -1345,6 +1348,8 @@ NamedVector Integrand::build_common_part( fact_scales_acc.at(i) = scales.at(std::format("fact_scale{}", i + 1)); } } + auto pdfs_first = pdfs_acc; + auto alpha_first = alpha_qcd_acc; pdfs_acc = evaluate_pdfs(fb, scales, x_acc, flavor_id); cluster_scales_acc = scales.at("outgoing_scales"); if (has_alphas_scales) { @@ -1352,9 +1357,42 @@ NamedVector Integrand::build_common_part( } alpha_qcd_acc = _running_coupling.value().build_function(fb, {ren_scale_acc}).at(0); - dxs_vec = _diff_xs.at(0) - .build_function(fb, make_xs_args(pdfs_acc, alpha_qcd_acc)) - .values(); + if (_mlm_history_qcd_power >= 0) { + // Only the scales changed, and the matrix element sees them only + // through alpha_s: with one power n of alpha_s for every diagram + // |M|^2 = C alpha_s^n, and the cross section is linear in each + // beam density. So the first evaluation, rescaled by + // (alpha_s' / alpha_s)^n f1' f2' / (f1 f2), is the second one to + // rounding - and exactly it where the history did not change, as + // for most events under clustering_history = madevent. The colour, + // helicity and diagram weights of the first evaluation stay valid: + // their probabilities are ratios of terms of the same power. + auto batch_size_acc = fb.batch_size({momenta_acc}); + auto ones = fb.full({1., batch_size_acc}); + Value factor = ones; + auto alpha_ratio = fb.div(alpha_qcd_acc, alpha_first); + for (int k = 0; k < _mlm_history_qcd_power; ++k) { + factor = fb.mul(factor, alpha_ratio); + } + for (std::size_t i = 0; i < 2; ++i) { + if (_diff_xs.at(0).has_pdf(i)) { + // a first density of exactly zero gave a zero weight, which + // stays zero (the guard only keeps 0 * x / 0 from a NaN) + auto denominator = fb.add( + pdfs_first.at(i), fb.cut_one(pdfs_first.at(i), 0., 0.) + ); + factor = + fb.mul(factor, fb.div(pdfs_acc.at(i), denominator)); + } + } + dxs_vec.at(0) = fb.mul(dxs_vec.at(0), factor); + } else { + // several powers of alpha_s mix (gluon fusion and VBF in one + // process, say): the matrix element has to be evaluated again + dxs_vec = _diff_xs.at(0) + .build_function(fb, make_xs_args(pdfs_acc, alpha_qcd_acc)) + .values(); + } } auto diff_xs_acc = dxs_vec.at(0); diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index ccbab96d9b..94a6229cf0 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -2399,7 +2399,8 @@ PYBIND11_MODULE(_madspace_py, m) { const std::vector&, std::size_t, const std::optional&, - const std::optional&>(), + const std::optional&, + int>(), py::arg("mapping"), py::arg("diff_xs"), py::arg("adaptive_map") = std::monostate{}, @@ -2430,6 +2431,7 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("compressed_channel_weight_count") = 50, py::arg("custom_bias") = std::nullopt, py::arg("pdf_grid2") = std::nullopt, + py::arg("mlm_history_qcd_power") = -1, pydoc::doc("Integrand::Integrand") ) .def( From 341151f4dade9825bca22e39a318f11fad54ec13 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 28 Oct 2026 08:45:45 +0100 Subject: [PATCH 70/81] MLM: optional pdf ladder under scale_scheme = clustering_mean beam.clustering_mean_pdf_ladder (off by default) walks madevent's pdf ladder under clustering_mean: the beam densities, the per-beam factorisation scale they end at and the ladder slots are madevent's, while mu_R, the alpha_s vertex scales, the jet scales and the xqcut veto stay clustering_mean's. Without it clustering_mean evaluates both densities once at the softest clustering scale, which is what moves the W+jets 1-jet rate near qcut by ~25% against madevent. The kernel runs the madevent beam-line walk when pdf_reweighting == 2 and restores the clustering_mean outputs after it; native systematics vary the ladder in that case too. Co-Authored-By: Claude --- madgraph/iolibs/template_files/mg7/launch.py | 12 +++-- .../iolibs/template_files/mg7/run_card.toml | 5 ++ madgraph/various/banner.py | 9 ++++ .../madspace/phasespace/mlm_clustering.hpp | 10 +++- .../include/madspace/phasespace/scale.hpp | 3 +- madspace/instruction_set.yaml | 4 +- madspace/src/kernels/mlm.hpp | 33 +++++++++++- madspace/src/phasespace/mlm_clustering.cpp | 14 ++++-- madspace/src/python/madspace.cpp | 6 ++- madspace/tests/test_mlm.py | 50 +++++++++++++++++++ 10 files changed, 133 insertions(+), 13 deletions(-) diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index d0c6f20432..a6b1d14c59 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -1151,12 +1151,15 @@ def build_systematics(self): else: config.alps = [1.0] # the pdf reweighting ladder of an MLM sample under madevent's scale - # scheme, varied as madevent's systematics vary it; a varied scale is - # kept inside the PDF grid, where LHAPDF freezes it for madevent + # scheme (or clustering_mean's with clustering_mean_pdf_ladder), varied + # as madevent's systematics vary it; a varied scale is kept inside the + # PDF grid, where LHAPDF freezes it for madevent beam = self.run_card["beam"] config.pdf_ladder = bool( self.mlm_clustering and not self.is_decay and not self.leptonic - and beam["scale_scheme"] == "madevent" and beam["pdf_reweighting"]) + and beam["pdf_reweighting"] + and (beam["scale_scheme"] == "madevent" + or beam["clustering_mean_pdf_ladder"])) if config.pdf_ladder: config.ladder_min_scale = float(self.min_pdf_scale) config.ladder_max_scale = float(self.max_scale) @@ -2942,6 +2945,9 @@ def cut_pids(observable_kwargs): ms.MLMClustering.ClusteringHistory, self.process.run_card["beam"]["clustering_history"], ), + clustering_mean_pdf_ladder=self.process.run_card["beam"][ + "clustering_mean_pdf_ladder" + ], ), min_scale=min_scale, max_scale=max_scale, diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index 8159b0bd40..fdac65c1cb 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -98,6 +98,11 @@ alphas_reweighting = %(beam.alphas_reweighting)s # at the factorisation scale, as madevent's pdfwgt does. Only used under # dynamical_scale_choice = "mlm" and scale_scheme = "madevent" pdf_reweighting = %(beam.pdf_reweighting)s +# with pdf_reweighting, walk the same pdf ladder under scale_scheme = +# "clustering_mean" (densities as madevent's, mu_R and alpha_s as +# clustering_mean's) instead of evaluating both densities at the softest +# clustering scale +clustering_mean_pdf_ladder = %(beam.clustering_mean_pdf_ladder)s # measure scoring a candidate final-state clustering (dynamical_scale_choice = # "mlm"). options: fxfx (NLO cluster_scale), madevent (madevent's LO DJ: a # massless-massive pair scores the massless one's transverse mass). They only diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index 69a8e994c7..87a0124b41 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6888,6 +6888,15 @@ def default_setup(self): # and scale_scheme = "madevent". self.add_toml_param('beam', 'pdf_reweighting', True, comment="re-evaluate the beam densities along the MLM clustering ladder") + # The same ladder under scale_scheme = "clustering_mean": the beam + # densities are then evaluated as madevent evaluates them (per-beam + # factorisation scale, lowered to the scale that pulled the parton out + # of the beam and walked back up), while mu_R, the alpha_s vertex + # scales and the jet scales stay clustering_mean's. Without it + # clustering_mean takes both densities once at the softest clustering + # scale. Needs pdf_reweighting. + self.add_toml_param('beam', 'clustering_mean_pdf_ladder', False, + comment="also walk the pdf ladder under scale_scheme = clustering_mean") # Which measure scores a candidate final-state clustering. The two only # differ for a non-resonant pair with a massless mother and one massive # and one massless daughter (q* > q W, g* > g h), or a massive mother diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp index af5d85fb2c..f2ac399b72 100644 --- a/madspace/include/madspace/phasespace/mlm_clustering.hpp +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -139,7 +139,13 @@ class MLMClustering : public FunctionGenerator { ClusteringMeasure clustering_measure = ClusteringMeasure::fxfx, // Anything but all_diagrams also compiles one state machine per // diagram, which build_along_diagram walks. - ClusteringHistory clustering_history = ClusteringHistory::all_diagrams + ClusteringHistory clustering_history = ClusteringHistory::all_diagrams, + // With pdf_reweighting, also walk the densities along the clustering + // ladder under scale_scheme = clustering_mean: the densities (their + // lowered leading scale, the ladder ratios and the per-beam scale the + // ladder ends at) are then madevent's, while mu_R, the alpha_s vertex + // scales and the jet bookkeeping stay clustering_mean's. + bool clustering_mean_pdf_ladder = false ); // The same outputs as the function itself, for a history restricted to @@ -183,6 +189,7 @@ class MLMClustering : public FunctionGenerator { const std::vector& bw_widths() const { return _bw_widths; } AlphasScheme alphas_scheme() const { return _alphas_scheme; } bool pdf_reweighting() const { return _pdf_reweighting; } + bool clustering_mean_pdf_ladder() const { return _clustering_mean_pdf_ladder; } ScaleScheme scale_scheme() const { return _scale_scheme; } ClusteringMeasure clustering_measure() const { return _clustering_measure; } ClusteringHistory clustering_history() const { return _clustering_history; } @@ -223,6 +230,7 @@ class MLMClustering : public FunctionGenerator { PartonLineScheme _parton_line_scheme; AlphasScheme _alphas_scheme; bool _pdf_reweighting; + bool _clustering_mean_pdf_ladder; ClusteringMeasure _clustering_measure; ClusteringHistory _clustering_history; std::vector _pdf_absolute_pdgs; diff --git a/madspace/include/madspace/phasespace/scale.hpp b/madspace/include/madspace/phasespace/scale.hpp index 83df51b135..df73a98238 100644 --- a/madspace/include/madspace/phasespace/scale.hpp +++ b/madspace/include/madspace/phasespace/scale.hpp @@ -168,7 +168,8 @@ class EnergyScale : public FunctionGenerator { // ratios would all come out as exactly one. bool mlm_pdf_reweighting() const { return _clustering && _clustering->pdf_reweighting() && - _clustering->scale_scheme() == ScaleScheme::madevent; + (_clustering->scale_scheme() == ScaleScheme::madevent || + _clustering->clustering_mean_pdf_ladder()); } const std::vector& mlm_pdf_absolute_pdgs() const { return _clustering.value().pdf_absolute_pdgs(); diff --git a/madspace/instruction_set.yaml b/madspace/instruction_set.yaml index 129dba4774..9cdde104b5 100644 --- a/madspace/instruction_set.yaml +++ b/madspace/instruction_set.yaml @@ -2794,7 +2794,7 @@ mlm_clustering_hadronic: desc: - name: pdf_reweighting type: [int, single] - desc: + desc: "0 off, 1 the pdf ladder under scale_scheme madevent, 2 also under clustering_mean" - name: clustering_measure type: [int, single] desc: @@ -2917,7 +2917,7 @@ mlm_clustering_leptonic: desc: - name: pdf_reweighting type: [int, single] - desc: + desc: "0 off, 1 the pdf ladder under scale_scheme madevent, 2 also under clustering_mean" - name: clustering_measure type: [int, single] desc: diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 510a317c54..4e77b9e379 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -804,7 +804,25 @@ KERNELSPEC int mlm_clustering_walk( } FVal pdf_scale_val[2] = {-1.0, -1.0}; - if (scale_scheme == SCALES_MADEVENT) { + // pdf_reweighting == 2 under clustering_mean: walk the beam lines as + // madevent does below, but keep only the pdf half of it - the lowered + // leading scale, the ladder and the per-beam scale it ends at, which is + // also the factorisation scale then reported. mu_R, the alpha_s vertex + // scales, the jet scales and the merging-jet gating are put back to + // clustering_mean's after the walk. Without it clustering_mean evaluates + // both beam densities at the softest clustering scale, with no ladder. + bool mean_with_ladder = scale_scheme != SCALES_MADEVENT && pdf_reweighting == 2; + FVal mean_ren_scale = ren_scale_val; + FVal mean_alphas_step[N_EXT_MAX - 3]; + FVal mean_outgoing[N_EXT_MAX]; + for (int i = 0; i < cluster_max; ++i) { + mean_alphas_step[i] = alphas_step[i]; + } + for (int i = 0; i < n_part - 2; ++i) { + mean_outgoing[i] = outgoing_scales[i]; + } + + if (scale_scheme == SCALES_MADEVENT || mean_with_ladder) { // madevent clusters one step further than this kernel does: its loop // runs all the way to the 2 -> 1 configuration, so it has one more // scale than cluster_max. That last step absorbs whatever is left of @@ -1372,6 +1390,19 @@ KERNELSPEC int mlm_clustering_walk( } } } + if (mean_with_ladder) { + ren_scale_val = mean_ren_scale; + for (int i = 0; i < cluster_max; ++i) { + alphas_step[i] = mean_alphas_step[i]; + } + for (int i = 0; i < n_part - 2; ++i) { + outgoing_scales[i] = mean_outgoing[i]; + } + for (int i = 0; i < n_part; ++i) { + merging_jet[i] = false; + } + have_merging_jets = false; + } // The merging cut. A matrix-element jet below xqcut is radiation the parton // shower is meant to produce, so the event is dropped - but only for a leg diff --git a/madspace/src/phasespace/mlm_clustering.cpp b/madspace/src/phasespace/mlm_clustering.cpp index f6bb479174..edd907815d 100644 --- a/madspace/src/phasespace/mlm_clustering.cpp +++ b/madspace/src/phasespace/mlm_clustering.cpp @@ -620,7 +620,8 @@ MLMClustering::MLMClustering( AlphasScheme alphas_scheme, bool pdf_reweighting, ClusteringMeasure clustering_measure, - ClusteringHistory clustering_history + ClusteringHistory clustering_history, + bool clustering_mean_pdf_ladder ) : FunctionGenerator( "MLMClustering", @@ -667,6 +668,7 @@ MLMClustering::MLMClustering( _parton_line_scheme(parton_line_scheme), _alphas_scheme(alphas_scheme), _pdf_reweighting(pdf_reweighting), + _clustering_mean_pdf_ladder(clustering_mean_pdf_ladder), _clustering_measure(clustering_measure), _clustering_history(clustering_history), _beam_flags(0), @@ -1098,7 +1100,10 @@ NamedVector MLMClustering::build_kernel( static_cast(_jet_leg_mask), static_cast(_parton_line_scheme), static_cast(_alphas_scheme), - static_cast(_pdf_reweighting), + // 2: the ladder under clustering_mean too (see the kernel) + static_cast( + _pdf_reweighting ? (_clustering_mean_pdf_ladder ? 2 : 1) : 0 + ), static_cast(_clustering_measure), history_mode, flavor_index, @@ -1123,7 +1128,10 @@ NamedVector MLMClustering::build_kernel( static_cast(_jet_leg_mask), static_cast(_parton_line_scheme), static_cast(_alphas_scheme), - static_cast(_pdf_reweighting), + // 2: the ladder under clustering_mean too (see the kernel) + static_cast( + _pdf_reweighting ? (_clustering_mean_pdf_ladder ? 2 : 1) : 0 + ), static_cast(_clustering_measure), history_mode, flavor_index, diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index 94a6229cf0..c5f7f3251d 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -1924,7 +1924,8 @@ PYBIND11_MODULE(_madspace_py, m) { AlphasScheme, bool, ClusteringMeasure, - ClusteringHistory>(), + ClusteringHistory, + bool>(), py::arg("topologies"), py::arg("permutations"), py::arg("diagram_indices"), @@ -1942,7 +1943,8 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("alphas_scheme") = AlphasScheme::per_vertex, py::arg("pdf_reweighting") = true, py::arg("clustering_measure") = ClusteringMeasure::fxfx, - py::arg("clustering_history") = ClusteringHistory::all_diagrams + py::arg("clustering_history") = ClusteringHistory::all_diagrams, + py::arg("clustering_mean_pdf_ladder") = false ) .def_property_readonly( "pdf_absolute_pdgs", &MLMClustering::pdf_absolute_pdgs diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index f985a6976b..f135288ce9 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -1491,6 +1491,56 @@ def test_pdf_reweighting_off_leaves_the_density_where_the_scale_is( assert np.all(out["pdf_rw_active"] == 0.0) +def test_clustering_mean_pdf_ladder_takes_only_the_densities_from_madevent( + t_channel_momenta, +): + """The ladder under clustering_mean is madevent's pdf half grafted onto + clustering_mean: the densities, the factorisation scales they end at and + every ladder slot are madevent's, while mu_R, the jet scales, the alpha_s + vertex scales and the xqcut veto stay clustering_mean's.""" + scheme = ms.MLMClustering.ScaleScheme + mean = t_channel_clustering(scale_scheme=scheme.clustering_mean, xqcut=20.0) + madevent = t_channel_clustering(scale_scheme=scheme.madevent, xqcut=20.0) + ladder = t_channel_clustering( + scale_scheme=scheme.clustering_mean, xqcut=20.0, + clustering_mean_pdf_ladder=True, + ) + # off unless asked for: clustering_mean keeps the density at its scale + assert np.all(pdf_outputs(mean, t_channel_momenta)["pdf_rw_active"] == 0.0) + + ren, _, _, jets, diagram, veto = run_all(ladder, t_channel_momenta) + ren_m, _, _, jets_m, diagram_m, veto_m = run_all(mean, t_channel_momenta) + assert np.array_equal(ren, ren_m) + assert np.array_equal(jets, jets_m) + assert np.array_equal(diagram, diagram_m) + assert np.array_equal(veto, veto_m) + for got, want in zip( + alphas_outputs(ladder, t_channel_momenta), + alphas_outputs(mean, t_channel_momenta), + ): + assert np.array_equal(got, want) + + _, fact1, fact2, _, _ = run(ladder, t_channel_momenta) + _, fact1_e, fact2_e, _, _ = run(madevent, t_channel_momenta) + assert np.array_equal(fact1, fact1_e) + assert np.array_equal(fact2, fact2_e) + out = pdf_outputs(ladder, t_channel_momenta) + out_e = pdf_outputs(madevent, t_channel_momenta) + assert (out["pdf_rw_active"] == 1.0).any() + for name in PDF_OUTPUTS: + assert np.array_equal(out[name], out_e[name]), name + + # and the switch is inert without pdf_reweighting + off = t_channel_clustering( + scale_scheme=scheme.clustering_mean, xqcut=20.0, + clustering_mean_pdf_ladder=True, pdf_reweighting=False, + ) + _, fact1_o, fact2_o, _, _ = run(off, t_channel_momenta) + _, fact1_m, fact2_m, _, _ = run(mean, t_channel_momenta) + assert np.array_equal(fact1_o, fact1_m) + assert np.array_equal(fact2_o, fact2_m) + + def test_pdf_reweighting_drops_the_density_down_the_ladder(t_channel_momenta): """The point of the whole thing: with it on, the density is asked for at or below the factorisation scale, and for a real part of the sample strictly From cf7fdca37b35cfd4ab076922cc8a20c3de348b2f Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 28 Oct 2026 11:46:00 +0100 Subject: [PATCH 71/81] MLM: walk the pdf ladder under clustering_mean by default clustering_mean_pdf_ladder now defaults to true, in the run card and in the madspace API. Against madevent (Pythia8 DJRs) it closes the 12-15% hole above qcut that the single softest-scale density leaves for W+jets and W Z+jets, and halves the tt~+jets excess; it made no sample worse. The unit tests about clustering_mean's own factorisation scale switch it off explicitly. Co-Authored-By: Claude --- .../iolibs/template_files/mg7/run_card.toml | 3 +- madgraph/various/banner.py | 11 ++-- .../madspace/phasespace/mlm_clustering.hpp | 7 ++- madspace/src/python/madspace.cpp | 2 +- madspace/tests/test_mlm.py | 57 ++++++++++++------- 5 files changed, 53 insertions(+), 27 deletions(-) diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index fdac65c1cb..0d4e4a3e08 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -96,7 +96,8 @@ parton_line_scheme = %(beam.parton_line_scheme)s alphas_reweighting = %(beam.alphas_reweighting)s # re-evaluate the beam densities along the clustering ladder rather than once # at the factorisation scale, as madevent's pdfwgt does. Only used under -# dynamical_scale_choice = "mlm" and scale_scheme = "madevent" +# dynamical_scale_choice = "mlm" (with scale_scheme = "clustering_mean" only +# together with clustering_mean_pdf_ladder) pdf_reweighting = %(beam.pdf_reweighting)s # with pdf_reweighting, walk the same pdf ladder under scale_scheme = # "clustering_mean" (densities as madevent's, mu_R and alpha_s as diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index 87a0124b41..42d56db3a6 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -6884,8 +6884,9 @@ def default_setup(self): # the emission that pulled the parton out of the beam and walked back # up, with the momentum fraction rescaled at each step. This is the # pdf half of the same rewgt loop, madevent's hidden pdfwgt flag, which - # is on by default there. Only read when dynamical_scale_choice = "mlm" - # and scale_scheme = "madevent". + # is on by default there. Only read when dynamical_scale_choice = "mlm", + # and under scale_scheme = "clustering_mean" only together with + # clustering_mean_pdf_ladder. self.add_toml_param('beam', 'pdf_reweighting', True, comment="re-evaluate the beam densities along the MLM clustering ladder") # The same ladder under scale_scheme = "clustering_mean": the beam @@ -6894,8 +6895,10 @@ def default_setup(self): # of the beam and walked back up), while mu_R, the alpha_s vertex # scales and the jet scales stay clustering_mean's. Without it # clustering_mean takes both densities once at the softest clustering - # scale. Needs pdf_reweighting. - self.add_toml_param('beam', 'clustering_mean_pdf_ladder', False, + # scale, which leaves a 10-15% hole in the 0->1 jet rate just above + # qcut against madevent for W+jets and W Z+jets (and +14% for tt~+jets, + # halved by the ladder). Needs pdf_reweighting. + self.add_toml_param('beam', 'clustering_mean_pdf_ladder', True, comment="also walk the pdf ladder under scale_scheme = clustering_mean") # Which measure scores a candidate final-state clustering. The two only # differ for a non-resonant pair with a massless mother and one massive diff --git a/madspace/include/madspace/phasespace/mlm_clustering.hpp b/madspace/include/madspace/phasespace/mlm_clustering.hpp index f2ac399b72..58fa8c9dc3 100644 --- a/madspace/include/madspace/phasespace/mlm_clustering.hpp +++ b/madspace/include/madspace/phasespace/mlm_clustering.hpp @@ -144,8 +144,11 @@ class MLMClustering : public FunctionGenerator { // ladder under scale_scheme = clustering_mean: the densities (their // lowered leading scale, the ladder ratios and the per-beam scale the // ladder ends at) are then madevent's, while mu_R, the alpha_s vertex - // scales and the jet bookkeeping stay clustering_mean's. - bool clustering_mean_pdf_ladder = false + // scales and the jet bookkeeping stay clustering_mean's. On by default: + // without it clustering_mean takes both densities at the softest + // clustering scale, which is what moves its merged rates near qcut + // away from madevent's. + bool clustering_mean_pdf_ladder = true ); // The same outputs as the function itself, for a history restricted to diff --git a/madspace/src/python/madspace.cpp b/madspace/src/python/madspace.cpp index c5f7f3251d..909c98a243 100644 --- a/madspace/src/python/madspace.cpp +++ b/madspace/src/python/madspace.cpp @@ -1944,7 +1944,7 @@ PYBIND11_MODULE(_madspace_py, m) { py::arg("pdf_reweighting") = true, py::arg("clustering_measure") = ClusteringMeasure::fxfx, py::arg("clustering_history") = ClusteringHistory::all_diagrams, - py::arg("clustering_mean_pdf_ladder") = false + py::arg("clustering_mean_pdf_ladder") = true ) .def_property_readonly( "pdf_absolute_pdgs", &MLMClustering::pdf_absolute_pdgs diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index f135288ce9..1c43cd6ec8 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -299,7 +299,10 @@ def test_factorization_scale_does_not_exceed_renormalization_scale(process): _, diagrams, pdg_ids = process momenta = sample_momenta(diagrams) ren, fac1, fac2, _, _ = run( - make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta + make_clustering( + diagrams, external_pdg_ids=pdg_ids, + clustering_mean_pdf_ladder=False, + ), momenta ) assert np.array_equal(fac1, fac2) assert np.all(fac1 <= ren * (1.0 + 1e-12)) @@ -351,7 +354,10 @@ def test_longitudinal_boosts_barely_change_the_scales(process): So: pin that the effect stays small rather than asserting exact invariance. """ _, diagrams, pdg_ids = process - clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, + clustering_mean_pdf_ladder=False, + ) momenta = sample_momenta(diagrams) rapidity = 0.3 @@ -378,7 +384,10 @@ def test_longitudinal_boosts_barely_change_the_scales(process): def test_scales_are_azimuthally_invariant(process): """Rotating the event around the beam axis must not change any scale.""" _, diagrams, pdg_ids = process - clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, + clustering_mean_pdf_ladder=False, + ) momenta = sample_momenta(diagrams) angle = 0.7 @@ -473,7 +482,10 @@ def test_renormalization_scale_is_a_geometric_mean(process): _, diagrams, pdg_ids = process momenta = sample_momenta(diagrams) ren, fac, _, out, _ = run( - make_clustering(diagrams, external_pdg_ids=pdg_ids), momenta + make_clustering( + diagrams, external_pdg_ids=pdg_ids, + clustering_mean_pdf_ladder=False, + ), momenta ) assert np.all(ren >= fac * (1.0 - 1e-12)) # mu_F is the smallest QCD clustering scale, so every jet scale - each of @@ -555,10 +567,12 @@ def test_resonant_clustering_uses_the_invariant_mass(): resonance branch off entirely.""" momenta = sample_momenta(z_to_bb_diagram(W_Z)) resonant = make_clustering( - z_to_bb_diagram(W_Z), external_pdg_ids=Z_TO_BB_PDGS, bw_cutoff=15.0 + z_to_bb_diagram(W_Z), external_pdg_ids=Z_TO_BB_PDGS, bw_cutoff=15.0, + clustering_mean_pdf_ladder=False, ) non_resonant = make_clustering( - z_to_bb_diagram(0.0), external_pdg_ids=Z_TO_BB_PDGS + z_to_bb_diagram(0.0), external_pdg_ids=Z_TO_BB_PDGS, + clustering_mean_pdf_ladder=False, ) ren_res, fac_res, _, _, _ = run(resonant, momenta) @@ -672,7 +686,10 @@ def test_single_clustering_scale_matches_the_measures(): } ] pdg_ids = [21, 21, 21, 21] - clustering = make_clustering(diagrams, external_pdg_ids=pdg_ids) + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, + clustering_mean_pdf_ladder=False, + ) momenta = sample_momenta(diagrams, batch_size=128, seed=99) ren, fac, _, _, _ = run(clustering, momenta) @@ -729,7 +746,8 @@ def test_kt_measure_matches_the_fortran(): ] pdg_ids = [21, 21, 21, 21, 21] clustering = make_clustering( - diagrams, external_pdg_ids=pdg_ids, jet_radius=jet_radius + diagrams, external_pdg_ids=pdg_ids, jet_radius=jet_radius, + clustering_mean_pdf_ladder=False, ) momenta = sample_momenta(diagrams, batch_size=128, seed=99) _, fac, _, _, _ = run(clustering, momenta) @@ -1499,13 +1517,14 @@ def test_clustering_mean_pdf_ladder_takes_only_the_densities_from_madevent( every ladder slot are madevent's, while mu_R, the jet scales, the alpha_s vertex scales and the xqcut veto stay clustering_mean's.""" scheme = ms.MLMClustering.ScaleScheme - mean = t_channel_clustering(scale_scheme=scheme.clustering_mean, xqcut=20.0) - madevent = t_channel_clustering(scale_scheme=scheme.madevent, xqcut=20.0) - ladder = t_channel_clustering( + mean = t_channel_clustering( scale_scheme=scheme.clustering_mean, xqcut=20.0, - clustering_mean_pdf_ladder=True, + clustering_mean_pdf_ladder=False, ) - # off unless asked for: clustering_mean keeps the density at its scale + madevent = t_channel_clustering(scale_scheme=scheme.madevent, xqcut=20.0) + # on by default + ladder = t_channel_clustering(scale_scheme=scheme.clustering_mean, xqcut=20.0) + # switched off, clustering_mean keeps the density at its scale assert np.all(pdf_outputs(mean, t_channel_momenta)["pdf_rw_active"] == 0.0) ren, _, _, jets, diagram, veto = run_all(ladder, t_channel_momenta) @@ -1532,8 +1551,7 @@ def test_clustering_mean_pdf_ladder_takes_only_the_densities_from_madevent( # and the switch is inert without pdf_reweighting off = t_channel_clustering( - scale_scheme=scheme.clustering_mean, xqcut=20.0, - clustering_mean_pdf_ladder=True, pdf_reweighting=False, + scale_scheme=scheme.clustering_mean, xqcut=20.0, pdf_reweighting=False, ) _, fact1_o, fact2_o, _, _ = run(off, t_channel_momenta) _, fact1_m, fact2_m, _, _ = run(mean, t_channel_momenta) @@ -1626,11 +1644,12 @@ def test_pdf_steps_name_a_real_beam(t_channel_momenta): def test_pdf_reweighting_is_off_under_the_other_scale_scheme(t_channel_momenta): - """There is no beam parton line to walk without madevent's scale scheme, so - asking for the reweighting there must do nothing rather than something - arbitrary.""" + """Under clustering_mean with the ladder switched off there is no beam + parton line to walk, so asking for the reweighting there must do nothing + rather than something arbitrary.""" clustering = t_channel_clustering( - scale_scheme=ms.MLMClustering.ScaleScheme.clustering_mean + scale_scheme=ms.MLMClustering.ScaleScheme.clustering_mean, + clustering_mean_pdf_ladder=False, ) _, fact1, fact2, _, _ = run(clustering, t_channel_momenta) out = pdf_outputs(clustering, t_channel_momenta) From 90d384f92165e2657c4026906470aa273952d6b0 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 28 Oct 2026 12:06:17 +0100 Subject: [PATCH 72/81] MLM: settle equal clustering candidates in cluster.f's loop order With two massless partons left in the final state they balance in pT, so clustering either into the beam it travels along scores the same mT. The walk compared rounded values, so an ulp decided which beam took the parton, and a rotation about the beam axis could change it. Which beam it is decides where that beam's line stops being a QCD line, i.e. jcentral, and so the madevent mu_F: 831.8 vs 1195.4 GeV for one ttgg test event in 200, under scale_scheme=madevent and clustering_mean with the pdf ladder alike. madevent's cluster.f has the same tie and settles it by rounding too; in exact arithmetic it keeps the first candidate of its loop order, which is (particle2, particle1) order on madspace's slots. Treat measures within 1e-12 as equal and take that order. It subsumes the earlier initial-beats-final rule for the DJ massless-massive tie, which was madevent-measure only. Add rotation and longitudinal-boost tests for the two beam-line schemes. --- madspace/src/kernels/mlm.hpp | 88 +++++++++++++++++++++++--------- madspace/tests/test_mlm.py | 98 ++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 23 deletions(-) diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 4e77b9e379..4f2a70d41b 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -208,6 +208,60 @@ KERNELSPEC FVal compute_scale( return sqrt(dj_clus(momentum1, momentum2, mass1, mass2, hadronic, jet_radius)); } +// Two candidate clusterings whose measures agree to this relative precision are +// taken to be equal, and settled by order rather than by rounding. +constexpr double TIE_TOLERANCE = 1e-12; + +// Whether a candidate clustering takes over from the best one found so far. +// +// A resonant candidate beats a non-resonant one, then the smaller measure wins. +// Measures that are equal in exact arithmetic are common: with two massless +// partons left in the final state they balance in transverse momentum, so +// clustering either one into the beam it travels along scores the same mT. +// Comparing the rounded values then hands the step to whichever came out an +// ulp lower, which a rotation about the beam axis can change, and which beam +// takes the parton decides where that beam's line ends and so mu_F. +// +// Settle such ties the way madevent's cluster.f does in exact arithmetic: it +// keeps the first of two equal candidates, looping over the later leg on the +// outside and its partner, beams first, on the inside. cluster.f keeps the +// surviving legs in their original order with the mother in the earlier +// slot, as the slots here are kept, so its loop order is the order of +// (particle2, particle1). That also covers the tie DJ makes between a beam +// clustering and a massless-massive final-state pair on the same parton: +// the beam comes first. +template +KERNELSPEC bool candidate_wins( + FVal scale, + int data, + bool resonant, + int best_next_state, + FVal best_scale, + int best_data, + bool best_resonant +) { + if (best_next_state == -1) { + return true; + } + if (resonant != best_resonant) { + return resonant; + } + if (scale < best_scale * (1.0 - TIE_TOLERANCE)) { + return true; + } + if (scale > best_scale * (1.0 + TIE_TOLERANCE)) { + return false; + } + int particle1 = data & 0xFF, particle2 = (data >> 8) & 0xFF; + int best_particle1 = best_data & 0xFF, best_particle2 = (best_data >> 8) & 0xFF; + if (particle2 != best_particle2) { + return particle2 < best_particle2; + } + // The same pair reached through another graph scores the same; the first + // one listed stands. + return particle1 < best_particle1; +} + // Update momenta and the separately-tracked masses after one clustering step. // Based on update_momenta from Template/NLO/SubProcesses/cluster.f template @@ -372,13 +426,11 @@ KERNELSPEC int mlm_clustering_walk( int win_next_state = -1, win_data = 0, win_trace = TRACE_FIRST; bool win_resonant = false; - bool win_initial = false; FVal win_scale = SCALE_MAX * 10.; // The best of the candidates the flavours of the event forbid, taken only // when a state offers nothing else, so that the walk can always go on. int alt_next_state = -1, alt_data = 0, alt_trace = TRACE_FIRST; bool alt_resonant = false; - bool alt_initial = false; FVal alt_scale = SCALE_MAX * 10.; while (cluster_count < cluster_max) { int data = state_machine[state]; @@ -438,40 +490,33 @@ KERNELSPEC int mlm_clustering_walk( // The MG5 fortran code extracted the resonance structure from the integration // channel. This is not always possible in MG7, so prefer resonant configs - // over non-resonant ones - // madevent's cluster.f keeps the first of two equal candidates, and it - // always tries a leg against the beams before the final-state legs. Under - // the madevent measure an initial-state clustering and a massless-massive - // final-state one can tie exactly, so settle that tie the same way - // whatever order the state machine lists them in. + // over non-resonant ones. Ties go the way cluster.f's loop order takes + // them whatever order the state machine lists the candidates in, see + // candidate_wins. bool forbidden = (trace_data & TRACE_SAME_FLAVOR) && leg_flavor[particle1] != 0 && leg_flavor[particle2] != 0 && leg_flavor[particle1] != leg_flavor[particle2]; if (forbidden) { - bool beats_on_tie = clustering_measure == MEASURE_MADEVENT && - is_initial && !alt_initial && - !(scale > alt_scale * (1.0 + 1e-12)); - if (alt_next_state == -1 || (!alt_resonant && resonant) || - (alt_resonant == resonant && (scale < alt_scale || beats_on_tie))) { + if (candidate_wins( + scale, data, resonant, alt_next_state, alt_scale, alt_data, + alt_resonant + )) { alt_next_state = next_state; alt_scale = scale; alt_data = data; alt_trace = trace_data; alt_resonant = resonant; - alt_initial = is_initial; } } else { - bool beats_on_tie = clustering_measure == MEASURE_MADEVENT && - is_initial && !win_initial && - !(scale > win_scale * (1.0 + 1e-12)); - if (win_next_state == -1 || (!win_resonant && resonant) || - (win_resonant == resonant && (scale < win_scale || beats_on_tie))) { + if (candidate_wins( + scale, data, resonant, win_next_state, win_scale, win_data, + win_resonant + )) { win_next_state = next_state; win_scale = scale; win_data = data; win_trace = trace_data; win_resonant = resonant; - win_initial = is_initial; } } if (is_last && win_next_state == -1) { @@ -480,7 +525,6 @@ KERNELSPEC int mlm_clustering_walk( win_data = alt_data; win_trace = alt_trace; win_resonant = alt_resonant; - win_initial = alt_initial; } if (is_last) { int p1_win = win_data & 0xFF; @@ -561,13 +605,11 @@ KERNELSPEC int mlm_clustering_walk( win_data = 0; win_trace = TRACE_FIRST; win_resonant = false; - win_initial = false; win_scale = SCALE_MAX * 10.; alt_next_state = -1; alt_data = 0; alt_trace = TRACE_FIRST; alt_resonant = false; - alt_initial = false; alt_scale = SCALE_MAX * 10.; } else { state += STATE_ITEM_SIZE; diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 1c43cd6ec8..209054c364 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -404,6 +404,104 @@ def test_scales_are_azimuthally_invariant(process): assert_jet_scales_agree(out, out_r) +# The schemes whose factorisation scale comes out of madevent's beam-line walk +# (setclscales in Template/LO/SubProcesses/reweight.f) rather than from the +# softest clustering. +BEAM_LINE_SCHEMES = { + "madevent": {"scale_scheme": "madevent"}, + "clustering_mean_pdf_ladder": {"clustering_mean_pdf_ladder": True}, +} + +# What the beam-line walk hands the pdf reweighting, all of it built from +# transverse masses and invariants. +LADDER_OUTPUTS = ["pdf_scale1", "pdf_scale2", "pdf_rw_x", "pdf_rw_q_num", "pdf_rw_q_den"] + + +def rotate_about_beam(momenta, angle): + cos_a, sin_a = np.cos(angle), np.sin(angle) + rotated = momenta.copy() + px, py = momenta[..., 1], momenta[..., 2] + rotated[..., 1] = cos_a * px - sin_a * py + rotated[..., 2] = sin_a * px + cos_a * py + return rotated + + +@pytest.mark.parametrize("scheme", sorted(BEAM_LINE_SCHEMES)) +def test_beam_line_scales_are_azimuthally_invariant(process, scheme): + """The same for the schemes that walk the beam lines, which are more + sensitive to the choice of history: which beam takes a parton decides where + that beam's line stops being a QCD line, and with it mu_F. + + Equal measures are common there. With two massless partons left in the + final state they balance in transverse momentum, so clustering either one + into the beam it travels along scores the same. Settled by rounding, a + rotation moved mu_F from 832 to 1195 GeV for one ttgg event in 200; the + tie now goes the way cluster.f's loop order takes it, so nothing may + change, not even which leg a jet scale is booked against. + """ + _, diagrams, pdg_ids = process + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, **BEAM_LINE_SCHEMES[scheme] + ) + momenta = sample_momenta(diagrams) + for angle in (0.7, -2.5): + rotated = rotate_about_beam(momenta, angle) + ren, fac1, fac2, out, diagram = run(clustering, momenta) + ren_r, fac1_r, fac2_r, out_r, diagram_r = run(clustering, rotated) + assert ren_r == pytest.approx(ren, rel=1e-9) + assert fac1_r == pytest.approx(fac1, rel=1e-9) + assert fac2_r == pytest.approx(fac2, rel=1e-9) + assert np.array_equal(diagram_r, diagram) + assert_jet_scales_agree(out, out_r, max_flip_fraction=0.0) + ladder = pdf_outputs(clustering, momenta) + ladder_r = pdf_outputs(clustering, rotated) + for name in LADDER_OUTPUTS: + assert ladder_r[name] == pytest.approx(ladder[name], rel=1e-9), name + + +@pytest.mark.parametrize("scheme", sorted(BEAM_LINE_SCHEMES)) +def test_beam_line_scales_barely_change_under_longitudinal_boosts(process, scheme): + """As test_longitudinal_boosts_barely_change_the_scales, for the schemes + that walk the beam lines: the measures and the transverse masses the walk + reads are boost invariant, but the 1 + 1e-6 against-the-beam penalty is + not, so a boost may change the history of an event with two close + candidates. Where it leaves the history alone, everything has to agree. + """ + _, diagrams, pdg_ids = process + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, **BEAM_LINE_SCHEMES[scheme] + ) + momenta = sample_momenta(diagrams) + + rapidity = 0.3 + cosh_y, sinh_y = np.cosh(rapidity), np.sinh(rapidity) + boosted = momenta.copy() + energy, pz = momenta[..., 0], momenta[..., 3] + boosted[..., 0] = cosh_y * energy + sinh_y * pz + boosted[..., 3] = sinh_y * energy + cosh_y * pz + + ren, fac1, fac2, out, diagram = run(clustering, momenta) + ren_b, fac1_b, fac2_b, out_b, diagram_b = run(clustering, boosted) + alphas, _ = alphas_outputs(clustering, momenta) + alphas_b, _ = alphas_outputs(clustering, boosted) + + # The history, step by step. The vertex scales alone do not tell: when the + # penalty moves from one beam to the other, the parton goes to the other + # beam at the same unpenalised scale, and the later steps that move are + # often non-QCD ones, which carry no alpha_s scale. + unchanged = (diagram_b == diagram) & np.isclose(alphas_b, alphas, rtol=1e-9).all( + axis=1 + ) + assert unchanged.mean() > 0.8, ( + f"only {unchanged.mean():.0%} of events keep their clustering history " + "under a longitudinal boost" + ) + assert ren_b[unchanged] == pytest.approx(ren[unchanged], rel=1e-9) + assert fac1_b[unchanged] == pytest.approx(fac1[unchanged], rel=1e-9) + assert fac2_b[unchanged] == pytest.approx(fac2[unchanged], rel=1e-9) + assert_jet_scales_agree(out[unchanged], out_b[unchanged]) + + def test_scales_are_homogeneous_for_a_single_clustering(): """Every clustering measure has mass dimension one, so scaling all momenta by a factor scales every returned scale by the same factor. From cbb229bce1edc492a6df34141fecb0435cddfde6 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 28 Oct 2026 21:05:37 +0100 Subject: [PATCH 73/81] Integrand: keep weight_after_cuts when it carries the MLM weights #222 leaves weight_after_cuts out of the common part's weight product unless flavor or mirror sampling contribute to it, as it is one by construction otherwise. On feat-mlm build_channel_part also multiplies in the merging weights (alpha_s vertices, pdf ladder and its vetoes) and the scale floor veto when the history is over every diagram, so a single-flavor, unmirrored MLM sample silently lost all of them. test_a_density_too_small_to_divide_by_drops_the_event caught it: the events the pdf ladder vetoes came back with a non-zero weight. Co-Authored-By: Claude --- madspace/src/phasespace/integrand.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index 253efabe46..d83be1414f 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -1418,10 +1418,14 @@ NamedVector Integrand::build_common_part( ? fb.mul(diff_xs_acc, fb.gather(flavor_id, _flavor_factors)) : fb.mul(diff_xs_acc, _flavor_factors.at(0)); } - // weight_after_cuts is one by construction unless flavor or mirror sampling - // contribute to it (see build_channel_part) + // weight_after_cuts is one by construction unless flavor or mirror sampling, + // or the merging weights and the scale veto of the history over every + // diagram, contribute to it (see build_channel_part) + bool channel_mlm_weights = _energy_scale && + !_energy_scale->mlm_history_per_diagram() && + (_energy_scale->is_mlm() || _energy_scale->has_scale_veto()); ValueVec weights_after_cuts; - if (has_multi_flavor || _has_mirror) { + if (has_multi_flavor || _has_mirror || channel_mlm_weights) { weights_after_cuts.push_back(args.at("weight_after_cuts")); } weights_after_cuts.push_back(diff_xs_acc); From b839980723170a53e5575fc0132709eb19bb71ef Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 28 Oct 2026 23:00:50 +0100 Subject: [PATCH 74/81] MLM review follow-ups: hidden run-card options, one weight_after_cuts rule, tests - run card: the MLM clustering options other than max_jet_flavor (jet_radius, jet_scale_scheme, scale_scheme, parton_line_scheme, alphas_reweighting, pdf_reweighting, clustering_mean_pdf_ladder, clustering_measure, clustering_history) are hidden. RunCardMG7.write adds a hidden parameter at the end of its section once it is user set or differs from its declared default, so a set value (also from from_LO, which sets scale_scheme without user=True) survives a rewrite. The pdf ladder comment no longer quotes sample-specific numbers. - Integrand: has_channel_weight_after_cuts() is the one statement of when build_channel_part multiplies a weight into weight_after_cuts. The common part uses it, and build_channel_part throws at graph build if it pushes a weight the helper does not announce, instead of the common part dropping it silently (what the #222 merge did to the MLM weights). - launch.py: the systematics' ladder decision is mlm_pdf_ladder(), with a unit test of its cases. - scale.hpp: the mlm_pdf_reweighting() comment no longer says only scale_scheme madevent walks the ladder. - test_mlm: the ladder veto through the whole Integrand also under clustering_mean + ladder (single flavour, no mirror: the case the weight_after_cuts merge bug hit), and the systematics' pdf product under clustering_mean with and without the ladder. Co-Authored-By: Claude --- docs/mlm-merging-status.md | 6 +- madgraph/iolibs/template_files/mg7/launch.py | 29 +++++--- .../iolibs/template_files/mg7/run_card.toml | 47 +----------- madgraph/various/banner.py | 72 +++++++++++++++---- .../include/madspace/phasespace/integrand.hpp | 5 ++ .../include/madspace/phasespace/scale.hpp | 9 +-- madspace/src/phasespace/integrand.cpp | 24 +++++-- madspace/tests/test_mlm.py | 47 +++++++++--- .../interface/test_mg7_mlm_pdf_ladder.py | 67 +++++++++++++++++ tests/unit_tests/various/test_banner.py | 38 +++++++++- 10 files changed, 255 insertions(+), 89 deletions(-) create mode 100644 tests/unit_tests/interface/test_mg7_mlm_pdf_ladder.py diff --git a/docs/mlm-merging-status.md b/docs/mlm-merging-status.md index 949ccc1a9f..594562ce6e 100644 --- a/docs/mlm-merging-status.md +++ b/docs/mlm-merging-status.md @@ -31,7 +31,11 @@ The branch was 699 commits behind. Most of the conflict surface was not real: were re-applied to `launch.py`. The matrix element is now built in a loop over backends, so `me_inputs` is computed once before that loop. * `run_card.toml` is now a template filled from `banner.py`, so `"mlm"`, - `beam.jet_radius` and `beam.max_jet_flavor` are declared there. + `beam.jet_radius` and `beam.max_jet_flavor` are declared there. The MLM + clustering options other than `max_jet_flavor` (`jet_radius`, `scale_scheme`, + `alphas_reweighting`, `pdf_reweighting`, `clustering_history`, ...) are + hidden: the template has no line for them, and `RunCardMG7.write` adds one at + the end of `[beam]` once they are set or differ from their default. * `LHECompleter` was refactored on main; main's version was taken. The branch's `TODO` about the permutation direction is gone because main fixed exactly that, by inverting the permutation before using it. diff --git a/madgraph/iolibs/template_files/mg7/launch.py b/madgraph/iolibs/template_files/mg7/launch.py index cac25f9c41..6c70fbdbeb 100644 --- a/madgraph/iolibs/template_files/mg7/launch.py +++ b/madgraph/iolibs/template_files/mg7/launch.py @@ -192,6 +192,20 @@ def me_parameters(run_card): return parameters +def mlm_pdf_ladder(run_card, mlm_clustering, is_decay=False, leptonic=False): + """Whether an MLM sample's beam densities walk the clustering ladder, the + pdf half of madevent's rewgt: under scale_scheme "madevent", and under + "clustering_mean" with clustering_mean_pdf_ladder. The Python side of + EnergyScale::mlm_pdf_reweighting, which decides the same for the kernel; + the systematics then vary the ladder as madevent's do.""" + beam = run_card["beam"] + return bool( + mlm_clustering and not is_decay and not leptonic + and beam["pdf_reweighting"] + and (beam["scale_scheme"] == "madevent" + or beam["clustering_mean_pdf_ladder"])) + + def interference_helicities_summed(run_card): """Whether [generation] interference_helicity is "summed". @@ -1164,16 +1178,11 @@ def build_systematics(self): config.alps = [float(v) for v in syst["alps"]] else: config.alps = [1.0] - # the pdf reweighting ladder of an MLM sample under madevent's scale - # scheme (or clustering_mean's with clustering_mean_pdf_ladder), varied - # as madevent's systematics vary it; a varied scale is kept inside the - # PDF grid, where LHAPDF freezes it for madevent - beam = self.run_card["beam"] - config.pdf_ladder = bool( - self.mlm_clustering and not self.is_decay and not self.leptonic - and beam["pdf_reweighting"] - and (beam["scale_scheme"] == "madevent" - or beam["clustering_mean_pdf_ladder"])) + # the pdf reweighting ladder of an MLM sample, varied as madevent's + # systematics vary it; a varied scale is kept inside the PDF grid, + # where LHAPDF freezes it for madevent + config.pdf_ladder = mlm_pdf_ladder( + self.run_card, self.mlm_clustering, self.is_decay, self.leptonic) if config.pdf_ladder: config.ladder_min_scale = float(self.min_pdf_scale) config.ladder_max_scale = float(self.max_scale) diff --git a/madgraph/iolibs/template_files/mg7/run_card.toml b/madgraph/iolibs/template_files/mg7/run_card.toml index f449727937..42bcc709af 100644 --- a/madgraph/iolibs/template_files/mg7/run_card.toml +++ b/madgraph/iolibs/template_files/mg7/run_card.toml @@ -70,52 +70,11 @@ fact_scale2 = %(beam.fact_scale2)s dynamical_scale_choice = %(beam.dynamical_scale_choice)s # multiplies the dynamical scale; fixed scales are unaffected scale_factor = %(beam.scale_factor)s -# jet radius used by the MLM clustering measure (only for dynamical_scale_choice = "mlm") -jet_radius = %(beam.jet_radius)s # heaviest quark flavour treated as a jet by the MLM back-clustering max_jet_flavor = %(beam.max_jet_flavor)s -# how the MLM back-clustering assigns a clustering scale to each outgoing jet -# options: production (the scale at which the parton's line was produced, as in -# madevent), emission (the scale at which the leg itself was emitted) -jet_scale_scheme = %(beam.jet_scale_scheme)s -# how mu_R and mu_F are read off the clustering history -# options: clustering_mean (geometric mean of every clustering scale), -# madevent (follow each beam's parton line, as madevent does) -scale_scheme = %(beam.scale_scheme)s -# how a beam's parton line is decided to carry on past a clustering, under -# scale_scheme = "madevent" -# options: goodjet (a line that has been through a non-jet vertex is not a -# parton line any more, as madevent does), flavor (read each -# daughter's own flavour) -parton_line_scheme = %(beam.parton_line_scheme)s -# where alpha_s is evaluated for a merged event, under -# dynamical_scale_choice = "mlm" -# options: per_vertex (at the scale of each clustering that produced a parton, -# as madevent does), geometric_mean (one coupling at the geometric -# mean of those scales), none (a single coupling at mu_R) -alphas_reweighting = %(beam.alphas_reweighting)s -# re-evaluate the beam densities along the clustering ladder rather than once -# at the factorisation scale, as madevent's pdfwgt does. Only used under -# dynamical_scale_choice = "mlm" (with scale_scheme = "clustering_mean" only -# together with clustering_mean_pdf_ladder) -pdf_reweighting = %(beam.pdf_reweighting)s -# with pdf_reweighting, walk the same pdf ladder under scale_scheme = -# "clustering_mean" (densities as madevent's, mu_R and alpha_s as -# clustering_mean's) instead of evaluating both densities at the softest -# clustering scale -clustering_mean_pdf_ladder = %(beam.clustering_mean_pdf_ladder)s -# measure scoring a candidate final-state clustering (dynamical_scale_choice = -# "mlm"). options: fxfx (NLO cluster_scale), madevent (madevent's LO DJ: a -# massless-massive pair scores the massless one's transverse mass). They only -# differ for e.g. q* > q W and g* > g h; madevent matches madevent's MLM -clustering_measure = %(beam.clustering_measure)s -# which diagrams the MLM clustering history may follow (dynamical_scale_choice = -# "mlm"). options: all_diagrams (smallest measure over every diagram at once), -# diagram (one diagram per event, picked by |A_i|^2, clustered with its own -# line flavours), madevent (all_diagrams unless its merging-jet count differs -# from the picked diagram's, as madevent reclusters along the channel). The -# last two evaluate the matrix element twice per event -clustering_history = %(beam.clustering_history)s +# the other MLM clustering options (scale_scheme, alphas_reweighting, +# pdf_reweighting, clustering_history, ...) are hidden; one that is set (at the +# launch prompt, or by adding its line to this section) is kept in this card # floor on mu_R and mu_F, since a PDF grid has no densities below it: the # scales are raised to it, and under dynamical_scale_choice = "mlm" the event # is dropped instead, as madevent does. Ignored without beam PDFs (0 disables it) diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index 519332fc88..d8eef678bc 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -4582,7 +4582,8 @@ def default_setup(self): #matching self.add_param("scalefact", 1.0) self.add_param("ickkw", 0, allowed=[0,1], hidden=True, comment="\'0\' for standard fixed order computation.\n\'1\' for MLM merging activates alphas and pdf re-weighting according to a kt clustering of the QCD radiation.") - self.add_param("highestmult", 1, fortran_name="nhmult", hidden=True) + self.add_param("highestmult", 1, fortran_name="nhmult", + hidden=True) self.add_param("ktscheme", 1, hidden=True) self.add_param("alpsfact", 1.0, hidden=True) self.add_param("chcluster", False, hidden=True) @@ -6714,6 +6715,9 @@ def __init__(self, *args, **opts): self.section_of = {} # set of internal keys (section.key) relevant during gridpack execution self.gridpack_params = set() + # declared default of each fixed parameter: a hidden one (no line in + # the template) is only written out once it differs from it + self.toml_defaults = {} # free-form sections kept as nested dicts self.dynamic_sections = collections.OrderedDict() # unknown sections preserved for round-trip @@ -6746,6 +6750,7 @@ def add_toml_param(self, section, key, value, gridpack=False, auto=False, **opts # not a fortran parameter: never goes to an include file opts.setdefault('include', False) self.add_param(internal, value, **opts) + self.toml_defaults[internal] = copy.deepcopy(value) self.section_of[internal] = section self.toml_sections.setdefault(section, []) if key not in self.toml_sections[section]: @@ -6889,7 +6894,8 @@ def default_setup(self): # hidden run_card d (common/to_dj/D) and FxFx's value, and Pythia's # JetMatching:coneRadius default. self.add_toml_param('beam', 'jet_radius', 1.0, - comment="jet radius of the MLM clustering measure (dynamical_scale_choice = mlm)") + comment="jet radius of the MLM clustering measure (dynamical_scale_choice = mlm)", + hidden=True) # Heaviest quark flavour treated as a jet by the MLM back-clustering # (the counterpart of maxjetflavor in the legacy run_card). self.add_toml_param('beam', 'max_jet_flavor', 4, @@ -6904,7 +6910,8 @@ def default_setup(self): # the softest clustering it takes part in. self.add_toml_param('beam', 'jet_scale_scheme', "production", allowed=['production', 'emission'], - comment="MLM clustering scale of each outgoing jet: production or emission") + comment="MLM clustering scale of each outgoing jet: production or emission", + hidden=True) # How mu_R and mu_F are read off the clustering history: # "clustering_mean": geometric mean of every clustering scale, with a # single factorisation scale. @@ -6915,7 +6922,8 @@ def default_setup(self): # section, unlike jet_scale_scheme. self.add_toml_param('beam', 'scale_scheme', "clustering_mean", allowed=['clustering_mean', 'madevent'], - comment="how mu_R and mu_F are read off the MLM clustering history") + comment="how mu_R and mu_F are read off the MLM clustering history", + hidden=True) # How a beam's parton line is decided to carry on past a vertex (only # read when scale_scheme = "madevent"): # "goodjet": a line stays a parton line only while every clustering @@ -6927,7 +6935,8 @@ def default_setup(self): # would have stopped them. self.add_toml_param('beam', 'parton_line_scheme', "goodjet", allowed=['goodjet', 'flavor'], - comment="when a beam's parton line carries on past an MLM clustering") + comment="when a beam's parton line carries on past an MLM clustering", + hidden=True) # Evaluate alpha_s at the scale of each clustering vertex instead of # once at the event scale, which is what madevent does for a merged # sample (the rewgt loop in Template/LO/SubProcesses/reweight.f). A @@ -6941,7 +6950,8 @@ def default_setup(self): # Only read when dynamical_scale_choice = "mlm". self.add_toml_param('beam', 'alphas_reweighting', "per_vertex", allowed=['per_vertex', 'geometric_mean', 'none'], - comment="where alpha_s is evaluated for an MLM merged event") + comment="where alpha_s is evaluated for an MLM merged event", + hidden=True) # Re-evaluate the beam densities along the clustering ladder instead of # once at the factorisation scale: the density is taken at the scale of # the emission that pulled the parton out of the beam and walked back @@ -6951,18 +6961,19 @@ def default_setup(self): # and under scale_scheme = "clustering_mean" only together with # clustering_mean_pdf_ladder. self.add_toml_param('beam', 'pdf_reweighting', True, - comment="re-evaluate the beam densities along the MLM clustering ladder") + comment="re-evaluate the beam densities along the MLM clustering ladder", + hidden=True) # The same ladder under scale_scheme = "clustering_mean": the beam # densities are then evaluated as madevent evaluates them (per-beam # factorisation scale, lowered to the scale that pulled the parton out # of the beam and walked back up), while mu_R, the alpha_s vertex # scales and the jet scales stay clustering_mean's. Without it # clustering_mean takes both densities once at the softest clustering - # scale, which leaves a 10-15% hole in the 0->1 jet rate just above - # qcut against madevent for W+jets and W Z+jets (and +14% for tt~+jets, - # halved by the ladder). Needs pdf_reweighting. + # scale, which moves the merged rates just above qcut away from + # madevent's. Needs pdf_reweighting. self.add_toml_param('beam', 'clustering_mean_pdf_ladder', True, - comment="also walk the pdf ladder under scale_scheme = clustering_mean") + comment="also walk the pdf ladder under scale_scheme = clustering_mean", + hidden=True) # Which measure scores a candidate final-state clustering. The two only # differ for a non-resonant pair with a massless mother and one massive # and one massless daughter (q* > q W, g* > g h), or a massive mother @@ -6975,7 +6986,8 @@ def default_setup(self): # Only read when dynamical_scale_choice = "mlm". self.add_toml_param('beam', 'clustering_measure', "fxfx", allowed=['fxfx', 'madevent'], - comment="measure scoring a candidate MLM final-state clustering") + comment="measure scoring a candidate MLM final-state clustering", + hidden=True) # Which diagrams the clustering history may follow: # "all_diagrams": the smallest-measure history over every diagram at # once, each line's flavour taken from the first @@ -6995,7 +7007,8 @@ def default_setup(self): # from one flavour assignment per subprocess. self.add_toml_param('beam', 'clustering_history', "all_diagrams", allowed=['all_diagrams', 'diagram', 'madevent'], - comment="which diagrams the MLM clustering history may follow") + comment="which diagrams the MLM clustering history may follow", + hidden=True) # Floor on mu_R and mu_F. Below the lowest Q of a PDF grid the # densities are undefined, so the scales are raised to it, which is # what LHAPDF's freezing does for madevent. Under @@ -8195,7 +8208,9 @@ def write(self, output_file, template=None, python_template=True, **opt): for key in keys: internal = '%s.%s' % (section, key) data[internal] = self.format_toml_value(self[internal]) + in_template = set(re.findall(r'%\(([\w.]+)\)s', text)) text = text % data + text = self.add_hidden_parameters(text, in_template) # free-form sections inserted at their markers for section in self.dynamic_section_names: @@ -8219,6 +8234,37 @@ def write(self, output_file, template=None, python_template=True, **opt): else: output_file.write(text) + def add_hidden_parameters(self, text, in_template): + """Add to the rendered card the hidden parameters (those with no line + in the template) that the user set or that no longer hold their + default, at the end of their section, so that they survive a rewrite + of the card.""" + missing = collections.OrderedDict() + for section, keys in self.toml_sections.items(): + for key in keys: + internal = '%s.%s' % (section, key) + if internal in in_template or internal not in self.hidden_param: + continue + if internal not in self.user_set and \ + self[internal] == self.toml_defaults.get(internal): + continue + line = '%s = %s' % (key, self.format_toml_value(self[internal])) + if self.comments.get(internal): + line += ' # %s' % self.comments[internal] + missing.setdefault(section, []).append(line) + for section, lines in missing.items(): + block = '\n'.join(lines) + '\n' + header = re.search(r'^\[%s\][ \t]*$' % re.escape(section), text, re.M) + if not header: + text = text.rstrip('\n') + '\n\n[%s]\n%s' % (section, block) + continue + # the section ends at the next table header, or at the end + following = re.search(r'^\[', text[header.end():], re.M) + end = header.end() + following.start() if following else len(text) + body = text[:end].rstrip('\n') + '\n' + text = body + block + ('\n' + text[end:] if following else '') + return text + def write_gridpack_card(self, output_file): """Write a minimal ``grid_run_card.toml`` containing only the parameters marked ``gridpack=True`` in :meth:`default_setup`, i.e. those actually diff --git a/madspace/include/madspace/phasespace/integrand.hpp b/madspace/include/madspace/phasespace/integrand.hpp index 30611319fe..0bab9ef189 100644 --- a/madspace/include/madspace/phasespace/integrand.hpp +++ b/madspace/include/madspace/phasespace/integrand.hpp @@ -255,6 +255,11 @@ class Integrand : public FunctionGenerator { FunctionBuilder& fb, const NamedVector& args ) const override; NamedVector compute_channel_part_ret_types() const; + // Whether build_channel_part multiplies anything into weight_after_cuts: + // flavor or mirror sampling, or the merging weights and the scale veto of + // the history over every diagram. The common part leaves weight_after_cuts + // out of its product otherwise, as it is then one by construction. + bool has_channel_weight_after_cuts() const; NamedVector build_channel_part(FunctionBuilder& fb, const NamedVector& args) const; NamedVector diff --git a/madspace/include/madspace/phasespace/scale.hpp b/madspace/include/madspace/phasespace/scale.hpp index df73a98238..c615c8b4bd 100644 --- a/madspace/include/madspace/phasespace/scale.hpp +++ b/madspace/include/madspace/phasespace/scale.hpp @@ -162,10 +162,11 @@ class EnergyScale : public FunctionGenerator { return _clustering && _clustering->alphas_scheme() != AlphasScheme::none; } - // Whether the consumer has pdf ratios to apply. Only ScaleScheme::madevent - // has a clustering ladder to walk: under any other scheme the kernel leaves - // every reweighting slot inert and pdf_scale equal to fact_scale, so the - // ratios would all come out as exactly one. + // Whether the consumer has pdf ratios to apply. The kernel walks the + // clustering ladder under ScaleScheme::madevent, and under clustering_mean + // with clustering_mean_pdf_ladder; otherwise it leaves every reweighting + // slot inert and pdf_scale equal to fact_scale, so the ratios would all + // come out as exactly one. bool mlm_pdf_reweighting() const { return _clustering && _clustering->pdf_reweighting() && (_clustering->scale_scheme() == ScaleScheme::madevent || diff --git a/madspace/src/phasespace/integrand.cpp b/madspace/src/phasespace/integrand.cpp index d83be1414f..5533fc48f1 100644 --- a/madspace/src/phasespace/integrand.cpp +++ b/madspace/src/phasespace/integrand.cpp @@ -563,6 +563,13 @@ NamedVector Integrand::compute_channel_part_ret_types() const { return ret; } +bool Integrand::has_channel_weight_after_cuts() const { + // the three groups of pushes into weights_after_cuts in build_channel_part + bool merging = _energy_scale && !_energy_scale->mlm_history_per_diagram() && + (_energy_scale->is_mlm() || _energy_scale->has_scale_veto()); + return _pid_options.size() > 1 || _has_mirror || merging; +} + NamedVector Integrand::build_channel_part( FunctionBuilder& fb, const NamedVector& args ) const { @@ -876,6 +883,13 @@ NamedVector Integrand::build_channel_part( weights_after_cuts.push_back(scales.at("scale_weight")); } + if (!weights_after_cuts.empty() && !has_channel_weight_after_cuts()) { + // the common part would drop these weights without a word + throw std::logic_error( + "Integrand: weight_after_cuts carries a weight that " + "has_channel_weight_after_cuts() does not announce" + ); + } Value weight_after_cuts = weights_after_cuts.empty() ? fb.full({1., batch_size_acc}) : fb.product(weights_after_cuts); @@ -1418,14 +1432,10 @@ NamedVector Integrand::build_common_part( ? fb.mul(diff_xs_acc, fb.gather(flavor_id, _flavor_factors)) : fb.mul(diff_xs_acc, _flavor_factors.at(0)); } - // weight_after_cuts is one by construction unless flavor or mirror sampling, - // or the merging weights and the scale veto of the history over every - // diagram, contribute to it (see build_channel_part) - bool channel_mlm_weights = _energy_scale && - !_energy_scale->mlm_history_per_diagram() && - (_energy_scale->is_mlm() || _energy_scale->has_scale_veto()); + // weight_after_cuts is one by construction unless the channel part put a + // weight into it ValueVec weights_after_cuts; - if (has_multi_flavor || _has_mirror || channel_mlm_weights) { + if (has_channel_weight_after_cuts()) { weights_after_cuts.push_back(args.at("weight_after_cuts")); } weights_after_cuts.push_back(diff_xs_acc); diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 209054c364..055a2ff211 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -1772,11 +1772,14 @@ def write_flat_pdf_grid(path, value): def t_channel_integrand_outputs( - tmp_path, pdf_value, batch_size=4000, pdf_reweighting=True, partial_weights=False + tmp_path, pdf_value, batch_size=4000, pdf_reweighting=True, partial_weights=False, + min_scale=0.0, **clustering_kwargs, ): """Every output of the t-channel process run through a whole Integrand under MLM (pdf reweighting by default), with the built-in flat test matrix - element and a flat PDF of the given value.""" + element and a flat PDF of the given value. clustering_kwargs go to + t_channel_clustering (scale_scheme madevent unless given); min_scale is the + scale floor of the EnergyScale, beam.min_scale in a run.""" context = ms.Context(device=ms.cpu_device(), thread_count=1) alphas_file = tmp_path / "alphas.info" alphas_file.write_text( @@ -1820,7 +1823,8 @@ def t_channel_integrand_outputs( pdf_grid=pdf_grid, running_coupling=ms.RunningCoupling(alphas_grid), energy_scale=ms.EnergyScale( - t_channel_clustering(pdf_reweighting=pdf_reweighting) + t_channel_clustering(pdf_reweighting=pdf_reweighting, **clustering_kwargs), + min_scale=min_scale, ), channel_indices=[0], partial_weights=partial_weights, @@ -1830,21 +1834,40 @@ def t_channel_integrand_outputs( ) -def t_channel_integrand_weights(tmp_path, pdf_value, batch_size=4000): +def t_channel_integrand_weights(tmp_path, pdf_value, batch_size=4000, **kwargs): """Weights and momenta of t_channel_integrand_outputs.""" - out = t_channel_integrand_outputs(tmp_path, pdf_value, batch_size) + out = t_channel_integrand_outputs(tmp_path, pdf_value, batch_size, **kwargs) return np.asarray(out[0]), np.asarray(out[1]) -def test_a_density_too_small_to_divide_by_drops_the_event(tmp_path): +# The ladder under clustering_mean (clustering_mean_pdf_ladder, on by default) +# goes through the same integrand path. This process has a single flavour and no +# mirror, which is where a weight_after_cuts left out of the weight product +# would drop the ladder's veto without a trace. With no jet cut, the geometric +# mean mu_R of clustering_mean falls below the alpha_s grid for a few percent of +# the events, so that case runs with the 2 GeV floor of beam.min_scale, which +# drops them as a run does (without it their weight is 0 * NaN). +LADDER_SCHEMES = [ + pytest.param({"scale_scheme": ms.MLMClustering.ScaleScheme.madevent}, + id="madevent"), + pytest.param({"scale_scheme": ms.MLMClustering.ScaleScheme.clustering_mean, + "min_scale": 2.0}, + id="clustering_mean_pdf_ladder"), +] + + +@pytest.mark.parametrize("scheme", LADDER_SCHEMES) +def test_a_density_too_small_to_divide_by_drops_the_event(tmp_path, scheme): """reweight.f sets rewgt = 0 when the density it would divide by is under 1e-10. With x f = 1e-12 everywhere every step of the ladder is such a division, so every event with a step on it must come out at zero. A ramp in the denominator used to let these through at num den 1e20 - a hundredth of the weight per step here, not zero.""" - weights, momenta = t_channel_integrand_weights(tmp_path, 1e-12) - out = pdf_outputs(t_channel_clustering(pdf_reweighting=True), momenta) + weights, momenta = t_channel_integrand_weights(tmp_path, 1e-12, **scheme) + clustering = {k: v for k, v in scheme.items() if k != "min_scale"} + out = pdf_outputs(t_channel_clustering(pdf_reweighting=True, **clustering), momenta) + assert not np.isnan(weights).any() stepped = (out["pdf_rw_active"] == 1.0).any(axis=1) assert stepped.any() and not stepped.all(), "both kinds needed" assert np.all(weights[stepped] == 0.0) @@ -1869,6 +1892,14 @@ def output_count(**kwargs): # ... and left out with it assert (output_count(pdf_reweighting=True, partial_weights=True) == output_count(pdf_reweighting=True)) + # the same under clustering_mean: left out with the ladder, which is on by + # default, and there without it + mean = {"scale_scheme": ms.MLMClustering.ScaleScheme.clustering_mean} + assert (output_count(partial_weights=True, **mean) + == output_count(**mean)) + no_ladder = dict(mean, clustering_mean_pdf_ladder=False) + assert (output_count(partial_weights=True, **no_ladder) + == output_count(**no_ladder) + 1) # -------------------------------------------------------------------------- diff --git a/tests/unit_tests/interface/test_mg7_mlm_pdf_ladder.py b/tests/unit_tests/interface/test_mg7_mlm_pdf_ladder.py new file mode 100644 index 0000000000..414acbe915 --- /dev/null +++ b/tests/unit_tests/interface/test_mg7_mlm_pdf_ladder.py @@ -0,0 +1,67 @@ +################################################################################ +# +# Copyright (c) 2026 The MadGraph5_aMC@NLO Development team and Contributors +# +# This file is a part of the MadGraph5_aMC@NLO project, an application which +# automatically generates Feynman diagrams and matrix elements for arbitrary +# high-energy processes in the Standard Model and beyond. +# +# It is subject to the MadGraph5_aMC@NLO license which should accompany this +# distribution. +# +# For more information, visit madgraph.phys.ucl.ac.be and amcatnlo.web.cern.ch +# +################################################################################ +"""When the native systematics vary the MLM pdf ladder. + +The kernel walks the ladder under scale_scheme "madevent", and under +"clustering_mean" with clustering_mean_pdf_ladder (the default), both only with +pdf_reweighting. The systematics have to vary the ladder in exactly those +cases: without it, a ladder sample's mu_F variations would leave the ladder's +lowered leading density and its ratios untouched. +""" +from __future__ import absolute_import + +import unittest + +from madgraph.various.banner import RunCardMG7 + + +class TestMLMPdfLadder(unittest.TestCase): + + def setUp(self): + try: + import madgraph.iolibs.template_files.mg7.launch as launch + except Exception as error: # madspace not built or importable here + self.skipTest('mg7 launch not importable: %s' % error) + self.launch = launch + + def ladder(self, mlm=True, is_decay=False, leptonic=False, **beam): + rc = RunCardMG7() + for key, value in beam.items(): + rc.set('beam.%s' % key, value, user=True) + return self.launch.mlm_pdf_ladder(rc, mlm, is_decay, leptonic) + + def test_both_scale_schemes_walk_the_ladder_by_default(self): + self.assertTrue(self.ladder(scale_scheme='madevent')) + self.assertTrue(self.ladder(scale_scheme='clustering_mean')) + + def test_clustering_mean_without_the_ladder(self): + self.assertFalse(self.ladder(scale_scheme='clustering_mean', + clustering_mean_pdf_ladder=False)) + # the switch is only read under clustering_mean + self.assertTrue(self.ladder(scale_scheme='madevent', + clustering_mean_pdf_ladder=False)) + + def test_pdf_reweighting_off_turns_it_off_everywhere(self): + for scheme in ('madevent', 'clustering_mean'): + self.assertFalse(self.ladder(scale_scheme=scheme, pdf_reweighting=False)) + + def test_only_merged_hadronic_production(self): + self.assertFalse(self.ladder(mlm=False)) + self.assertFalse(self.ladder(is_decay=True)) + self.assertFalse(self.ladder(leptonic=True)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit_tests/various/test_banner.py b/tests/unit_tests/various/test_banner.py index 1ff97c2088..10e55387b3 100755 --- a/tests/unit_tests/various/test_banner.py +++ b/tests/unit_tests/various/test_banner.py @@ -1651,7 +1651,8 @@ def test_runcard_dispatches_to_mg7_for_toml(self): def test_mlm_jet_radius_defaults_to_madevents_d(self): """beam.jet_radius is madevent's hidden run_card d (common/to_dj/D), - which is 1 there and in FxFx, also as written to the TOML card""" + which is 1 there and in FxFx; hidden in the TOML card too, so it is only + written once it differs from that, and reads back as 1 otherwise""" import tomllib legacy = bannermod.RunCardLO() self.assertEqual(legacy['d'], 1.0) @@ -1659,7 +1660,40 @@ def test_mlm_jet_radius_defaults_to_madevents_d(self): self.assertEqual(rc['beam']['jet_radius'], legacy['d']) out = io.StringIO() rc.write(out, template=self.template) - self.assertEqual(tomllib.loads(out.getvalue())['beam']['jet_radius'], 1.0) + self.assertNotIn('jet_radius', tomllib.loads(out.getvalue())['beam']) + self.assertEqual( + bannermod.RunCardMG7(out.getvalue())['beam']['jet_radius'], 1.0) + rc.set('beam.jet_radius', 0.4, user=True) + out = io.StringIO() + rc.write(out, template=self.template) + self.assertEqual(tomllib.loads(out.getvalue())['beam']['jet_radius'], 0.4) + + def test_hidden_mlm_options_survive_a_rewrite(self): + """The MLM clustering options are hidden: absent from the default card, + and written at the end of [beam] once set, also when set without + user=True as from_LO does, so a rewrite of the card keeps them""" + import tomllib + hidden = ['jet_radius', 'jet_scale_scheme', 'scale_scheme', + 'parton_line_scheme', 'alphas_reweighting', 'pdf_reweighting', + 'clustering_mean_pdf_ladder', 'clustering_measure', + 'clustering_history'] + rc = bannermod.RunCardMG7() + out = io.StringIO() + rc.write(out, template=self.template) + beam = tomllib.loads(out.getvalue())['beam'] + self.assertEqual([k for k in hidden if k in beam], []) + self.assertIn('max_jet_flavor', beam) + rc.set('beam.scale_scheme', 'madevent') + rc.set('beam.clustering_history', 'diagram', user=True) + out = io.StringIO() + rc.write(out, template=self.template) + reread = bannermod.RunCardMG7(out.getvalue()) + self.assertEqual(reread['beam']['scale_scheme'], 'madevent') + self.assertEqual(reread['beam']['clustering_history'], 'diagram') + # and a second rewrite keeps them + out2 = io.StringIO() + reread.write(out2, template=self.template) + self.assertEqual(out2.getvalue(), out.getvalue()) def test_numeric_suffix_like_legacy(self): """integer/float k/M suffixes are parsed as in the legacy RunCard""" From c3ffd6814cf539837d5220dde83140a3af9db895 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Wed, 28 Oct 2026 23:21:08 +0100 Subject: [PATCH 75/81] MLM kernel: gate the madevent-only writes instead of snapshot/restore Under clustering_mean + pdf ladder the beam-line walk used to overwrite mu_R, the alpha_s vertex scales, the jet scales and the merging-jet flags and restore a snapshot of them afterwards. Each of those writes now sits under one madevent_scales flag inside the walk, and madevent's mu_R is kept in a local (walk_ren_scale) that the mu_F fallbacks still use, so nothing has to be put back: a new write in the walk is next to the flag rather than relying on a restore list. The snapshot arrays (about 21 values per event, filled for every scheme) are gone. test_the_ladder_leaves_every_other_clustering_mean_output_alone checks on ttgg and ttggg, for every clustering history and both jet scale schemes, that the ladder leaves every non-pdf output equal to plain clustering_mean's and mu_F and the ladder outputs equal to madevent's. Mutation checks: ungating mu_R fails it in all 13 cases, ungating the merging jets and jet-scale booking fails all 12 new cases (the t-channel test alone missed that one). An ungated alpha_s step cannot be seen: the entries madevent rewrites (the central mT, the raised last scale) are never alpha_s-reweighted vertices in any fixture (0/6000 W+jet events differ either). Co-Authored-By: Claude --- madspace/src/kernels/mlm.hpp | 82 ++++++++++++++++-------------------- madspace/tests/test_mlm.py | 56 ++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 45 deletions(-) diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 4f2a70d41b..5b40c5272e 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -849,22 +849,17 @@ KERNELSPEC int mlm_clustering_walk( // pdf_reweighting == 2 under clustering_mean: walk the beam lines as // madevent does below, but keep only the pdf half of it - the lowered // leading scale, the ladder and the per-beam scale it ends at, which is - // also the factorisation scale then reported. mu_R, the alpha_s vertex - // scales, the jet scales and the merging-jet gating are put back to - // clustering_mean's after the walk. Without it clustering_mean evaluates - // both beam densities at the softest clustering scale, with no ladder. + // also the factorisation scale then reported. Without it clustering_mean + // evaluates both beam densities at the softest clustering scale, with no + // ladder. bool mean_with_ladder = scale_scheme != SCALES_MADEVENT && pdf_reweighting == 2; - FVal mean_ren_scale = ren_scale_val; - FVal mean_alphas_step[N_EXT_MAX - 3]; - FVal mean_outgoing[N_EXT_MAX]; - for (int i = 0; i < cluster_max; ++i) { - mean_alphas_step[i] = alphas_step[i]; - } - for (int i = 0; i < n_part - 2; ++i) { - mean_outgoing[i] = outgoing_scales[i]; - } if (scale_scheme == SCALES_MADEVENT || mean_with_ladder) { + // Everything the walk writes besides the densities and mu_F - mu_R, + // the alpha_s vertex scales, the jet scales and the merging-jet gating + // - is madevent's only, and stays clustering_mean's under + // mean_with_ladder: every such write below sits under this flag. + bool madevent_scales = scale_scheme == SCALES_MADEVENT; // madevent clusters one step further than this kernel does: its loop // runs all the way to the 2 -> 1 configuration, so it has one more // scale than cluster_max. That last step absorbs whatever is left of @@ -1124,10 +1119,12 @@ KERNELSPEC int mlm_clustering_walk( } } } - for (int leg = 0; leg < n_part; ++leg) { - merging_jet[leg] = leg >= 2 && iqjets[leg] > 0; + if (madevent_scales) { + for (int leg = 0; leg < n_part; ++leg) { + merging_jet[leg] = leg >= 2 && iqjets[leg] > 0; + } + have_merging_jets = true; } - have_merging_jets = true; for (int j = 0; j < 2; ++j) { if (jfirst[j] < 0) { jfirst[j] = jlast[j]; @@ -1190,8 +1187,10 @@ KERNELSPEC int mlm_clustering_walk( pt_step[jlast[j]] = pt_step[jfirst[j]]; } } - for (int i = 0; i < cluster_max; ++i) { - alphas_step[i] = pt_step[i]; + if (madevent_scales) { + for (int i = 0; i < cluster_max; ++i) { + alphas_step[i] = pt_step[i]; + } } // The jet scales madevent writes, the "Store jet info for matching" @@ -1205,7 +1204,7 @@ KERNELSPEC int mlm_clustering_walk( // // Only the production scheme is madevent's; the emission scheme keeps // the booking above. - if (by_production) { + if (by_production && madevent_scales) { FVal ptclus[N_EXT_MAX]; for (int leg = 0; leg < n_part; ++leg) { ptclus[leg] = 0.0; @@ -1268,24 +1267,27 @@ KERNELSPEC int mlm_clustering_walk( // mu_R: the geometric mean of the four scales. madevent writes it as // (pt2 pt2 pt2 pt2)^(1/8) over squared scales, which is the same thing. FVal last_scale = pt_step[steps - 1]; + // madevent's mu_R, which mu_F falls back on; it only becomes the + // event's mu_R under madevent_scales + FVal walk_ren_scale; // Take the roots before multiplying. A degenerate clustering leaves a // scale at SCALE_MAX, and the product of four of those overflows to // infinity, which then reaches alpha_s as a NaN. if (jlast[0] >= 0 && jlast[1] >= 0) { - ren_scale_val = pow(s_last[0], 0.25) * pow(s_central[0], 0.25) * + walk_ren_scale = pow(s_last[0], 0.25) * pow(s_central[0], 0.25) * pow(s_last[1], 0.25) * pow(s_central[1], 0.25); } else if (jlast[0] >= 0) { - ren_scale_val = sqrt(s_last[0]) * sqrt(s_central[0]); + walk_ren_scale = sqrt(s_last[0]) * sqrt(s_central[0]); } else if (jlast[1] >= 0) { - ren_scale_val = sqrt(s_last[1]) * sqrt(s_central[1]); + walk_ren_scale = sqrt(s_last[1]) * sqrt(s_central[1]); } else if (jcentral[0] >= 0 && jcentral[1] >= 0) { - ren_scale_val = sqrt(s_central[0]) * sqrt(s_central[1]); + walk_ren_scale = sqrt(s_central[0]) * sqrt(s_central[1]); } else if (jcentral[0] >= 0) { - ren_scale_val = s_central[0]; + walk_ren_scale = s_central[0]; } else if (jcentral[1] >= 0) { - ren_scale_val = s_central[1]; + walk_ren_scale = s_central[1]; } else { - ren_scale_val = last_scale; + walk_ren_scale = last_scale; } // mu_F, one per beam. madevent stores q2fact = mu_F^2 as @@ -1324,20 +1326,23 @@ KERNELSPEC int mlm_clustering_walk( } } if (!(fac_scale1 > 0.0)) { - fac_scale1 = ren_scale_val; + fac_scale1 = walk_ren_scale; } if (!(fac_scale2 > 0.0)) { - fac_scale2 = ren_scale_val; + fac_scale2 = walk_ren_scale; } // and keep everything inside the range the rest of the kernel uses - if (!(ren_scale_val > 0.0) || !(ren_scale_val < SCALE_MAX)) { - ren_scale_val = last_scale; + if (!(walk_ren_scale > 0.0) || !(walk_ren_scale < SCALE_MAX)) { + walk_ren_scale = last_scale; } if (!(fac_scale1 > 0.0) || !(fac_scale1 < SCALE_MAX)) { - fac_scale1 = ren_scale_val; + fac_scale1 = walk_ren_scale; } if (!(fac_scale2 > 0.0) || !(fac_scale2 < SCALE_MAX)) { - fac_scale2 = ren_scale_val; + fac_scale2 = walk_ren_scale; + } + if (madevent_scales) { + ren_scale_val = walk_ren_scale; } // The pdf half of rewgt in Template/LO/SubProcesses/reweight.f, the @@ -1432,19 +1437,6 @@ KERNELSPEC int mlm_clustering_walk( } } } - if (mean_with_ladder) { - ren_scale_val = mean_ren_scale; - for (int i = 0; i < cluster_max; ++i) { - alphas_step[i] = mean_alphas_step[i]; - } - for (int i = 0; i < n_part - 2; ++i) { - outgoing_scales[i] = mean_outgoing[i]; - } - for (int i = 0; i < n_part; ++i) { - merging_jet[i] = false; - } - have_merging_jets = false; - } // The merging cut. A matrix-element jet below xqcut is radiation the parton // shower is meant to produce, so the event is dropped - but only for a leg diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index 055a2ff211..f49ae476c6 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -1657,6 +1657,62 @@ def test_clustering_mean_pdf_ladder_takes_only_the_densities_from_madevent( assert np.array_equal(fact2_o, fact2_m) +@pytest.mark.parametrize( + "history", list(ms.MLMClustering.ClusteringHistory.__members__.values()) +) +@pytest.mark.parametrize( + "jet_scale_scheme", list(ms.MLMClustering.JetScaleScheme.__members__.values()) +) +def test_the_ladder_leaves_every_other_clustering_mean_output_alone( + process, history, jet_scale_scheme +): + """The walk under clustering_mean + ladder only writes the densities and + mu_F; everything else it computes is madevent's, and has to stay out of the + event. Checked on the gluon-rich processes, for every history and both jet + scale schemes, against plain clustering_mean (every non-pdf output, exactly) + and against madevent (mu_F and every ladder output, exactly).""" + _, diagrams, pdg_ids = process + momenta = sample_momenta(diagrams) + scheme = ms.MLMClustering.ScaleScheme + + def clustering(**kwargs): + return make_clustering( + diagrams, external_pdg_ids=pdg_ids, xqcut=20.0, + clustering_history=history, jet_scale_scheme=jet_scale_scheme, + **kwargs, + ) + + ladder = clustering(scale_scheme=scheme.clustering_mean) + mean = clustering( + scale_scheme=scheme.clustering_mean, clustering_mean_pdf_ladder=False + ) + madevent = clustering(scale_scheme=scheme.madevent) + + ren, fact1, fact2, jets, diagram, veto = run_all(ladder, momenta) + ren_m, _, _, jets_m, diagram_m, veto_m = run_all(mean, momenta) + assert np.array_equal(ren, ren_m) + assert np.array_equal(jets, jets_m) + assert np.array_equal(diagram, diagram_m) + assert np.array_equal(veto, veto_m) + for got, want in zip( + alphas_outputs(ladder, momenta), alphas_outputs(mean, momenta) + ): + assert np.array_equal(got, want) + + _, fact1_e, fact2_e, _, diagram_e, _ = run_all(madevent, momenta) + # the same history, or mu_F and the ladder have nothing to agree on + same = diagram == diagram_e + assert same.mean() > 0.5 + assert np.array_equal(fact1[same], fact1_e[same]) + assert np.array_equal(fact2[same], fact2_e[same]) + # (no ladder step fires on these two fixtures, under madevent either: the + # steps themselves are compared on the t-channel process above) + out = pdf_outputs(ladder, momenta) + out_e = pdf_outputs(madevent, momenta) + for name in PDF_OUTPUTS: + assert np.array_equal(out[name][same], out_e[name][same]), name + + def test_pdf_reweighting_drops_the_density_down_the_ladder(t_channel_momenta): """The point of the whole thing: with it on, the density is asked for at or below the factorisation scale, and for a real part of the sample strictly From 5a3edaa5fb6a2ae17363d6c450ee52f586fd848f Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 29 Oct 2026 01:04:48 +0100 Subject: [PATCH 76/81] MLM automatic for a human user: merging on by default, DJR plots after Pythia8 - RunCardMG7.create_default_for_process: a process generated with several multiplicities, the higher ones only adding jets, on hadronic beams, is set up as an MLM merged sample, as madevent sets ickkw = 1: dynamical_scale_choice = "mlm", xqcut = 30, no jet-jet and jet-lepton delta_r cut. The "only jets added" test is mlm_matching_needed(), shared with RunCardLO. - Pythia8: for an MLM sample Main:InternalAnalysis is switched on (new hidden PY8Card parameters, a user setting wins), so main164 writes the differential jet rates to djrs.dat. - HwUList.from_pythia8_main164 reads that file: main164 writes (no XML parser takes it) and two columns. Each job of a split shower is scaled by 1e9 n_job / n_total (main164 normalises every job to the full cross section, in mb), the serial shower by 1e9. A DJR read error only warns instead of aborting the shower. - create_plot: ickkw is read through __getitem__ (an mg7 run card answers the legacy key without containing it, so the plots were never made), and an observable without data (main164 writes no pts.dat) is skipped rather than stopping before the rendering. Checked end to end: p p > e+ ve + 0,1j output mg7, launched with only shower=pythia8, runs MLM (xqcut 30, qCut 45), reports the matched cross section (6157 pb) and writes HTML//tag_1_PY8_plots with the DJR PDF; the d01 integral equals the matched cross section (5915 vs 5953 +- 55 pb). Co-Authored-By: Claude --- madgraph/interface/common_run_interface.py | 16 ++- madgraph/interface/madevent_interface.py | 45 +++++++- madgraph/various/banner.py | 115 ++++++++++++-------- madgraph/various/histograms.py | 51 +++++++++ tests/unit_tests/various/test_banner.py | 44 ++++++++ tests/unit_tests/various/test_histograms.py | 58 ++++++++++ 6 files changed, 280 insertions(+), 49 deletions(-) diff --git a/madgraph/interface/common_run_interface.py b/madgraph/interface/common_run_interface.py index aab4884416..0afa5de747 100755 --- a/madgraph/interface/common_run_interface.py +++ b/madgraph/interface/common_run_interface.py @@ -1472,9 +1472,15 @@ def create_plot(self, mode='Pythia8', event_path=None, output=None, tag=None): PY8_plots_root_path = pjoin(self.me_dir,'HTML', self.run_name,'%s_PY8_plots'%tag) - if 'ickkw' in self.run_card: - if int(self.run_card['ickkw'])==1 or \ - self.run_card['ktdurham']>0.0 or self.run_card['ptlund']>0.0: + # read through __getitem__: an mg7 run card answers these legacy keys + # (ickkw = 1 for an MLM sample) without containing them + try: + ickkw = int(self.run_card['ickkw']) + ckkw_cut = self.run_card['ktdurham'] > 0.0 or self.run_card['ptlund'] > 0.0 + except KeyError: + ickkw, ckkw_cut = 0, False + if ickkw or ckkw_cut: + if ickkw==1 or ckkw_cut: self.update_status('Create matching plots for Pythia8', level='pythia8') @@ -1491,6 +1497,10 @@ def create_plot(self, mode='Pythia8', event_path=None, output=None, tag=None): self.run_name, '%s_pts.dat' % tag) for observable_name, data_path in [('djr',djr_path), ('pt',pt_path)]: + # Pythia8's main164 writes the DJRs only (no pts.dat): + # an observable without data is skipped, not the plots + if not os.path.isfile(data_path): + continue try: if not self.generate_Pythia8_HwU_plots( PY8_plots_root_path, merging_scale_name, diff --git a/madgraph/interface/madevent_interface.py b/madgraph/interface/madevent_interface.py index cbd5867d6a..0442474c1d 100755 --- a/madgraph/interface/madevent_interface.py +++ b/madgraph/interface/madevent_interface.py @@ -4662,6 +4662,13 @@ def setup_Pythia8RunAndCard(self, PY8_Card, run_type): logger.info("No user-defined value for Pythia8 parameter "+ "'JetMatching:nJetMax'. Setting it automatically to %d."%nJetMax) PY8_Card.MadGraphSet('JetMatching:nJetMax',nJetMax, force=True) + # the differential jet rates of the showered events, from which + # the DJR plots of the run are made (create_plot) + if 'main:internalanalysis' not in PY8_Card.user_set: + PY8_Card.MadGraphSet('Main:InternalAnalysis', True) + if PY8_Card['Main:InternalAnalysis'] and \ + 'internalanalysis:output' not in PY8_Card.user_set: + PY8_Card.MadGraphSet('InternalAnalysis:output', './djrs.dat') # cite the shower-kt MLM scheme only when it is actually enabled if common_run.citation is not None and \ PY8_Card['JetMatching:doShowerKt'.lower()]: @@ -4999,6 +5006,23 @@ def do_pythia8(self, line): raise self.InvalidCmd('Pythia8 shower interrupted with return'+\ ' code %d.\n'%ret_code+\ 'You can find more information in this log file:\n%s'%pythia_log) + # main164's differential jet rates (an MLM sample), in mb: to + # the _djrs.dat in pb that the DJR plots are made from + djr_file = pjoin(self.me_dir,'Events',self.run_name,'djrs.dat') + if os.path.isfile(djr_file): + try: + djr_HwU = histograms.HwUList.from_pythia8_main164( + djr_file, scale=1e9) + except Exception as error: + logger.warning('Could not read the DJR histograms in %s: %s', + djr_file, error) + djr_HwU = None + if djr_HwU is not None: + djr_HwU.output(pjoin(self.me_dir,'Events',self.run_name,'djrs'), + format='HwU') + shutil.move(pjoin(self.me_dir,'Events',self.run_name,'djrs.HwU'), + pjoin(self.me_dir,'Events',self.run_name,'%s_djrs.dat'%tag)) + os.remove(djr_file) else: if self.run_card['event_norm']=='sum': logger.error("") @@ -5200,10 +5224,29 @@ def wait_monitoring(Idle, Running, Done): # djr plots djr_HwU = None n_added = 0 - for split_dir in split_dirs: + for i_split, split_dir in enumerate(split_dirs): djr_file = pjoin(split_dir,'djrs.dat') if not os.path.isfile(djr_file): continue + # Pythia8's main164 writes its own, simpler histfile (see + # HwUList.from_pythia8_main164): only the summed + # histograms, without the per-sample cross sections, in mb + # and normalised to the full cross section in each job + try: + new_djr_HwU = histograms.HwUList.from_pythia8_main164( + djr_file, scale=1e9 * partition_for_PY8[i_split] + / float(sum(partition_for_PY8))) + except Exception as error: + logger.warning('Could not read the DJR histograms in %s: %s', + djr_file, error) + continue + if new_djr_HwU is not None: + if djr_HwU is None: + djr_HwU = new_djr_HwU + else: + for i, hist in enumerate(djr_HwU): + djr_HwU[i] = hist + new_djr_HwU[i] + continue xsecs = self.extract_cross_sections_from_DJR(djr_file) if len(xsecs)>0: n_added += 1 diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index d8eef678bc..c393c3c1a4 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -2097,6 +2097,14 @@ def default_setup(self): # Visible parameters # ================== self.add_param("Main:numberOfEvents", 0) + # main164's own analysis: the differential jet rates log10 d_{n,n+1} + # of the showered events, written to InternalAnalysis:output (the + # djrs.dat the DJR plots are made from). Switched on by MadGraph7 for + # an MLM sample. + self.add_param("Main:InternalAnalysis", False, hidden=True, + always_write_to_card=False) + self.add_param("InternalAnalysis:output", './djrs.dat', hidden=True, + always_write_to_card=False) # for MLM merging # -1.0 means that it will be set automatically by MadGraph7 self.add_param("JetMatching:qCut", -1.0, always_write_to_card=False) @@ -2568,9 +2576,6 @@ def group_params(params): # special case for HepMC needs two flags if 'HepMC:output' == param_entry: output.write(' %s=%s\n'%('Main:HepMC', 'on')) - if 'Main:InternalAnalysis'.lower() in self.user_set and \ - self['Main:InternalAnalysis'].lower() == 'on': - output.write('InternalAnalysis:output = ./djrs.dat\n') output.write(template%(param_entry, value_entry.replace(value,new_value))) @@ -4467,6 +4472,38 @@ def get_model_flavour_scheme(proc_def): return None +def mlm_matching_needed(proc_def): + """Whether the generated processes call for MLM merging: they come in more + than one multiplicity, and the highest one only adds jets (light quarks, + b quarks, gluons) to the lowest one. madevent then turns on ickkw = 1 with + xqcut = 30, MadGraph7 its "mlm" scale choice with the same xqcut.""" + try: + multiplicities = [len(proc[0]['legs']) for proc in proc_def] + except (TypeError, KeyError, IndexError): + return False + if not multiplicities: + return False + min_particle, max_particle = min(multiplicities), max(multiplicities) + if min_particle == max_particle: + return False + # take one of the processes with min_particle + for procmin in proc_def: + if len(procmin[0]['legs']) == min_particle: + idsmin = [l['id'] for l in procmin[0]['legs']] + break + for procmax in proc_def: + if len(procmax[0]['legs']) != max_particle: + continue + idsmax = [l['id'] for l in procmax[0]['legs']] + for i in idsmin: + if i in idsmax: + idsmax.remove(i) + if all(j in [1,-1,2,-2,3,-3,4,-4,5,-5,21,81,-81] for j in idsmax): + # all are jets => matching is ON + return True + return False + + class RunCardLO(RunCard): """an object to handle in a nice way the run_card information""" @@ -5264,48 +5301,17 @@ def create_default_for_process(self, proc_characteristic, history, proc_def): # Check if need matching - min_particle = 99 - max_particle = 0 - for proc in proc_def: - min_particle = min(len(proc[0]['legs']), min_particle) - max_particle = max(len(proc[0]['legs']), max_particle) - if min_particle != max_particle: - #take one of the process with min_particle - for procmin in proc_def: - if len(procmin[0]['legs']) != min_particle: - continue - else: - idsmin = [l['id'] for l in procmin[0]['legs']] - break - matching = False - for procmax in proc_def: - if len(procmax[0]['legs']) != max_particle: - continue - idsmax = [l['id'] for l in procmax[0]['legs']] - for i in idsmin: - if i not in idsmax: - continue - else: - idsmax.remove(i) - for j in idsmax: - if j not in [1,-1,2,-2,3,-3,4,-4,5,-5,21,81,-81]: - break - else: - # all are jet => matching is ON - matching=True - break - - if matching: - self['ickkw'] = 1 - self['xqcut'] = 30 - #self['use_syst'] = False - self['drjj'] = 0 - self['drjl'] = 0 - self['sys_alpsfact'] = "0.5 1 2" - self['systematics_arguments'].append('--alps=0.5,1,2') - self.display_block.append('mlm') - self.display_block.append('ckkw') - self['dynamical_scale_choice'] = -1 + if mlm_matching_needed(proc_def): + self['ickkw'] = 1 + self['xqcut'] = 30 + #self['use_syst'] = False + self['drjj'] = 0 + self['drjl'] = 0 + self['sys_alpsfact'] = "0.5 1 2" + self['systematics_arguments'].append('--alps=0.5,1,2') + self.display_block.append('mlm') + self.display_block.append('ckkw') + self['dynamical_scale_choice'] = -1 # For interference module, the systematics are wrong. @@ -8299,6 +8305,10 @@ def write_gridpack_card(self, output_file): # ------------------------------------------------------------------ # process dependent defaults (mirrors RunCardLO.create_default_for_process) # ------------------------------------------------------------------ + # merging cut set up for a process generated with several jet + # multiplicities, madevent's + default_mlm_xqcut = 30.0 + def create_default_for_process(self, proc_characteristic, history, proc_def): """Adjust default values depending on the process, then read the site/user default file (``input/default_run_card_mg7.toml``).""" @@ -8344,6 +8354,21 @@ def create_default_for_process(self, proc_characteristic, history, proc_def): if key not in self.user_set: self[key] = self.photon_pdf + # More than one multiplicity, the higher ones only adding jets: an MLM + # merged sample, as madevent sets it up (ickkw = 1, xqcut = 30, drjj = + # drjl = 0). The shower then does the jet matching (is_mlm_merged), and + # auto_ptj_mjj ties the jet cuts to xqcut. + if beam_id & hadronic and not self['beam']['leptonic'] and \ + mlm_matching_needed(proc_def): + self['beam']['dynamical_scale_choice'] = 'mlm' + self['phasespace']['xqcut'] = self.default_mlm_xqcut + cuts = self.dynamic_sections['cuts'] + for key in ('jet-delta_r', 'jet-lepton-delta_r'): + if isinstance(cuts.get(key), dict): + cuts[key].pop('min', None) + if not cuts[key]: + del cuts[key] + # 1 -> N decay: no cuts at all. A partial width is an inclusive # quantity, so any kinematic cut biases it low (the collider defaults # cost ~3% on t > b w+, w+ > e+ ve). The Breit-Wigner cutoff in diff --git a/madgraph/various/histograms.py b/madgraph/various/histograms.py index edd4fbc213..cb9f06ee0b 100755 --- a/madgraph/various/histograms.py +++ b/madgraph/various/histograms.py @@ -3200,6 +3200,57 @@ def get(self, name): raise NameError("no histogram with name: %s" % name) + @staticmethod + def from_pythia8_main164(file_path, scale=1.0): + """The differential jet rates written by Pythia8's main164 with + Main:InternalAnalysis = on: a holding, per run, the + histograms log10d01 ... log10d34 as two columns, the lower edge of each + (equal-width) bin and its weighted content, with no uncertainty and no + split by jet sample. Returns the histograms of the first run as a + HwUList with 'central' and a zero 'stat_error', or None when the file + holds none. main164 writes '', which no XML parser takes, + so the file is read line by line. Every bin is multiplied by scale: + main164 fills in mb, each event weighted by sigma / (its own number of + events), so a job of a split run needs 1e9 n_job / n_total to give its + share in pb.""" + histos = HwUList([]) + current = None + rows = [] + + def close(): + if current is None or len(rows) < 2: + return + width = rows[1][0] - rows[0][0] + new_histo = HwU() + new_histo.process_histogram_name(current) + new_histo.bins = BinList(weight_labels=['central', 'stat_error']) + for xmin, value in rows: + new_histo.bins.append(Bin((float(xmin), float(xmin + width)), + {'central': float(value) * scale, + 'stat_error': 0.0})) + histos.append(new_histo) + + with open(file_path) as stream: + for line in stream: + line = line.strip() + if line.startswith('= 2: + rows.append((float(columns[0]), float(columns[1]))) + return histos if len(histos) else None + def parse_histos_from_PY8_XML_stream(self, stream, run_id=None, merging_scale=None, accepted_types_order=[], consider_reweights='ALL', raw_labels=False): diff --git a/tests/unit_tests/various/test_banner.py b/tests/unit_tests/various/test_banner.py index 10e55387b3..d03e2e7ef7 100755 --- a/tests/unit_tests/various/test_banner.py +++ b/tests/unit_tests/various/test_banner.py @@ -2552,6 +2552,50 @@ def mg7_proc(initial, final, model=None): return {'legs': legs, 'legs_with_decays': [], 'model': model or FakeModel()} +class TestRunCardMG7AutoMLM(unittest.TestCase): + """A process generated with several jet multiplicities is set up as an MLM + merged sample at output time, as madevent's ickkw = 1 is.""" + + class PC(dict): + def __init__(self, ninitial=2): + super().__init__(ninitial=ninitial, loop_induced=False, + colored_pdgs=[]) + + def build(self, processes): + rc = bannermod.RunCardMG7() + rc.create_default_for_process(self.PC(), '', processes) + return rc + + def test_extra_jets_turn_mlm_on(self): + rc = self.build([[mg7_proc([2, -1], [-11, 12])], + [mg7_proc([2, -1], [-11, 12, 21])]]) + self.assertEqual(rc['beam']['dynamical_scale_choice'], 'mlm') + self.assertEqual(rc['phasespace']['xqcut'], 30.0) + self.assertTrue(rc.is_mlm_merged()) + self.assertEqual(rc['ickkw'], 1) + # drjj = drjl = 0, as madevent + self.assertNotIn('jet-delta_r', rc.dynamic_sections['cuts']) + self.assertNotIn('jet-lepton-delta_r', rc.dynamic_sections['cuts']) + self.assertIn('lepton-delta_r', rc.dynamic_sections['cuts']) + + def test_an_extra_photon_is_no_merging(self): + rc = self.build([[mg7_proc([2, -1], [-11, 12])], + [mg7_proc([2, -1], [-11, 12, 22])]]) + self.assertNotEqual(rc['beam']['dynamical_scale_choice'], 'mlm') + self.assertEqual(rc['phasespace']['xqcut'], 0.0) + self.assertIn('jet-delta_r', rc.dynamic_sections['cuts']) + + def test_one_multiplicity_is_no_merging(self): + rc = self.build([[mg7_proc([2, -1], [-11, 12, 21])]]) + self.assertFalse(rc.is_mlm_merged()) + + def test_lepton_collider_is_no_merging(self): + rc = self.build([[mg7_proc([11, -11], [2, -2])], + [mg7_proc([11, -11], [2, -2, 21])]]) + self.assertFalse(rc.is_mlm_merged()) + self.assertEqual(rc['phasespace']['xqcut'], 0.0) + + class TestRunCardMG7Histograms(unittest.TestCase): """[histograms]: the default plots written at output time.""" diff --git a/tests/unit_tests/various/test_histograms.py b/tests/unit_tests/various/test_histograms.py index c4d196c2f2..09dd149ecd 100755 --- a/tests/unit_tests/various/test_histograms.py +++ b/tests/unit_tests/various/test_histograms.py @@ -882,3 +882,61 @@ def test_cli_accepts_run_id_and_rejects_incompatible_sums(self): self.assertNotEqual(result.returncode, 0) self.assertIn('are not compatible and cannot be combined', result.stdout+result.stderr) + + +class TestPythia8Main164DJRs(unittest.TestCase): + """The differential jet rates Pythia8's main164 writes with + Main:InternalAnalysis = on, which the DJR plots of an MLM run are made + from.""" + + MAIN164 = """ + + + 0.0000e+00 1.0000e-06 + 1.0000e+00 2.0000e-06 + 2.0000e+00 0.0000e+00 + + + 0.0000e+00 3.0000e-06 + 1.0000e+00 0.0000e+00 + 2.0000e+00 4.0000e-06 + + + +""" + + def write(self, text): + import tempfile + handle, path = tempfile.mkstemp(suffix='.dat') + with os.fdopen(handle, 'w') as stream: + stream.write(text) + self.addCleanup(os.remove, path) + return path + + def test_reads_the_main164_histfile(self): + """main164 writes (one quote too many), which no XML + parser takes; the bins are read with their edges and scaled""" + histos = histograms.HwUList.from_pythia8_main164( + self.write(self.MAIN164), scale=1e9) + self.assertEqual([h.title for h in histos], ['log10d01 []', 'log10d12 []']) + bins = histos[0].bins + self.assertEqual([b.boundaries for b in bins], + [(0.0, 1.0), (1.0, 2.0), (2.0, 3.0)]) + self.assertEqual([b.wgts['central'] for b in bins], [1000.0, 2000.0, 0.0]) + self.assertTrue(all(b.wgts['stat_error'] == 0.0 for b in bins)) + # and it writes out as a HwU file the plots can be made from + out = self.write('') + histos.output(out[:-len('.dat')], format='HwU') + self.addCleanup(os.remove, out[:-len('.dat')] + '.HwU') + reread = histograms.HwUList(out[:-len('.dat')] + '.HwU') + self.assertEqual(len(reread), 2) + + def test_the_former_interface_format_is_left_to_its_reader(self): + old = self.write('\n\n' + '\n\n' + '\n\n') + self.assertIsNone(histograms.HwUList.from_pythia8_main164(old)) + + def test_a_file_without_histograms(self): + self.assertIsNone(histograms.HwUList.from_pythia8_main164( + self.write('\n\n'))) From c3c03349add0f9ad349703048116a1804eadf95a Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 29 Oct 2026 04:57:31 +0100 Subject: [PATCH 77/81] DJR reader: per-jet-sample histograms and bin errors from main164 HwUList.from_pythia8_main164 also reads blocks (returned titled |JETSAMPLE@n, as the plots expect) and a third column with the bin uncertainty. main164 writes neither today; a patched main164 (per-sample histograms from JetMatchingMadgraph::nMEpartons(), errors via Hist::table(..., printError), the quote fixed) does, and the DJR plots then show the 0/1/2-jet contributions. The former MG5aMC_PY8_interface format is now told apart by the header attribute of its . Today's main164 output reads as before. Co-Authored-By: Claude --- madgraph/various/histograms.py | 49 +++++++++++++-------- tests/unit_tests/various/test_histograms.py | 20 +++++++++ 2 files changed, 51 insertions(+), 18 deletions(-) diff --git a/madgraph/various/histograms.py b/madgraph/various/histograms.py index cb9f06ee0b..bc6cdaa858 100755 --- a/madgraph/various/histograms.py +++ b/madgraph/various/histograms.py @@ -3204,40 +3204,52 @@ def get(self, name): def from_pythia8_main164(file_path, scale=1.0): """The differential jet rates written by Pythia8's main164 with Main:InternalAnalysis = on: a holding, per run, the - histograms log10d01 ... log10d34 as two columns, the lower edge of each - (equal-width) bin and its weighted content, with no uncertainty and no - split by jet sample. Returns the histograms of the first run as a - HwUList with 'central' and a zero 'stat_error', or None when the file - holds none. main164 writes '', which no XML parser takes, + histograms log10d01 ... log10d34 of the whole sample, one row per bin + with its lower edge and weighted content (and, from a main164 that + prints it, its uncertainty), and possibly the same per jet sample + (number of matrix-element partons) in blocks. + Returns the histograms of the first run as a HwUList: the per-sample + ones, titled |JETSAMPLE@n as the plots expect, when the file has them, + the whole-sample ones otherwise; None when it holds none, or when it is + the former MG5aMC_PY8_interface format (whose carries a weight + header), which parse_histos_from_PY8_XML_stream reads. + + main164 up to 8.317 writes '', which no XML parser takes, so the file is read line by line. Every bin is multiplied by scale: main164 fills in mb, each event weighted by sigma / (its own number of events), so a job of a split run needs 1e9 n_job / n_total to give its share in pb.""" - histos = HwUList([]) - current = None - rows = [] + totals, samples = HwUList([]), HwUList([]) + current, sample, rows = None, None, [] def close(): if current is None or len(rows) < 2: return width = rows[1][0] - rows[0][0] new_histo = HwU() - new_histo.process_histogram_name(current) + new_histo.process_histogram_name( + current if sample is None else '%s |JETSAMPLE@%d' % (current, sample)) new_histo.bins = BinList(weight_labels=['central', 'stat_error']) - for xmin, value in rows: - new_histo.bins.append(Bin((float(xmin), float(xmin + width)), - {'central': float(value) * scale, - 'stat_error': 0.0})) - histos.append(new_histo) + for row in rows: + new_histo.bins.append(Bin((float(row[0]), float(row[0] + width)), + {'central': float(row[1]) * scale, + 'stat_error': float(row[2]) * abs(scale) if len(row) > 2 else 0.0})) + (totals if sample is None else samples).append(new_histo) with open(file_path) as stream: for line in stream: line = line.strip() + if line.startswith('= 2: - rows.append((float(columns[0]), float(columns[1]))) + rows.append([float(c) for c in columns[:3]]) + histos = samples if len(samples) else totals return histos if len(histos) else None def parse_histos_from_PY8_XML_stream(self, stream, run_id=None, diff --git a/tests/unit_tests/various/test_histograms.py b/tests/unit_tests/various/test_histograms.py index 09dd149ecd..aea1205623 100755 --- a/tests/unit_tests/various/test_histograms.py +++ b/tests/unit_tests/various/test_histograms.py @@ -937,6 +937,26 @@ def test_the_former_interface_format_is_left_to_its_reader(self): '\n\n') self.assertIsNone(histograms.HwUList.from_pythia8_main164(old)) + def test_jet_samples_and_errors(self): + """A main164 that also writes the histograms per jet sample (number of + matrix-element partons) and the bin uncertainties: the per-sample + histograms are returned, titled |JETSAMPLE@n, with their errors""" + text = ('\n\n' + '\n' + ' 0.0 3.0e-06 1.0e-07\n 1.0 2.0e-06 1.0e-07\n\n' + '\n' + '\n' + ' 0.0 3.0e-06 1.0e-07\n 1.0 0.0 0.0\n\n' + '\n\n' + '\n' + ' 0.0 0.0 0.0\n 1.0 2.0e-06 1.0e-07\n\n' + '\n\n\n') + histos = histograms.HwUList.from_pythia8_main164(self.write(text), scale=1e9) + self.assertEqual([(h.title, h.jetsample) for h in histos], + [('log10d01 []', 0), ('log10d01 []', 1)]) + self.assertEqual([b.wgts['central'] for b in histos[1].bins], [0.0, 2000.0]) + self.assertEqual([b.wgts['stat_error'] for b in histos[1].bins], [0.0, 100.0]) + def test_a_file_without_histograms(self): self.assertIsNone(histograms.HwUList.from_pythia8_main164( self.write('\n\n'))) From 1b6ef996b6c545ceafd03fda6edb3338217ec16e Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 29 Oct 2026 05:27:02 +0100 Subject: [PATCH 78/81] Pythia8 main164: DJRs per jet sample, patched by MadGraph7 until Pythia8 does it Stock main164 (Pythia8 8.317) histograms the jet rates of its internal analysis summed over the jet samples, so an MLM run's DJR plots cannot show the 0/1/2-jet contributions the former MG5aMC_PY8_interface plotted. Until a Pythia8 release can (a merge request is being prepared), an MLM shower builds a patched copy of main164 when the installed one cannot: - misc.patch_pythia8_main164_djrs: tags each event with the number of matrix-element partons of the MLM matching (JetMatchingMadgraph:: nMEpartons, the former interface's tag), writes the histograms per jet sample in blocks with the bin errors, and fixes the '' of Pythia8 <= 8.317. Pythia8's own main164.cc is never modified; a main164.cc the patch does not know (anchors checked on 8.316 and 8.317) or one that already splits per jet sample is used as is. - misc.get_pythia8_main164(jet_sample_djrs=True): builds that copy as a variant next to the HepMC ones, /main164[_hepmcN]_djr (or /lib/PY8_main164[_hepmcN]_djr for a read-only Pythia8), stamped with the patch version. - do_pythia8 picks the main164 build after the card setup, so that the MLM run's Main:InternalAnalysis selects it; a failed patched build falls back to the stock main164 with a warning. DJRoutput:jetSamples (hidden pythia8_card switch, on by default, not a Pythia8 setting) turns it off. - install pythia8 prebuilds the patched main164. Checked end to end: p p > e+ ve + 0,1j output mg7, launch with only shower=pythia8, builds examples/main164_djr once and writes DJR plots with the 0- and 1-jet samples and their statistical errors. Co-Authored-By: Claude --- madgraph/interface/madevent_interface.py | 33 ++++- madgraph/interface/madgraph_interface.py | 8 ++ madgraph/various/banner.py | 6 + madgraph/various/misc.py | 147 +++++++++++++++++++++-- tests/unit_tests/various/test_misc.py | 71 ++++++++++- 5 files changed, 248 insertions(+), 17 deletions(-) diff --git a/madgraph/interface/madevent_interface.py b/madgraph/interface/madevent_interface.py index 0442474c1d..131f3b5b2a 100755 --- a/madgraph/interface/madevent_interface.py +++ b/madgraph/interface/madevent_interface.py @@ -4499,26 +4499,46 @@ def get_pythia8_main164(self, PY8_Card): else: hepmc_version = self.get_auto_hepmc_version() + # The jet rates of an MLM shower (Main:InternalAnalysis) split per jet + # sample, for the DJR plots: a main164 that does not do it is patched + # (misc.patch_pythia8_main164_djrs), unless DJRoutput:jetSamples is off. + # Not a Pythia8 setting either. + PY8_Card.vetoParamWriteOut('DJRoutput:jetSamples') + jet_sample_djrs = bool(PY8_Card['Main:InternalAnalysis']) and \ + bool(PY8_Card['DJRoutput:jetSamples']) + if jet_sample_djrs: + try: + return self._get_pythia8_main164(hepmc_format, hepmc_version, True) + except self.InvalidCmd as error: + logger.warning('main164 could not be built to split the jet rates ' + + 'per jet sample, the DJR plots show their sum only:\n%s' % error) + return self._get_pythia8_main164(hepmc_format, hepmc_version, False) + + def _get_pythia8_main164(self, hepmc_format, hepmc_version, jet_sample_djrs): + """get_pythia8_main164 for a given HepMC version, falling back to the + version Pythia8 was configured with under HEPMCoutput:format = auto.""" + try: - return self.get_pythia8_main164_for(hepmc_version) + return self.get_pythia8_main164_for(hepmc_version, jet_sample_djrs) except (MadGraph5Error, OSError) as error: if hepmc_format != 'auto': raise self.InvalidCmd(str(error)) logger.warning('%s\nPythia8 writes the HepMC version it was '% error + 'configured with instead.') try: - return self.get_pythia8_main164_for(None) + return self.get_pythia8_main164_for(None, jet_sample_djrs) except (MadGraph5Error, OSError) as error: raise self.InvalidCmd(str(error)) - def get_pythia8_main164_for(self, hepmc_version): + def get_pythia8_main164_for(self, hepmc_version, jet_sample_djrs=False): """main164 writing HepMC events (None: the version Pythia8 was configured with), see misc.get_pythia8_main164.""" return misc.get_pythia8_main164(self.options['pythia8_path'], fallback_dir=pjoin(self.me_dir, 'lib', 'PY8_main164'), hepmc_version=hepmc_version, - hepmc_paths=self.get_hepmc_paths(hepmc_version or 3)) + hepmc_paths=self.get_hepmc_paths(hepmc_version or 3), + jet_sample_djrs=jet_sample_djrs) def setup_Pythia8RunAndCard(self, PY8_Card, run_type): """ Setup the Pythia8 Run environment and card. In particular all the process and run specific parameters @@ -4846,8 +4866,6 @@ def do_pythia8(self, line): 'pythia8_card_default.dat')) PY8_Card.read(pjoin(self.me_dir, 'Cards', 'pythia8_card.dat'), setter='user') - pythia_main = self.get_pythia8_main164(PY8_Card) - run_type = 'default' merged_run_types = ['MLM','CKKW'] if int(self.run_card['ickkw'])==1: @@ -4858,6 +4876,9 @@ def do_pythia8(self, line): # Edit the card and run environment according to the run specification HepMC_event_output = self.setup_Pythia8RunAndCard(PY8_Card, run_type) + # after the setup: an MLM shower writes the jet rates (InternalAnalysis), + # which selects a main164 that also splits them per jet sample + pythia_main = self.get_pythia8_main164(PY8_Card) if self.options['run_mode']==0 or (self.options['run_mode']==2 and self.options['nb_core']==1): diff --git a/madgraph/interface/madgraph_interface.py b/madgraph/interface/madgraph_interface.py index d93315f37c..a67e23d574 100755 --- a/madgraph/interface/madgraph_interface.py +++ b/madgraph/interface/madgraph_interface.py @@ -8146,6 +8146,14 @@ def install_pythia8_main164(self, prefix, config_file, add_options): logger.warning('Pythia8 is installed but its main164 could not be ' + 'compiled, so MadEvent cannot shower with it yet:\n%s' % error) return + # the main164 an MLM shower uses to split its jet rates per jet sample, + # patched if needed: built now rather than at the first MLM shower + try: + misc.get_pythia8_main164(pythia8_path, jet_sample_djrs=True) + except (MadGraph5Error, OSError) as error: + logger.info('main164 could not be built to split the jet rates per ' + + 'jet sample (the DJR plots of MLM showers will show their sum ' + + 'only):\n%s' % error) # the HepMC3 of a Pythia8 built against it (pythia8_hepmc3) if not self.options['hepmc3_path'] and misc.find_hepmc(3, [pjoin(prefix, 'hepmc3')]): self.options['hepmc3_path'] = pjoin(prefix, 'hepmc3') diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index c393c3c1a4..a1c2aa382e 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -2105,6 +2105,12 @@ def default_setup(self): always_write_to_card=False) self.add_param("InternalAnalysis:output", './djrs.dat', hidden=True, always_write_to_card=False) + # Not a Pythia8 setting: also split those jet rates per jet sample + # (number of matrix-element partons), building a patched copy of + # main164 when the installed one does not do it (see + # misc.patch_pythia8_main164_djrs). Off: the plots show their sum only. + self.add_param("DJRoutput:jetSamples", True, hidden=True, + always_write_to_card=False) # for MLM merging # -1.0 means that it will be set automatically by MadGraph7 self.add_param("JetMatching:qCut", -1.0, always_write_to_card=False) diff --git a/madgraph/various/misc.py b/madgraph/various/misc.py index 9ef94b8bb8..6358c6b66c 100755 --- a/madgraph/various/misc.py +++ b/madgraph/various/misc.py @@ -1875,8 +1875,117 @@ def get_pythia8_hepmc_flags(pythia8_path, hepmc_version, hepmc_paths=()): return '-I%s -L%s -Wl,-rpath,%s -l%s' % (pjoin(prefix, 'include'), libdir, libdir, 'HepMC3' if hepmc_version == 3 else 'HepMC') +# Version of the per-jet-sample DJR patch of main164 below. It is part of the +# build stamp, so that a change to the patch rebuilds the patched main164. +MAIN164_DJR_PATCH_VERSION = 1 + +def pythia8_main164_writes_jet_sample_djrs(source): + """Whether a main164.cc histograms the differential jet rates per jet + sample (number of matrix-element partons) by itself.""" + + return 'nMEpartons' in source + +def patch_pythia8_main164_djrs(source): + """The main164.cc of Pythia8 made to write, next to the summed DJR + histograms of its internal analysis, the same per jet sample in + blocks (the number of matrix-element partons of + the MLM matching, JetMatchingMadgraph::nMEpartons, as the former + MG5aMC_PY8_interface split them), with the bin uncertainties as a third + column and the '' of Pythia8 <= 8.317 fixed. + + Until a Pythia8 release does this itself, MadGraph7 builds this patched + copy for its MLM showers; Pythia8's own main164.cc is never modified. + Returns None when the source is not the main164.cc this was written + against (Pythia8 8.316 and 8.317): the stock main164 is then used, and the + DJR plots show the summed histograms only.""" + + edits = [ + (" CombineMatchingInput jetMatchingHook;\n" + " if (doJetMatching) jetMatchingHook.setHook(pythia);\n", + " // MadGraph7: the MadGraph-style matching hook knows the number of\n" + " // matrix-element partons of each event, i.e. its jet sample.\n" + " shared_ptr madgraphHook = doJetMatching\n" + " ? dynamic_pointer_cast(jetMatchingHook.hook)\n" + " : nullptr;\n"), + (" vector d01Hists, d12Hists, d23Hists, d34Hists;\n", + " // MadGraph7: the same per jet sample, per subrun.\n" + " vector< map > > sampleHists;\n"), + (' d34Hists.push_back(Hist("d34", 100., 0., 3.));\n', + " sampleHists.push_back(map >());\n"), + (" if (dij.size() > 3) d34Hists.back().fill(log10(dij[3]), w);\n", + " // MadGraph7: and in the histograms of the jet sample of the event.\n" + " int nSample = madgraphHook ? madgraphHook->nMEpartons().first : -1;\n" + " if (nSample >= 0) {\n" + " map >& samples = sampleHists.back();\n" + " if (samples.find(nSample) == samples.end())\n" + ' samples[nSample] = { Hist("d01", 100., 0., 3.),\n' + ' Hist("d12", 100., 0., 3.), Hist("d23", 100., 0., 3.),\n' + ' Hist("d34", 100., 0., 3.) };\n' + " for (int k = 0; k < 4 && k < int(dij.size()); ++k)\n" + " samples[nSample][k].fill(log10(dij[k]), w);\n" + " }\n"), + ] + for anchor, addition in edits: + if source.count(anchor) != 1: + return None + source = source.replace(anchor, anchor + addition) + + # the output of the internal analysis, one per subrun + start = ' output << "\\n";\n' + end = ' output << "\\n";\n' + if source.count(start) != 1 or source.count(end) != 1 or \ + source.index(start) > source.index(end): + return None + output = ( + ' output << "\\n";\n' + " // MadGraph7: lower bin edge, content and uncertainty; the summed\n" + " // histograms, then those of each jet sample.\n" + ' const string names[4] = {"log10d01", "log10d12", "log10d23",\n' + ' "log10d34"};\n' + " const Hist* totals[4] = {&d01Hists[i], &d12Hists[i], &d23Hists[i],\n" + " &d34Hists[i]};\n" + " for (int k = 0; k < 4; ++k) {\n" + ' output << "\\n";\n' + " totals[k]->table(output, false, false, true);\n" + ' output << "\\n";\n' + " }\n" + " for (auto& sample : sampleHists[i]) {\n" + ' output << "\\n";\n' + " for (int k = 0; k < 4; ++k) {\n" + ' output << "\\n";\n' + " sample.second[k].table(output, false, false, true);\n" + ' output << "\\n";\n' + " }\n" + ' output << "\\n";\n' + " }\n") + i, j = source.index(start), source.index(end) + source = source[:i] + output + source[j:] + return ('// Patched by MadGraph7 (version %d of the per-jet-sample DJR patch,\n' + '// see patch_pythia8_main164_djrs in madgraph/various/misc.py).\n' + % MAIN164_DJR_PATCH_VERSION) + source + +def _pythia8_main164_djr_source(examples): + """The patched main164.cc for per-jet-sample DJRs, or None when it is not + needed (the installed main164 does it) or not possible (an unknown + main164.cc), see patch_pythia8_main164_djrs.""" + + try: + source = open(pjoin(examples, 'main164.cc')).read() + except (IOError, OSError): + return None + if pythia8_main164_writes_jet_sample_djrs(source): + return None + patched = patch_pythia8_main164_djrs(source) + if patched is None: + logger.info('The main164 of this Pythia8 version cannot be patched to ' + + 'histogram the jet rates per jet sample: the DJR plots show ' + + 'their sum only.') + return patched + def get_pythia8_main164(pythia8_path, fallback_dir=None, hepmc_version=None, - hepmc_paths=()): + hepmc_paths=(), jet_sample_djrs=False): """Return the path of a compiled main164, compiling it if needed. main164 is built in the Pythia8 examples directory with the Makefile that @@ -1889,7 +1998,13 @@ def get_pythia8_main164(pythia8_path, fallback_dir=None, hepmc_version=None, with, a dedicated main164 is built against the HepMC installation found in hepmc_paths or next to Pythia8: in /main164_hepmc, so that it is shared by all processes, or in _hepmc - if the examples directory is read-only.""" + if the examples directory is read-only. + + With jet_sample_djrs, main164 histograms the jet rates of an MLM shower + per jet sample too: when the installed main164.cc does not, a patched copy + (patch_pythia8_main164_djrs) is built in the same way, in a directory + named with _djr appended. When the patch does not apply, the stock main164 + is returned.""" executable, examples = find_pythia8_main164(pythia8_path) if not examples: @@ -1903,6 +2018,9 @@ def get_pythia8_main164(pythia8_path, fallback_dir=None, hepmc_version=None, '%s. MadGraph7 showers LO events with main164, an example program of ' % pythia8_path + "Pythia8 that exists since Pythia 8.311 (it can be installed with 'install pythia8').") + source = _pythia8_main164_djr_source(examples) if jet_sample_djrs else None + + hepmc = None if hepmc_version and hepmc_version != pythia8_hepmc_version(examples): if not os.path.isfile(pjoin(examples, 'main164.cc')): raise MadGraph5Error('main164.cc cannot be found in %s, so that ' % examples + @@ -1915,9 +2033,14 @@ def get_pythia8_main164(pythia8_path, fallback_dir=None, hepmc_version=None, 'no HepMC%d installation was found (it can be installed with ' % hepmc_version + "'install %s'), and Pythia8 was configured with another version." % default) hepmc = (hepmc_version,) + hepmc + + if hepmc or source: + suffix = ('_hepmc%d' % hepmc[0] if hepmc else '') + ('_djr' if source else '') stamp = _pythia8_main164_stamp(pythia8_path, examples, hepmc) - shared_dir = pjoin(examples, 'main164_hepmc%d' % hepmc_version) - local_dir = '%s_hepmc%d' % (fallback_dir, hepmc_version) if fallback_dir else None + if source: + stamp += '\ndjr patch %d' % MAIN164_DJR_PATCH_VERSION + shared_dir = pjoin(examples, 'main164' + suffix) + local_dir = fallback_dir + suffix if fallback_dir else None for build_dir in (shared_dir, local_dir): if build_dir and _pythia8_main164_is_built(build_dir, stamp): return pjoin(build_dir, 'main164') @@ -1927,9 +2050,10 @@ def get_pythia8_main164(pythia8_path, fallback_dir=None, hepmc_version=None, build_dir = local_dir else: raise MadGraph5Error('No writable directory to compile main164 ' + - 'for HepMC%d.' % hepmc_version) + (('for HepMC%d.' % hepmc[0]) if hepmc else 'with per-jet-sample jet rates.')) return _compile_pythia8_main164(examples, build_dir, stamp, - _pythia8_main164_makefile_inc(examples, hepmc)) + _pythia8_main164_makefile_inc(examples, hepmc), + source=source) if executable: return executable @@ -2027,10 +2151,12 @@ def _pythia8_main164_is_built(build_dir, stamp): return os.path.isfile(pjoin(build_dir, 'main164')) and \ os.path.isfile(stamp_file) and open(stamp_file).read() == stamp -def _compile_pythia8_main164(examples, build_dir, stamp=None, makefile_inc=None): +def _compile_pythia8_main164(examples, build_dir, stamp=None, makefile_inc=None, + source=None): """Compile main164 in build_dir and return its path. If build_dir is not the - Pythia8 examples directory, main164.cc and the Makefile are copied there, - with makefile_inc as Makefile.inc, and the build is stamped.""" + Pythia8 examples directory, main164.cc (or the given source in its place) + and the Makefile are copied there, with makefile_inc as Makefile.inc, and + the build is stamped.""" executable = pjoin(build_dir, 'main164') stamp_file = pjoin(build_dir, 'BUILD_STAMP') @@ -2042,6 +2168,9 @@ def _compile_pythia8_main164(examples, build_dir, stamp=None, makefile_inc=None) os.makedirs(build_dir) for name in ('main164.cc', 'Makefile'): shutil.copy(pjoin(examples, name), build_dir) + if source is not None: + with open(pjoin(build_dir, 'main164.cc'), 'w') as fsock: + fsock.write(source) with open(pjoin(build_dir, 'Makefile.inc'), 'w') as fsock: fsock.write(makefile_inc) diff --git a/tests/unit_tests/various/test_misc.py b/tests/unit_tests/various/test_misc.py index 24bb1445db..24a8d01f89 100755 --- a/tests/unit_tests/various/test_misc.py +++ b/tests/unit_tests/various/test_misc.py @@ -274,9 +274,21 @@ def tearDown(self): os.chmod(self.examples, 0o755) shutil.rmtree(self.tmpdir) - def write_sources(self, mainMG=False, hepmc=2): + # the lines of Pythia8's main164.cc (8.316, 8.317) that the per-jet-sample + # DJR patch anchors on + main164_djr_anchors = ( + ' CombineMatchingInput jetMatchingHook;\n' + ' if (doJetMatching) jetMatchingHook.setHook(pythia);\n' + ' vector d01Hists, d12Hists, d23Hists, d34Hists;\n' + ' d34Hists.push_back(Hist("d34", 100., 0., 3.));\n' + ' if (dij.size() > 3) d34Hists.back().fill(log10(dij[3]), w);\n' + ' output << "\\n";\n' + ' d34Hists[i].table(output, false, false);\n' + ' output << "\\n";\n') + + def write_sources(self, mainMG=False, hepmc=2, source=None): with open(os.path.join(self.examples, 'main164.cc'), 'w') as fsock: - fsock.write('#!/bin/sh\necho main164\n') + fsock.write(source if source is not None else '#!/bin/sh\necho main164\n') rule = "mainMG: main164.cc\n\techo mainMG > main164; chmod +x main164\n" \ if mainMG else '' with open(os.path.join(self.examples, 'Makefile'), 'w') as fsock: @@ -428,6 +440,61 @@ def test_find_hepmc(self): # a HepMC3 installation is not a HepMC2 one self.assertEqual(misc.find_hepmc(2, [prefix]), None) + def test_main164_djr_patch(self): + """the per-jet-sample split, the bin errors and the '', patched) + self.assertIn('madgraphHook->nMEpartons().first', patched) + self.assertIn('', patched) + self.assertTrue(misc.pythia8_main164_writes_jet_sample_djrs(patched)) + self.assertFalse(misc.pythia8_main164_writes_jet_sample_djrs( + self.main164_djr_anchors)) + self.assertIsNone(misc.patch_pythia8_main164_djrs('int main() {}\n')) + + def test_jet_sample_djrs_build_a_patched_main164(self): + self.write_sources(source=self.main164_djr_anchors) + executable = misc.get_pythia8_main164(self.py8, jet_sample_djrs=True) + shared = os.path.join(self.examples, 'main164_djr') + self.assertEqual(executable, os.path.join(shared, 'main164')) + # the stub make copies the source: it is the patched one + self.assertIn('nMEpartons', open(executable).read()) + # Pythia8's own main164.cc is never modified + self.assertEqual(open(os.path.join(self.examples, 'main164.cc')).read(), + self.main164_djr_anchors) + # built once + os.remove(os.path.join(shared, 'main164.cc')) + self.assertEqual(misc.get_pythia8_main164(self.py8, jet_sample_djrs=True), + executable) + # the default one is not affected + self.assertEqual(misc.get_pythia8_main164(self.py8), + os.path.join(self.examples, 'main164')) + + def test_jet_sample_djrs_not_patched_when_not_needed_or_possible(self): + # a main164 that splits per jet sample by itself + self.write_sources(source=self.main164_djr_anchors + + '// madgraphHook->nMEpartons()\n') + self.assertEqual(misc.get_pythia8_main164(self.py8, jet_sample_djrs=True), + os.path.join(self.examples, 'main164')) + # a main164.cc the patch does not know: the stock one + os.remove(os.path.join(self.examples, 'main164')) + self.write_sources() + self.assertEqual(misc.get_pythia8_main164(self.py8, jet_sample_djrs=True), + os.path.join(self.examples, 'main164')) + self.assertFalse(os.path.exists(os.path.join(self.examples, 'main164_djr'))) + + def test_jet_sample_djrs_with_another_hepmc_version(self): + self.write_sources(hepmc=2, source=self.main164_djr_anchors) + prefix = self.make_hepmc(3) + executable = misc.get_pythia8_main164(self.py8, hepmc_version=3, + hepmc_paths=[prefix], jet_sample_djrs=True) + self.assertEqual(executable, os.path.join(self.examples, + 'main164_hepmc3_djr', 'main164')) + self.assertIn('nMEpartons', open(executable).read()) + def test_same_hepmc_version_uses_the_default_main164(self): self.write_sources(hepmc=2) executable = misc.get_pythia8_main164(self.py8, hepmc_version=2) From 95c77cf7941eab71d84845715aaccc38eb31aad5 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 29 Oct 2026 09:03:57 +0100 Subject: [PATCH 79/81] MLM clustering measure: numerically stable for nearly collinear pairs The kt measure min(pt1^2, pt2^2) * 2 * (cosh(deta) - cos(dphi)) / D^2 of cluster.f subtracts two numbers close to one for a nearly collinear pair and lost up to eight digits there (1.2e-8 relative on the 128 events of test_kt_measure_matches_the_fortran, 2.2e-6 on 20000 g g > g g g events). Scalar libm and SIMD SLEEF round cosh/cos differently in the last bit, so the two gave different scales: test_kt_measure_matches_the_fortran failed in the simd_256/avx512y/simd_512 wheel tests (1.5e-9 > 1e-9). It is now evaluated as 4 * (sinh^2(deta/2) + sin^2(dphi/2)), with eta = asinh(pz/pt) (no cancellation for a forward parton, unlike log((|p| + pz)/(|p| - pz))) and dphi = atan2(cross, dot); the Durham measure 2 min(E1^2, E2^2) (1 - cos_theta) likewise as 4 min(E^2) sin^2(theta/2) with theta = atan2(|p1 x p2|, p1.p2). Identical to cluster.f in exact arithmetic. - On identical momenta, scalar and NEON (simd_128) now give bit-identical scales (200000 values). - Scalar: 539 of 200000 values move by more than 1e-9; every one that could be checked against a 60-digit evaluation (97) moved towards it: old error up to 2.2e-6, new at most 1.2e-12. - test_mlm: the Python reference uses the same form; test_a_nearly_collinear_pair_keeps_its_clustering_scale (kt and Durham, pairs 1e-5 apart) fails with the old formula in any mode (1e-6 off). - madspace suite: 1995 passed in scalar and in simd_128. Co-Authored-By: Claude --- madspace/src/kernels/mlm.hpp | 45 ++++++++++++------ madspace/tests/test_mlm.py | 91 ++++++++++++++++++++++++++++++++---- 2 files changed, 114 insertions(+), 22 deletions(-) diff --git a/madspace/src/kernels/mlm.hpp b/madspace/src/kernels/mlm.hpp index 5b40c5272e..9e50a6c31f 100644 --- a/madspace/src/kernels/mlm.hpp +++ b/madspace/src/kernels/mlm.hpp @@ -88,14 +88,24 @@ KERNELSPEC FVal dj_clus( FVal jet_radius ) { if (!hadronic) { - // Durham e+e- measure: 2*min(E1^2, E2^2)*(1 - cos_theta) + // Durham e+e- measure: 2*min(E1^2, E2^2)*(1 - cos_theta), evaluated as + // 4*min(E1^2, E2^2)*sin^2(theta/2) with theta = atan2(|p1 x p2|, + // p1.p2). For a nearly collinear pair 1 - cos_theta is the difference + // of two numbers close to one and loses most of its digits. auto p1a = sqrt(esquare(p1)); auto p2a = sqrt(esquare(p2)); if (p1a * p2a == 0.0) { return 0.0; } - auto costh = edot(p1, p2) / (p1a * p2a); - return 2.0 * min(p1[0] * p1[0], p2[0] * p2[0]) * max(1.0 - costh, 0.0); + auto cross_x = p1[2] * p2[3] - p1[3] * p2[2]; + auto cross_y = p1[3] * p2[1] - p1[1] * p2[3]; + auto cross_z = p1[1] * p2[2] - p1[2] * p2[1]; + auto theta = atan2( + sqrt(cross_x * cross_x + cross_y * cross_y + cross_z * cross_z), + edot(p1, p2) + ); + auto sin_half = sin(0.5 * theta); + return 4.0 * min(p1[0] * p1[0], p2[0] * p2[0]) * sin_half * sin_half; } // hadronic: massless+massive pair clusters to the lighter parton's mT^2 @@ -108,25 +118,32 @@ KERNELSPEC FVal dj_clus( return djb_clus(p2, true) * ONE_PLUS_TINY; } - // both massless or both massive: generalised kt measure in (eta, phi) + // both massless or both massive: generalised kt measure in (eta, phi), + // min(pt1^2, pt2^2) * 2 * (cosh(deta) - cos(dphi)) / D^2 as in cluster.f, + // evaluated as 4 * (sinh^2(deta/2) + sin^2(dphi/2)), with dphi from + // atan2(cross, dot) and eta = asinh(pz/pt). For a nearly collinear pair + // cosh(deta) and cos(dphi) are both close to one, and their difference + // lost up to eight digits, so that scalar and SIMD math (and the GPU) gave + // different scales; log((|p| + pz)/(|p| - pz)) also cancels for a forward + // parton. auto pt1_sq = p1[1] * p1[1] + p1[2] * p1[2]; auto pt2_sq = p2[1] * p2[1] + p2[2] * p2[2]; if (pt1_sq == 0.0 || pt2_sq == 0.0) { return 0.0; } - auto p1a = sqrt(pt1_sq + p1[3] * p1[3]); - auto p2a = sqrt(pt2_sq + p2[3] * p2[3]); - auto eta1 = 0.5 * log((p1a + p1[3]) / (p1a - p1[3])); - auto eta2 = 0.5 * log((p2a + p2[3]) / (p2a - p2[3])); + auto deta = asinh(p1[3] / sqrt(pt1_sq)) - asinh(p2[3] / sqrt(pt2_sq)); + auto dphi = + atan2(p1[1] * p2[2] - p1[2] * p2[1], p1[1] * p2[1] + p1[2] * p2[2]); + auto sinh_half = sinh(0.5 * deta); + auto sin_half = sin(0.5 * dphi); auto m_max_sq = max(mass1 * mass1, mass2 * mass2); - auto dphi_cos = (p1[1] * p2[1] + p1[2] * p2[2]) / sqrt(pt1_sq * pt2_sq); auto result = m_max_sq + - min(pt1_sq, pt2_sq) * 2.0 * (cosh(eta1 - eta2) - dphi_cos) / + min(pt1_sq, pt2_sq) * 4.0 * + (sinh_half * sinh_half + sin_half * sin_half) / (jet_radius * jet_radius); - // An exactly collinear pair makes eta1 or eta2 infinite and their - // difference NaN. The Fortran zeroes both negative and NaN results here - // ("prevent numerical inaccuracies"); max() alone would let the NaN - // through. + // The Fortran zeroes both negative and NaN results here ("prevent + // numerical inaccuracies"); max() alone would let a NaN from extreme + // inputs through. if (!(result > 0.0)) { return 0.0; } diff --git a/madspace/tests/test_mlm.py b/madspace/tests/test_mlm.py index f49ae476c6..2e1f2fb366 100644 --- a/madspace/tests/test_mlm.py +++ b/madspace/tests/test_mlm.py @@ -718,27 +718,102 @@ def djb_clus_reference(p, hadronic=True): def dj_clus_reference(p1, p2, mass1, mass2, jet_radius): - """The both-massless / both-massive branch of dj_clus.""" + """The both-massless / both-massive branch of dj_clus: + min(pt1^2, pt2^2) * 2 * (cosh(deta) - cos(dphi)) / D^2, in the form the + kernel evaluates it, 4 * (sinh^2(deta/2) + sin^2(dphi/2)), which keeps its + digits for a nearly collinear pair.""" pt1_sq = p1[1] ** 2 + p1[2] ** 2 pt2_sq = p2[1] ** 2 + p2[2] ** 2 if pt1_sq == 0.0 or pt2_sq == 0.0: return 0.0 - p1a = np.sqrt(pt1_sq + p1[3] ** 2) - p2a = np.sqrt(pt2_sq + p2[3] ** 2) - eta1 = 0.5 * np.log((p1a + p1[3]) / (p1a - p1[3])) - eta2 = 0.5 * np.log((p2a + p2[3]) / (p2a - p2[3])) + deta = np.arcsinh(p1[3] / np.sqrt(pt1_sq)) - np.arcsinh(p2[3] / np.sqrt(pt2_sq)) + dphi = np.arctan2(p1[1] * p2[2] - p1[2] * p2[1], p1[1] * p2[1] + p1[2] * p2[2]) m_max_sq = max(mass1**2, mass2**2) - dphi_cos = (p1[1] * p2[1] + p1[2] * p2[2]) / np.sqrt(pt1_sq * pt2_sq) return max( m_max_sq + min(pt1_sq, pt2_sq) - * 2.0 - * (np.cosh(eta1 - eta2) - dphi_cos) + * 4.0 + * (np.sinh(deta / 2) ** 2 + np.sin(dphi / 2) ** 2) / jet_radius**2, 0.0, ) +def durham_reference(p1, p2): + """The lepton-collider branch of dj_clus, 2 * min(E1^2, E2^2) * + (1 - cos_theta), as 4 * min(E1^2, E2^2) * sin^2(theta/2).""" + cross = np.cross(p1[1:], p2[1:]) + theta = np.arctan2(np.linalg.norm(cross), np.dot(p1[1:], p2[1:])) + return 4.0 * min(p1[0] ** 2, p2[0] ** 2) * np.sin(theta / 2) ** 2 + + +def massless(px, py, pz): + return np.array([np.sqrt(px * px + py * py + pz * pz), px, py, pz]) + + +def collinear_pair_event(hadronic): + """Two final-state partons about 1e-5 apart, legs 2 and 4 (e+ e- > d d~ g) + or 2 and 3 (g g > g g g), with the third one recoiling and the beams along + z: momentum is conserved. For such a pair cosh(deta) - cos(dphi), or + 1 - cos_theta, is of order 1e-10.""" + def pt_eta_phi(pt, eta, phi): + return massless(pt * np.cos(phi), pt * np.sin(phi), pt * np.sinh(eta)) + a = pt_eta_phi(300.0, 2.5, 0.7) + b = pt_eta_phi(250.0, 2.5 + 8e-6, 0.7 + 6e-6) + if hadronic: + c = massless(-(a[1] + b[1]), -(a[2] + b[2]), -400.0) + final = [a, b, c] + else: + c = massless(-(a[1] + b[1]), -(a[2] + b[2]), -(a[3] + b[3])) + final = [a, c, b] + energy = sum(p[0] for p in final) + pz = sum(p[3] for p in final) + beam1 = np.array([(energy + pz) / 2, 0.0, 0.0, (energy + pz) / 2]) + beam2 = np.array([(energy - pz) / 2, 0.0, 0.0, -(energy - pz) / 2]) + return np.array([[beam1, beam2] + final]) + + +@pytest.mark.parametrize("hadronic", [True, False], ids=["kt", "durham"]) +def test_a_nearly_collinear_pair_keeps_its_clustering_scale(hadronic): + """The clustering scale of two partons 1e-5 apart. Written as in cluster.f, + cosh(deta) - cos(dphi) (or 1 - cos_theta) is the difference of two numbers + within 1e-10 of one, which left about 1e-6 of relative error, enough for + scalar and SIMD math to give different scales and histories. The kernel's + form of the measure has none of that cancellation.""" + jet_radius = 0.4 + if hadronic: + pdg_ids = [21, 21, 21, 21, 21] + vertices = [["o0", "o1", "p0"], ["i0", "o2", "p1"], ["p1", "p0", "i1"]] + legs = (2, 3) + else: + pdg_ids = [-11, 11, 1, -1, 21] + vertices = [["o0", "o2", "p0"], ["p0", "o1", "p1"], ["i0", "p1", "i1"]] + legs = (2, 4) + diagrams = [{ + "incoming_masses": [0.0, 0.0], + "outgoing_masses": [0.0, 0.0, 0.0], + "propagators": [[0.0, 0.0], [0.0, 0.0]], + "vertices": vertices, + "permutations": [[0, 1, 2, 3, 4]], + }] + clustering = make_clustering( + diagrams, external_pdg_ids=pdg_ids, jet_radius=jet_radius, + hadronic=hadronic, clustering_mean_pdf_ladder=False, + jet_scale_scheme=ms.MLMClustering.JetScaleScheme.emission, + ) + event = collinear_pair_event(hadronic) + _, _, _, jets, _ = run(clustering, event) + p1, p2 = event[0][legs[0]], event[0][legs[1]] + if hadronic: + expected = np.sqrt(dj_clus_reference(p1, p2, 0.0, 0.0, jet_radius)) + else: + expected = np.sqrt(durham_reference(p1, p2)) + assert 1e-4 < expected < 1e-1, "not the collinear configuration intended" + # the softest clustering is that pair: both its legs carry its scale + for leg in legs: + assert jets[0][leg - 2] == pytest.approx(expected, rel=1e-9) + + def first_step_candidates(clustering, n_ext, event, jet_radius, masses): """The clustering measures the kernel evaluates in its first step. From ff788295d69d3243d7f3778868469559aa537237 Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 29 Oct 2026 09:19:57 +0100 Subject: [PATCH 80/81] main164 jet samples: follow the change proposed to Pythia8 The interim patch of main164 now applies exactly the change prepared for Pythia8 (commit 89d24fa4 of a local clone of Pythia8 8.318, to be proposed upstream): the setting InternalAnalysis:jetSamples (default off) writes the DJR histograms per jet sample in blocks, two columns, with the jet sample tagged for kt-MLM (nMEpartons().first), shower-kT (getProcessSubset().size()), FxFx (npNLO()) and merging (Merging:nRequested - Merging:nRecluster), and the quote fixed. On 8.318 the patched file is identical to that commit apart from the two-line MadGraph7 header; it also applies to 8.316 and 8.317. - PY8Card: InternalAnalysis:jetSamples (hidden, default off) replaces the MadGraph7-only DJRoutput:jetSamples. An MLM run sets it on (a user setting wins); a main164 that already knows it is used as is, one that does not is patched, and an unpatchable one does not get the setting in its command file (Pythia8 would refuse it). - HwUList.from_pythia8_main164 reads blocks (and still the ones of the first interim patch). - Tests follow; one checks the patch against the installed Pythia8. End to end: p p > e+ ve + 0,1j, output mg7, launch with shower=pythia8 only: main164_djr rebuilt, InternalAnalysis:jetSamples=on in the command file, per-sample DJR plots. Co-Authored-By: Claude --- madgraph/interface/madevent_interface.py | 22 ++- madgraph/various/banner.py | 10 +- madgraph/various/histograms.py | 11 +- madgraph/various/misc.py | 141 +++++++++++--------- tests/unit_tests/various/test_histograms.py | 19 +++ tests/unit_tests/various/test_misc.py | 39 ++++-- 6 files changed, 154 insertions(+), 88 deletions(-) diff --git a/madgraph/interface/madevent_interface.py b/madgraph/interface/madevent_interface.py index 131f3b5b2a..3d119549aa 100755 --- a/madgraph/interface/madevent_interface.py +++ b/madgraph/interface/madevent_interface.py @@ -4500,19 +4500,24 @@ def get_pythia8_main164(self, PY8_Card): hepmc_version = self.get_auto_hepmc_version() # The jet rates of an MLM shower (Main:InternalAnalysis) split per jet - # sample, for the DJR plots: a main164 that does not do it is patched - # (misc.patch_pythia8_main164_djrs), unless DJRoutput:jetSamples is off. - # Not a Pythia8 setting either. - PY8_Card.vetoParamWriteOut('DJRoutput:jetSamples') + # sample (InternalAnalysis:jetSamples), for the DJR plots: a main164 + # that cannot do it is patched (misc.patch_pythia8_main164_djrs). jet_sample_djrs = bool(PY8_Card['Main:InternalAnalysis']) and \ - bool(PY8_Card['DJRoutput:jetSamples']) + bool(PY8_Card['InternalAnalysis:jetSamples']) + executable = None if jet_sample_djrs: try: - return self._get_pythia8_main164(hepmc_format, hepmc_version, True) + executable = self._get_pythia8_main164(hepmc_format, + hepmc_version, True) except self.InvalidCmd as error: logger.warning('main164 could not be built to split the jet rates ' + 'per jet sample, the DJR plots show their sum only:\n%s' % error) - return self._get_pythia8_main164(hepmc_format, hepmc_version, False) + if executable is None: + executable = self._get_pythia8_main164(hepmc_format, hepmc_version, False) + # a main164 that does not know the setting would refuse it + if not misc.pythia8_main164_knows_jet_samples(executable): + PY8_Card.vetoParamWriteOut('InternalAnalysis:jetSamples') + return executable def _get_pythia8_main164(self, hepmc_format, hepmc_version, jet_sample_djrs): """get_pythia8_main164 for a given HepMC version, falling back to the @@ -4686,6 +4691,9 @@ def setup_Pythia8RunAndCard(self, PY8_Card, run_type): # the DJR plots of the run are made (create_plot) if 'main:internalanalysis' not in PY8_Card.user_set: PY8_Card.MadGraphSet('Main:InternalAnalysis', True) + # and per jet sample (the 0/1/2-jet contributions) + if 'internalanalysis:jetsamples' not in PY8_Card.user_set: + PY8_Card.MadGraphSet('InternalAnalysis:jetSamples', True) if PY8_Card['Main:InternalAnalysis'] and \ 'internalanalysis:output' not in PY8_Card.user_set: PY8_Card.MadGraphSet('InternalAnalysis:output', './djrs.dat') diff --git a/madgraph/various/banner.py b/madgraph/various/banner.py index a1c2aa382e..eaa49f8703 100755 --- a/madgraph/various/banner.py +++ b/madgraph/various/banner.py @@ -2105,11 +2105,11 @@ def default_setup(self): always_write_to_card=False) self.add_param("InternalAnalysis:output", './djrs.dat', hidden=True, always_write_to_card=False) - # Not a Pythia8 setting: also split those jet rates per jet sample - # (number of matrix-element partons), building a patched copy of - # main164 when the installed one does not do it (see - # misc.patch_pythia8_main164_djrs). Off: the plots show their sum only. - self.add_param("DJRoutput:jetSamples", True, hidden=True, + # main164's setting to also write those jet rates per jet sample + # (number of matrix-element partons), on for an MLM sample. When the + # installed main164 does not know it, MadGraph7 builds a patched copy + # (misc.patch_pythia8_main164_djrs); off, the plots show their sum. + self.add_param("InternalAnalysis:jetSamples", False, hidden=True, always_write_to_card=False) # for MLM merging # -1.0 means that it will be set automatically by MadGraph7 diff --git a/madgraph/various/histograms.py b/madgraph/various/histograms.py index bc6cdaa858..bad7f3c354 100755 --- a/madgraph/various/histograms.py +++ b/madgraph/various/histograms.py @@ -3207,8 +3207,9 @@ def from_pythia8_main164(file_path, scale=1.0): histograms log10d01 ... log10d34 of the whole sample, one row per bin with its lower edge and weighted content (and, from a main164 that prints it, its uncertainty), and possibly the same per jet sample - (number of matrix-element partons) in blocks. - Returns the histograms of the first run as a HwUList: the per-sample + (number of matrix-element partons) in blocks + (InternalAnalysis:jetSamples). Returns the histograms of the first run + as a HwUList: the per-sample ones, titled |JETSAMPLE@n as the plots expect, when the file has them, the whole-sample ones otherwise; None when it holds none, or when it is the former MG5aMC_PY8_interface format (whose carries a weight @@ -3243,11 +3244,13 @@ def close(): return None if line.startswith(': main164 with InternalAnalysis:jetSamples; + # : the first MadGraph7 patch of main164 + block = re.match(r'<(?:jetsample|jethistograms)\s+njet="(-?\d+)"', line) if block: sample = int(block.group(1)) continue - if line.startswith(' blocks (the number of matrix-element partons of - the MLM matching, JetMatchingMadgraph::nMEpartons, as the former - MG5aMC_PY8_interface split them), with the bin uncertainties as a third - column and the '' of Pythia8 <= 8.317 fixed. - - Until a Pythia8 release does this itself, MadGraph7 builds this patched - copy for its MLM showers; Pythia8's own main164.cc is never modified. - Returns None when the source is not the main164.cc this was written - against (Pythia8 8.316 and 8.317): the stock main164 is then used, and the - DJR plots show the summed histograms only.""" + """The main164.cc of Pythia8 with the change proposed to Pythia8: the + setting InternalAnalysis:jetSamples (default off) also writes the + differential jet rate histograms of its internal analysis per jet sample, + in blocks after the totals of each , and the + '' of Pythia8 <= 8.318 is fixed. The jet sample is the + number of matrix-element partons of MLM matching (nMEpartons().first, + getProcessSubset().size() for shower-kT, npNLO() for FxFx), or + Merging:nRequested less Merging:nRecluster for merging. + + Until a Pythia8 release has it, MadGraph7 builds this patched copy for + its MLM showers; Pythia8's own main164.cc is never modified. Returns None + when the source is not the main164.cc this was written against (Pythia8 + 8.316 to 8.318): the stock main164 is then used, and the DJR plots show + the summed histograms only.""" edits = [ - (" CombineMatchingInput jetMatchingHook;\n" - " if (doJetMatching) jetMatchingHook.setHook(pythia);\n", - " // MadGraph7: the MadGraph-style matching hook knows the number of\n" - " // matrix-element partons of each event, i.e. its jet sample.\n" - " shared_ptr madgraphHook = doJetMatching\n" - " ? dynamic_pointer_cast(jetMatchingHook.hook)\n" - " : nullptr;\n"), + (' pythia.settings.addWord("InternalAnalysis:output", "analysis.dat");\n', + ' // Optionally also write the histograms of each jet sample separately.\n' + ' pythia.settings.addFlag("InternalAnalysis:jetSamples", false);\n'), (" vector d01Hists, d12Hists, d23Hists, d34Hists;\n", - " // MadGraph7: the same per jet sample, per subrun.\n" - " vector< map > > sampleHists;\n"), + " // Optionally also for each jet sample, as known by merging or by\n" + " // MadGraph-style (not Alpgen-style) jet matching.\n" + ' bool doJetSamples = pythia.flag("InternalAnalysis:jetSamples");\n' + " shared_ptr jetMatchingPtr\n" + " = dynamic_pointer_cast(jetMatchingHook.hook);\n" + " vector > > jetSampleHists;\n"), (' d34Hists.push_back(Hist("d34", 100., 0., 3.));\n', - " sampleHists.push_back(map >());\n"), + " jetSampleHists.push_back(map >());\n"), (" if (dij.size() > 3) d34Hists.back().fill(log10(dij[3]), w);\n", - " // MadGraph7: and in the histograms of the jet sample of the event.\n" - " int nSample = madgraphHook ? madgraphHook->nMEpartons().first : -1;\n" - " if (nSample >= 0) {\n" - " map >& samples = sampleHists.back();\n" - " if (samples.find(nSample) == samples.end())\n" - ' samples[nSample] = { Hist("d01", 100., 0., 3.),\n' + " // Optionally also for the jet sample of the event: for jet matching\n" + " // the number of matrix-element partons (only those to be matched\n" + " // for shower-kT, those of the Born for FxFx), for merging the\n" + " // requested number of jets less the reclustered ones.\n" + " int nJetSample = -1;\n" + " if (doJetSamples && jetMatchingPtr) {\n" + ' if (pythia.flag("JetMatching:doFxFx"))\n' + " nJetSample = jetMatchingPtr->npNLO();\n" + ' else if (pythia.flag("JetMatching:doShowerKt"))\n' + " nJetSample = jetMatchingPtr->getProcessSubset().size();\n" + " else\n" + " nJetSample = jetMatchingPtr->nMEpartons().first;\n" + " } else if (doJetSamples && doMerging) {\n" + ' nJetSample = pythia.mode("Merging:nRequested")\n' + ' - pythia.mode("Merging:nRecluster");\n' + " }\n" + " if (nJetSample >= 0) {\n" + " vector& sampleHists = jetSampleHists.back()[nJetSample];\n" + " if (sampleHists.empty())\n" + ' sampleHists = { Hist("d01", 100., 0., 3.),\n' ' Hist("d12", 100., 0., 3.), Hist("d23", 100., 0., 3.),\n' ' Hist("d34", 100., 0., 3.) };\n' - " for (int k = 0; k < 4 && k < int(dij.size()); ++k)\n" - " samples[nSample][k].fill(log10(dij[k]), w);\n" + " for (int iHist = 0; iHist < 4 && iHist < int(dij.size()); ++iHist)\n" + " sampleHists[iHist].fill(log10(dij[iHist]), w);\n" " }\n"), ] for anchor, addition in edits: @@ -1930,38 +1957,26 @@ def patch_pythia8_main164_djrs(source): return None source = source.replace(anchor, anchor + addition) - # the output of the internal analysis, one per subrun - start = ' output << "\\n";\n' - end = ' output << "\\n";\n' - if source.count(start) != 1 or source.count(end) != 1 or \ - source.index(start) > source.index(end): + run_header = ' output << "\\n";\n' + run_end = ' output << "\\n";\n' + if source.count(run_header) != 1 or source.count(run_end) != 1: return None - output = ( - ' output << "\\n";\n' - " // MadGraph7: lower bin edge, content and uncertainty; the summed\n" - " // histograms, then those of each jet sample.\n" - ' const string names[4] = {"log10d01", "log10d12", "log10d23",\n' - ' "log10d34"};\n' - " const Hist* totals[4] = {&d01Hists[i], &d12Hists[i], &d23Hists[i],\n" - " &d34Hists[i]};\n" - " for (int k = 0; k < 4; ++k) {\n" - ' output << "\\n";\n' - " totals[k]->table(output, false, false, true);\n" - ' output << "\\n";\n' - " }\n" - " for (auto& sample : sampleHists[i]) {\n" - ' output << "\\n";\n' - " for (int k = 0; k < 4; ++k) {\n" - ' output << "\\n";\n' - " sample.second[k].table(output, false, false, true);\n" + source = source.replace(run_header, + ' output << "\\n";\n') + source = source.replace(run_end, + " // Optionally print the histograms of each jet sample.\n" + " for (const auto& jetSample : jetSampleHists[i]) {\n" + ' output << "\\n";\n' + " for (const Hist& hist : jetSample.second) {\n" + ' output << "\\n";\n' + " hist.table(output, false, false);\n" ' output << "\\n";\n' " }\n" - ' output << "\\n";\n' - " }\n") - i, j = source.index(start), source.index(end) - source = source[:i] + output + source[j:] + ' output << "\\n";\n' + " }\n" + "\n" + run_end) return ('// Patched by MadGraph7 (version %d of the per-jet-sample DJR patch,\n' '// see patch_pythia8_main164_djrs in madgraph/various/misc.py).\n' % MAIN164_DJR_PATCH_VERSION) + source diff --git a/tests/unit_tests/various/test_histograms.py b/tests/unit_tests/various/test_histograms.py index aea1205623..915f8c7738 100755 --- a/tests/unit_tests/various/test_histograms.py +++ b/tests/unit_tests/various/test_histograms.py @@ -957,6 +957,25 @@ def test_jet_samples_and_errors(self): self.assertEqual([b.wgts['central'] for b in histos[1].bins], [0.0, 2000.0]) self.assertEqual([b.wgts['stat_error'] for b in histos[1].bins], [0.0, 100.0]) + def test_jetsample_blocks_of_pythia8(self): + """main164 with InternalAnalysis:jetSamples: + blocks after the totals, two columns""" + text = ('\n\n' + '\n' + ' 0.0 3.0e-06\n 1.0 2.0e-06\n\n' + '\n' + '\n' + ' 0.0 3.0e-06\n 1.0 0.0\n\n' + '\n\n' + '\n' + ' 0.0 0.0\n 1.0 2.0e-06\n\n' + '\n\n\n') + histos = histograms.HwUList.from_pythia8_main164(self.write(text), scale=1e9) + self.assertEqual([(h.title, h.jetsample) for h in histos], + [('log10d01 []', 0), ('log10d01 []', 1)]) + self.assertEqual([b.wgts['central'] for b in histos[0].bins], [3000.0, 0.0]) + self.assertTrue(all(b.wgts['stat_error'] == 0.0 for b in histos[1].bins)) + def test_a_file_without_histograms(self): self.assertIsNone(histograms.HwUList.from_pythia8_main164( self.write('\n\n'))) diff --git a/tests/unit_tests/various/test_misc.py b/tests/unit_tests/various/test_misc.py index 24a8d01f89..85a34e6479 100755 --- a/tests/unit_tests/various/test_misc.py +++ b/tests/unit_tests/various/test_misc.py @@ -274,11 +274,10 @@ def tearDown(self): os.chmod(self.examples, 0o755) shutil.rmtree(self.tmpdir) - # the lines of Pythia8's main164.cc (8.316, 8.317) that the per-jet-sample - # DJR patch anchors on + # the lines of Pythia8's main164.cc (8.316 to 8.318) that the + # per-jet-sample DJR patch anchors on main164_djr_anchors = ( - ' CombineMatchingInput jetMatchingHook;\n' - ' if (doJetMatching) jetMatchingHook.setHook(pythia);\n' + ' pythia.settings.addWord("InternalAnalysis:output", "analysis.dat");\n' ' vector d01Hists, d12Hists, d23Hists, d34Hists;\n' ' d34Hists.push_back(Hist("d34", 100., 0., 3.));\n' ' if (dij.size() > 3) d34Hists.back().fill(log10(dij[3]), w);\n' @@ -445,10 +444,12 @@ def test_main164_djr_patch(self): patched in; an unknown source is left alone""" patched = misc.patch_pythia8_main164_djrs(self.main164_djr_anchors) self.assertIsNotNone(patched) + self.assertIn('pythia.settings.addFlag("InternalAnalysis:jetSamples", false);', + patched) self.assertIn('dynamic_pointer_cast', patched) - self.assertIn('madgraphHook->nMEpartons().first', patched) - self.assertIn('nMEpartons().first', patched) + self.assertIn('pythia.mode("Merging:nRecluster")', patched) + self.assertIn('', patched) self.assertTrue(misc.pythia8_main164_writes_jet_sample_djrs(patched)) self.assertFalse(misc.pythia8_main164_writes_jet_sample_djrs( @@ -460,8 +461,12 @@ def test_jet_sample_djrs_build_a_patched_main164(self): executable = misc.get_pythia8_main164(self.py8, jet_sample_djrs=True) shared = os.path.join(self.examples, 'main164_djr') self.assertEqual(executable, os.path.join(shared, 'main164')) - # the stub make copies the source: it is the patched one + # the stub make copies the source: it is the patched one, which knows + # InternalAnalysis:jetSamples (the stock one does not) self.assertIn('nMEpartons', open(executable).read()) + self.assertTrue(misc.pythia8_main164_knows_jet_samples(executable)) + self.assertFalse(misc.pythia8_main164_knows_jet_samples( + misc.get_pythia8_main164(self.py8))) # Pythia8's own main164.cc is never modified self.assertEqual(open(os.path.join(self.examples, 'main164.cc')).read(), self.main164_djr_anchors) @@ -473,10 +478,26 @@ def test_jet_sample_djrs_build_a_patched_main164(self): self.assertEqual(misc.get_pythia8_main164(self.py8), os.path.join(self.examples, 'main164')) + def test_main164_djr_patch_is_the_proposed_pythia8_change(self): + """Against the real main164.cc of a Pythia8 release, when one is + installed: the patch applies, and writes the fixed""" + import glob + sources = glob.glob(os.path.join(os.environ.get('HOME', ''), 'Documents', + 'HEPTools', 'pythia8', 'share', 'Pythia8', 'examples', + 'main164.cc')) + if not sources: + self.skipTest('no Pythia8 installation to check against') + source = open(sources[0]).read() + if misc.pythia8_main164_writes_jet_sample_djrs(source): + self.skipTest('this Pythia8 already has InternalAnalysis:jetSamples') + patched = misc.patch_pythia8_main164_djrs(source) + self.assertIsNotNone(patched) + self.assertIn('output << "\\n";', patched) + def test_jet_sample_djrs_not_patched_when_not_needed_or_possible(self): # a main164 that splits per jet sample by itself self.write_sources(source=self.main164_djr_anchors + - '// madgraphHook->nMEpartons()\n') + ' pythia.settings.addFlag("InternalAnalysis:jetSamples", false);\n') self.assertEqual(misc.get_pythia8_main164(self.py8, jet_sample_djrs=True), os.path.join(self.examples, 'main164')) # a main164.cc the patch does not know: the stock one From 2b29edb200582a4fd561fb1f6bb8dfa77a88b5eb Mon Sep 17 00:00:00 2001 From: Olivier Mattelaer Date: Thu, 29 Oct 2026 09:54:30 +0100 Subject: [PATCH 81/81] TEST_pythia8_main164: drop the test that needs an installed Pythia8 test_main164_djr_patch_is_the_proposed_pythia8_change checked the patch against the main164.cc of a Pythia8 under ~/Documents/HEPTools, and was skipped where there is none, as on CI. tests/test_manager.py exits 1 on any skipped test, so unittest_bundle_7 failed although all its tests passed. The patch content stays covered by test_main164_djr_patch; the real Pythia8 8.316-8.318 sources were checked by hand. Co-Authored-By: Claude --- tests/unit_tests/various/test_misc.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tests/unit_tests/various/test_misc.py b/tests/unit_tests/various/test_misc.py index 85a34e6479..ccbc0cc2c4 100755 --- a/tests/unit_tests/various/test_misc.py +++ b/tests/unit_tests/various/test_misc.py @@ -478,22 +478,6 @@ def test_jet_sample_djrs_build_a_patched_main164(self): self.assertEqual(misc.get_pythia8_main164(self.py8), os.path.join(self.examples, 'main164')) - def test_main164_djr_patch_is_the_proposed_pythia8_change(self): - """Against the real main164.cc of a Pythia8 release, when one is - installed: the patch applies, and writes the fixed""" - import glob - sources = glob.glob(os.path.join(os.environ.get('HOME', ''), 'Documents', - 'HEPTools', 'pythia8', 'share', 'Pythia8', 'examples', - 'main164.cc')) - if not sources: - self.skipTest('no Pythia8 installation to check against') - source = open(sources[0]).read() - if misc.pythia8_main164_writes_jet_sample_djrs(source): - self.skipTest('this Pythia8 already has InternalAnalysis:jetSamples') - patched = misc.patch_pythia8_main164_djrs(source) - self.assertIsNotNone(patched) - self.assertIn('output << "\\n";', patched) - def test_jet_sample_djrs_not_patched_when_not_needed_or_possible(self): # a main164 that splits per jet sample by itself self.write_sources(source=self.main164_djr_anchors +