Follow-up to #110, deliberately left out of that PR because it is a behaviour change rather than a rename. #110 can merge as it is.
The gap
lepton-lepton-sum-mass and lepton-pair_mass are the same cut: both are the invariant mass of every lepton pair. As event filters they behave identically. Only the second one bounds the integration.
Measuring the floor Cuts::m_inv_min() reports for a min=500 cut on the top pair of g g > t t~ g g:
| cut |
floor |
top-pair_mass |
500.0 |
top-sum-mass |
500.0 |
top-top-sum-mass |
0.0 |
So a user who writes the cut the pre-existing way gets the forbidden region generated and thrown away, while the same cut spelled pair_mass never generates it. Nothing warns them; the cross section is right either way, only the efficiency differs.
Why
Cuts::m_inv_min() in madspace/src/phasespace/cuts.cpp:129 matches the summed spelling with
if (o.sum_momenta() && idx.size() == 1 && idx.at(0).size() == 2) {
pairs.emplace_back(idx.at(0).at(0), idx.at(0).at(1));
}
Observable::build_indices lays out _indices as one list per selection group. A single group (top-sum-mass) gives idx.size() == 1 with both members inside idx[0], which is the shape above. Two groups (top-top-sum-mass) give idx.size() == 2, one member each, so it falls through and contributes no floor.
That second shape is exactly what the obs_pair_mass branch just below (cuts.cpp:137) already handles - it is keyed on the observable, so it never sees an obs_mass observable.
Reproducing
import madspace as ms
pids = [21, 21, 6, -6, 21, 21]
O = ms.Observable
def floor(o):
return ms.Cuts([ms.CutItem(o, min=500.0)]).m_inv_min()[0][1]
print(floor(O(pids, 'pair_mass', [[6, -6]]))) # 500.0
print(floor(O(pids, 'mass', [[6, -6]], sum_momenta=True))) # 500.0
print(floor(O(pids, 'mass', [[6, -6], [6, -6]], sum_momenta=True))) # 0.0
Fix
Extend the summed lambda to also accept idx.size() == 2 with sum_momenta(), pairing idx[0][k] with idx[1][k] the way the obs_pair_mass branch does. Worth checking that the > 2 group case (lepton-jet-top-sum-mass) stays excluded, since a triple's mass is not a floor on any pair.
The sqrt(s_hat) bound in phasespace.cpp reads the same table, so it follows for free.
🤖 Generated with Claude Code
Follow-up to #110, deliberately left out of that PR because it is a behaviour change rather than a rename. #110 can merge as it is.
The gap
lepton-lepton-sum-massandlepton-pair_massare the same cut: both are the invariant mass of every lepton pair. As event filters they behave identically. Only the second one bounds the integration.Measuring the floor
Cuts::m_inv_min()reports for amin=500cut on the top pair ofg g > t t~ g g:top-pair_masstop-sum-masstop-top-sum-massSo a user who writes the cut the pre-existing way gets the forbidden region generated and thrown away, while the same cut spelled
pair_massnever generates it. Nothing warns them; the cross section is right either way, only the efficiency differs.Why
Cuts::m_inv_min()inmadspace/src/phasespace/cuts.cpp:129matches the summed spelling withObservable::build_indiceslays out_indicesas one list per selection group. A single group (top-sum-mass) givesidx.size() == 1with both members insideidx[0], which is the shape above. Two groups (top-top-sum-mass) giveidx.size() == 2, one member each, so it falls through and contributes no floor.That second shape is exactly what the
obs_pair_massbranch just below (cuts.cpp:137) already handles - it is keyed on the observable, so it never sees anobs_massobservable.Reproducing
Fix
Extend the
summedlambda to also acceptidx.size() == 2withsum_momenta(), pairingidx[0][k]withidx[1][k]the way theobs_pair_massbranch does. Worth checking that the> 2group case (lepton-jet-top-sum-mass) stays excluded, since a triple's mass is not a floor on any pair.The
sqrt(s_hat)bound inphasespace.cppreads the same table, so it follows for free.🤖 Generated with Claude Code