128 {
131
132
136 <<
"' = " << segments->
size());
137
138 if (truthDiag) {
139 std::size_t truthSegs = 0, bkgSegs = 0;
142 }
145 }
146
147 SegmentEdgeGraph graph{};
148 std::vector<SegmentEdgeScore>
scores{};
151 << ": built graph with nodes=" << graph.nNodes
152 << ", edges=" << graph.nEdges);
153
154
155
156 std::vector<int32_t> nodeTruthId;
157
158 std::unordered_map<int32_t, double> truthIdToAbsEta;
159 if (truthDiag) {
160 nodeTruthId.assign(graph.nNodes, -1);
161 std::size_t truthSegs = 0, bkgSegs = 0;
162 for (std::size_t node = 0; node < graph.nNodes; ++node) {
165 if (truthPart) {
166 nodeTruthId[node] = static_cast<int32_t>(truthPart->index());
167 truthIdToAbsEta.emplace(nodeTruthId[node], std::abs(truthPart->
eta()));
168 ++truthSegs;
169 } else {
170 ++bkgSegs;
171 }
172 }
175 }
176
179 float minProb = std::numeric_limits<float>::max();
180 float maxProb = std::numeric_limits<float>::lowest();
181 for (
const SegmentEdgeScore& score :
scores) {
182 minProb = std::min(minProb,
score.probability);
183 maxProb = std::max(maxProb,
score.probability);
184 }
186 <<
": edge scores=" <<
scores.size()
187 << ", prob range=[" << minProb << ", " << maxProb << "]");
188 } else {
190 << ": no edge scores produced");
191 }
192
193
194
195 std::unordered_map<std::uint64_t, float> pairProbability;
196 pairProbability.reserve(
scores.size());
197 for (
const SegmentEdgeScore& score :
scores) {
198 if (
score.src >= graph.nNodes ||
score.dst >= graph.nNodes ||
200 continue;
201 }
202 const std::uint64_t
key = undirectedPairKey(
score.src,
score.dst);
203 auto [
it, inserted] = pairProbability.emplace(key,
score.probability);
204 if (!inserted)
it->second = std::max(
it->second,
score.probability);
205 }
206
207
208
209 if (truthDiag) {
210 std::size_t trueTotal = 0, truePassed = 0, bkgTotal = 0, bkgPassed = 0;
211 for (const auto& [key, probability] : pairProbability) {
213 if (first >= graph.nNodes || second >= graph.nNodes) continue;
214 const bool isTrueEdge =
217 if (isTrueEdge) {
218 ++trueTotal;
220 } else {
221 ++bkgTotal;
223 }
224 }
229 }
230
231
232
233 using WeightedEdge = std::pair<std::uint64_t, float>;
234 const auto betterWeightedEdge = [](
const WeightedEdge&
first,
235 const WeightedEdge&
second) {
238 if (probabilityOrder != 0) {
239 return probabilityOrder < 0;
240 }
242 };
243 std::vector<std::vector<WeightedEdge>> edgesByNode(graph.nNodes);
244 std::size_t thresholdPairs = 0;
245 for (const auto& [key, probability] : pairProbability) {
248 if (first >= graph.nNodes || second >= graph.nNodes) continue;
249 edgesByNode[
first].emplace_back(key, probability);
250 edgesByNode[
second].emplace_back(key, probability);
251 ++thresholdPairs;
252 }
253
254 std::size_t thresholdedNodes = 0;
255 for (const std::vector<WeightedEdge>& nodeEdges : edgesByNode) {
256 thresholdedNodes += !nodeEdges.empty();
257 }
258
259 std::vector<Muon::MuonStationIndex::LayerIndex> nodeLayer(graph.nNodes);
260 for (std::size_t node = 0; node < graph.nNodes; ++node) {
262 }
264 std::unordered_map<std::uint64_t, unsigned char> nominations;
265 nominations.reserve(thresholdPairs);
266 for (std::size_t node = 0; node < graph.nNodes; ++node) {
267 std::vector<WeightedEdge>& nodeEdges = edgesByNode[node];
268 std::sort(nodeEdges.begin(), nodeEdges.end(), betterWeightedEdge);
269 if (maxEdgesPerLayer != 0) {
270
271
273 std::erase_if(nodeEdges, [&](const WeightedEdge& edge) {
274 const auto [
first,
second] = unpackPairKey(edge.first);
276 const auto layer =
static_cast<std::size_t
>(Acts::toUnderlying(nodeLayer[other]) + 1);
277 return taken[
layer]++ >= maxEdgesPerLayer;
278 });
282 }
283 for (const WeightedEdge& edge : nodeEdges) {
284 ++nominations[edge.first];
285 }
286 }
287
288 std::size_t mutualTopKPairs = 0;
289 std::size_t oneSidedTopKPairs = 0;
290 for (
const auto& [_,
count] : nominations) {
292 ++mutualTopKPairs;
293 } else {
294 ++oneSidedTopKPairs;
295 }
296 }
297
298 std::unordered_set<std::uint64_t> selectedPairKeys;
299 selectedPairKeys.reserve(thresholdPairs);
301
302 std::vector<WeightedEdge> acceptedPairs;
303 acceptedPairs.reserve(thresholdPairs);
304 for (const auto& [key, probability] : pairProbability) {
306 acceptedPairs.emplace_back(key, probability);
307 }
308 std::sort(acceptedPairs.begin(), acceptedPairs.end(), betterWeightedEdge);
309
311 std::vector<unsigned int>
degree(graph.nNodes, 0);
312 for (const WeightedEdge& edge : acceptedPairs) {
313 const auto [
first,
second] = unpackPairKey(edge.first);
314 if (maxDegree != 0 &&
315 (degree[first] >= maxDegree || degree[second] >= maxDegree)) {
316 continue;
317 }
318 selectedPairKeys.insert(edge.first);
321 }
322 } else {
323 for (
const auto& [key,
count] : nominations) {
325 selectedPairKeys.insert(key);
326 }
327 }
328
329
330
331 std::size_t orphanRecoveryPairs = 0;
335 std::vector<unsigned char> selectedNode(graph.nNodes, 0);
336 for (const std::uint64_t key : selectedPairKeys) {
338 if (first < graph.nNodes) selectedNode[
first] = 1;
339 if (second < graph.nNodes) selectedNode[
second] = 1;
340 }
341 for (std::size_t node = 0; node < graph.nNodes; ++node) {
342 if (selectedNode[node] || edgesByNode[node].
empty())
continue;
343 const std::uint64_t
key = edgesByNode[node].front().first;
345 if (first >= graph.nNodes || second >= graph.nNodes) continue;
346 if (selectedPairKeys.insert(key).second) ++orphanRecoveryPairs;
347 selectedNode[
first] = 1;
349 }
350 }
351
352 std::vector<std::uint64_t> selectedPairs{selectedPairKeys.begin(),
353 selectedPairKeys.end()};
354 std::sort(selectedPairs.begin(), selectedPairs.end());
355
356
357
358 if (truthDiag) {
359 for (const auto& [key, probability] : pairProbability) {
362 if (first >= graph.nNodes || second >= graph.nNodes) continue;
363 if (nodeTruthId[first] < 0 || nodeTruthId[first] != nodeTruthId[second]) continue;
364 if (nodeLayer[first] == nodeLayer[second]) continue;
365 const std::size_t region =
367 ++m_sumTrueCrossLayerEdges[region];
368 if (selectedPairKeys.contains(key)) ++m_sumTrueCrossLayerSelected[region];
369 }
370 }
371
372
373
374 std::vector<float> nodeBestSelectedScore;
375 if (truthDiag) {
376 nodeBestSelectedScore.assign(graph.nNodes, -1.f);
377 for (const std::uint64_t key : selectedPairs) {
378 const auto found = pairProbability.find(key);
379 if (found == pairProbability.end()) continue;
381 nodeBestSelectedScore[
first] = std::max(nodeBestSelectedScore[first],
found->second);
382 nodeBestSelectedScore[
second] = std::max(nodeBestSelectedScore[second],
found->second);
383 }
384 }
385
386
387
388
389
390 const auto layerOf = [&graph](std::size_t node) {
392 };
393 std::vector<float> nodeBestCrossLayerScore;
394 std::vector<unsigned int> nodeCrossLayerEdges;
395 {
396 nodeBestCrossLayerScore.assign(graph.nNodes, -1.f);
397 if (truthDiag) nodeCrossLayerEdges.assign(graph.nNodes, 0);
398 for (const std::uint64_t key : selectedPairs) {
400 if (layerOf(first) == layerOf(second)) continue;
401 const auto found = pairProbability.find(key);
402 if (found == pairProbability.end()) continue;
403 nodeBestCrossLayerScore[
first] = std::max(nodeBestCrossLayerScore[first],
found->second);
404 nodeBestCrossLayerScore[
second] = std::max(nodeBestCrossLayerScore[second],
found->second);
405 if (truthDiag) {
406 ++nodeCrossLayerEdges[
first];
407 ++nodeCrossLayerEdges[
second];
408 }
409 }
410 }
411
412
413 DisjointSet components{graph.nNodes};
414 std::vector<bool> activeNode(graph.nNodes, false);
415 for (const std::uint64_t key : selectedPairs) {
417 components.unite(first, second);
418 activeNode[
first] =
true;
419 activeNode[
second] =
true;
420 }
421
422 std::unordered_map<std::size_t, std::vector<std::size_t>> byRoot;
423 byRoot.reserve(graph.nNodes);
424 for (std::size_t node = 0; node < graph.nNodes; ++node) {
425 if (activeNode[node]) byRoot[components.find(node)].push_back(node);
426 }
427
428
429 std::vector<std::vector<std::size_t>> componentNodes;
430 componentNodes.reserve(byRoot.size());
431 for (auto& [_, nodes] : byRoot) {
433 componentNodes.push_back(std::move(nodes));
434 }
435 std::ranges::sort(componentNodes,
436 [](const auto& first, const auto& second) {
438 });
439
440 xAOD::ContainerDecorator<xAOD::MuonSegmentContainer, std::vector<unsigned>>
442 xAOD::ContainerDecorator<xAOD::MuonSegmentContainer, std::vector<float>>
444
445 std::vector<std::uint8_t> keptNode(graph.nNodes, false);
446 std::vector<std::uint8_t> nodeDropReason;
447 if (truthDiag) {
448 nodeDropReason.assign(graph.nNodes, kReasonNotSelected);
449 }
450
451
452
453 std::unordered_map<int32_t, std::unordered_set<unsigned>> muonComponents;
454 std::unordered_map<int32_t, bool> muonEverMixed;
455
456 std::vector<int32_t> weakCleanMuons;
457 std::unordered_map<int32_t, unsigned int> strongComponentsPerMuon;
458 const auto sizeBin = [](std::size_t
size) -> std::size_t {
460 if (
size < 10)
return 3;
461 if (
size < 20)
return 4;
462 return 5;
463 };
464
465 std::size_t topologyNodes = 0;
466 std::size_t retainedNodes = 0;
467 std::size_t chamberSuppressedNodes = 0;
468 std::size_t rejectedComponents = 0;
469 std::size_t componentsKept = 0;
470 std::size_t anchors = 0;
471 std::size_t nodesRejectedByMinComponent = 0;
472 unsigned nextComponentId = 1;
474
475 const auto isBetterNode = [&](std::size_t candidate, std::size_t incumbent) {
476 if (betterSegment(graph.segments[candidate], graph.segments[incumbent])) {
477 return true;
478 }
479 if (betterSegment(graph.segments[incumbent], graph.segments[candidate])) {
480 return false;
481 }
482 return candidate < incumbent;
483 };
484
485 for (const std::vector<std::size_t>& rawNodes : componentNodes) {
486 topologyNodes += rawNodes.size();
487 std::vector<std::size_t> retained = rawNodes;
488
489
490
492 std::unordered_map<int, std::size_t> bestByChamber;
493 bestByChamber.reserve(rawNodes.size());
494 for (const std::size_t node : rawNodes) {
496 static_cast<int>(graph.segments[node]->chamberIndex());
497 const auto found = bestByChamber.find(chamber);
498 if (found == bestByChamber.end() || isBetterNode(node,
found->second)) {
500 }
501 }
502 retained.clear();
503 retained.reserve(bestByChamber.size());
504 for (const auto& [_, node] : bestByChamber) retained.push_back(node);
505 std::sort(retained.begin(), retained.end());
506 chamberSuppressedNodes += rawNodes.size() - retained.size();
507 }
508
509
510 if (truthDiag) {
511 for (const std::size_t node : rawNodes) {
512 nodeDropReason[node] = kReasonChamberDedup;
513 }
514 for (const std::size_t node : retained) {
515 nodeDropReason[node] = kReasonComponentRejected;
516 }
517 }
518
520 nodesRejectedByMinComponent += retained.size();
521 ++rejectedComponents;
522 continue;
523 }
524
525
526
527 std::vector<std::size_t> rankedNodes{retained};
529
530 int bestRank = std::numeric_limits<int>::max();
531 for (const std::size_t node : rankedNodes) {
532 bestRank = std::min(
533 bestRank,
535 graph.segments[node]->chamberIndex())));
536 }
537 std::erase_if(rankedNodes, [&](std::size_t node) {
539 graph.segments[node]->chamberIndex())) != bestRank;
540 });
541
542
544 std::ranges::sort(rankedNodes, [&](std::size_t first, std::size_t second) {
546 nodeBestCrossLayerScore[first], nodeBestCrossLayerScore[second]);
547 return order != 0 ?
order < 0 : isBetterNode(first, second);
548 });
551 }
552 }
553 } else {
554 std::ranges::sort(rankedNodes, isBetterNode);
556 ? rankedNodes.size()
558 rankedNodes.size());
559 rankedNodes.resize(nAnchors);
560 }
561 if (rankedNodes.empty()) {
562 ++rejectedComponents;
563 continue;
564 }
565
566
567
568 const unsigned componentId = nextComponentId++;
569 float componentCrossScore = -1.f;
570 for (const std::size_t node : rawNodes) {
571 componentCrossScore = std::max(componentCrossScore, nodeBestCrossLayerScore[node]);
572 }
573 for (const std::size_t node : retained) {
574 scoreDecor(*graph.segments[node]) = {componentCrossScore, nodeBestCrossLayerScore[node]};
575 const auto anchorIt = std::ranges::find(rankedNodes, node);
576 const bool isAnchor = anchorIt != rankedNodes.end();
577 decor(*graph.segments[node]) = {
578 componentId,
579 isAnchor ?
static_cast<unsigned int>(anchorIt - rankedNodes.begin()) + 1 : 0
u};
580 keptNode[node] = true;
581 }
582 retainedNodes += retained.size();
583 anchors += rankedNodes.size();
584 ++componentsKept;
585
586
587 if (truthDiag) {
588 std::unordered_set<int32_t> componentTruthIds;
589 for (const std::size_t node : retained) {
590 if (nodeTruthId[node] >= 0) componentTruthIds.insert(nodeTruthId[node]);
591 }
592 const std::size_t outcome = componentTruthIds.empty() ? 2
593 : componentTruthIds.size() == 1 ? 0 : 1;
594 ++m_groupPurity.componentsByOutcome[outcome];
595 ++m_groupPurity.componentSizeHist[sizeBin(retained.size())];
596
597
598
599
600 float strongest = -1.f;
601 for (const std::size_t node : rawNodes) {
602 strongest = std::max(strongest, nodeBestSelectedScore[node]);
603 }
604 const std::size_t scoreBin = strongest < 0.05f ? 0 : strongest < 0.1f ? 1
605 : strongest < 0.3f ? 2 : strongest < 0.5f ? 3 : 4;
606 std::uint32_t layerMask = 0;
607 for (const std::size_t node : retained) {
608
610 graph.segments[node]->chamberIndex())) + 1);
611 }
612 const std::size_t layerBin =
617
618
619 if (std::popcount(layerMask) >= 2) {
620 float crossScore = -1.f;
621 unsigned int crossEdges = 0;
622 for (const std::size_t node : rawNodes) {
623 crossScore = std::max(crossScore, nodeBestCrossLayerScore[node]);
624 crossEdges += nodeCrossLayerEdges[node];
625 }
626 crossEdges /= 2;
627 const std::size_t crossBin = crossScore < 0.05f ? 0 : crossScore < 0.1f ? 1 : crossScore < 0.3f ? 2
628 : crossScore < 0.5f ? 3 : crossScore < 0.8f ? 4 : 5;
631 std::min<std::size_t>(crossEdges, 3)];
632 if (crossScore < 0.05f) {
633 if (outcome == 0) weakCleanMuons.push_back(*componentTruthIds.begin());
634 } else {
635 for (
const int32_t
id : componentTruthIds) ++strongComponentsPerMuon[
id];
636 }
637 }
638 const bool mixed = componentTruthIds.size() >= 2;
639 if (mixed) {
640 m_groupPurity.mixedComponentSegments += retained.size();
641 const std::uint64_t
packed =
642 (static_cast<std::uint64_t>(componentTruthIds.size()) << 32) | retained.size();
643 std::uint64_t
current = m_groupPurity.worstMixedPacked.load(std::memory_order_relaxed);
644 while (packed > current && !m_groupPurity.worstMixedPacked.compare_exchange_weak(
645 current, packed, std::memory_order_relaxed)) {}
646 }
647 for (const int32_t id : componentTruthIds) {
648 muonComponents[
id].insert(componentId);
649 if (mixed) muonEverMixed[
id] =
true;
650 }
651 }
652 }
653
654 if (truthDiag) {
655 std::size_t truthSegs = 0, bkgSegs = 0;
656 for (std::size_t node = 0; node < graph.nNodes; ++node) {
657 if (!keptNode[node]) continue;
658 nodeTruthId[node] >= 0 ? ++truthSegs : ++bkgSegs;
659 }
662
663 std::vector<unsigned char> thresholded(graph.nNodes, 0);
664 for (std::size_t node = 0; node < graph.nNodes; ++node) {
665 thresholded[node] = !edgesByNode[node].empty();
666 }
668 keptNode, nodeDropReason);
670 for (const int32_t id : weakCleanMuons) {
671 ++(strongComponentsPerMuon[
id] > 0 ? m_groupPurity.weakCleanWithOther : m_groupPurity.weakCleanAlone);
672 }
673 }
674
676 auto connectedSegments =
677 std::make_unique<ConstDataVector<xAOD::MuonSegmentContainer>>(
679 connectedSegments->reserve(graph.nNodes);
680 for (std::size_t node = 0; node < graph.nNodes; ++node) {
681 if (!keptNode[node] || !graph.segments[node]) continue;
682 connectedSegments->push_back(graph.segments[node]);
683 }
684
685 const std::size_t nConnectedSegments = connectedSegments->size();
686 SG::WriteHandle<ConstDataVector<xAOD::MuonSegmentContainer>> connectedHandle{
690 << ": wrote " << nConnectedSegments
691 << " ML-connected segment(s) to '"
693 }
694
696 << ": ML components graphNodes=" << graph.nNodes
697 << ", thresholdedNodes=" << thresholdedNodes
698 << ", thresholdPairs=" << thresholdPairs
699 << ", mutualTopKPairs=" << mutualTopKPairs
700 << ", oneSidedTopKPairs=" << oneSidedTopKPairs
701 << ", selectedPairs=" << selectedPairKeys.size()
702 << ", components=" << componentsKept
703 << ", topologyNodes=" << topologyNodes
704 << ", retainedNodes=" << retainedNodes
705 << ", chamberSuppressedNodes=" << chamberSuppressedNodes
706 << ", nodesRejectedByMinComponent=" << nodesRejectedByMinComponent
708 << ", seedAnchors=" << anchors
709 << ", rejectedComponents=" << rejectedComponents
712 << ", orphanRecoveryPairs=" << orphanRecoveryPairs
715
716
729 }
730 return StatusCode::SUCCESS;
731}
#define ATH_CHECK
Evaluate an expression and check for errors.
#define ATH_MSG_DEBUG(x,...)
std::vector< std::vector< float > > scores
size_t size() const
Number of registered mappings.
static const Attributes_t empty
bool msgLvl(const MSG::Level lvl) const
std::atomic< std::size_t > m_sumOnnxBkgSegments
std::atomic< std::size_t > m_sumChamberSuppressedNodes
Gaudi::Property< bool > m_keepBestSegmentPerChamber
void classifyGroupPurity(const std::unordered_map< std::int32_t, std::unordered_set< unsigned > > &muonComponents, const std::unordered_map< std::int32_t, bool > &muonEverMixed, const std::unordered_map< std::int32_t, double > &truthIdToAbsEta) const
MC-only: given the per-muon component memberships and mixed-status gathered while the per-component c...
std::atomic< std::size_t > m_sumRetainedNodes
Gaudi::Property< bool > m_anchorInnermostLayer
SG::WriteDecorHandleKey< xAOD::MuonSegmentContainer > m_pairGateDecorKey
Per-segment payload consumed by MlMsTrackSeeder: [componentId, anchorRank + 1] Empty means that the s...
Gaudi::Property< bool > m_useDegreeCappedComponents
std::atomic< std::size_t > m_sumOrphanRecoveryPairs
ToolHandle< ISegmentEdgeClassifierTool > m_edgeClassifier
Gaudi::Property< unsigned int > m_maxEdgesPerNode
std::atomic< std::size_t > m_sumThresholdedNodes
std::atomic< std::size_t > m_sumRetainedBkgSegments
Gaudi::Property< bool > m_requireMutualTopKEdges
std::atomic< std::size_t > m_sumOneSidedTopKPairs
std::atomic< std::size_t > m_sumInputBkgSegments
void classifyLostTruthSegments(const xAOD::MuonSegmentContainer &segments, const SegmentEdgeGraph &graph, const std::unordered_map< std::uint64_t, float > &pairProbability, const std::vector< unsigned char > &thresholded, const std::vector< std::uint8_t > &keptNode, const std::vector< unsigned char > &nodeDropReason) const
MC-only: classify every truth-labeled input segment that did not make it into the filtered container ...
std::atomic< std::size_t > m_sumTrueEdgesPassed
bool m_truthDiagEnabled
Whether truth-particle matching is available/enabled this job.
std::atomic< std::size_t > m_sumBkgEdgesPassed
std::atomic< std::size_t > m_sumBkgEdgesTotal
std::atomic< std::size_t > m_sumGraphNodes
Job-summed post-ONNX selection counters (see execute()).
Gaudi::Property< unsigned int > m_minSegmentsPerComponent
std::atomic< std::size_t > m_sumOnnxTruthSegments
std::atomic< std::size_t > m_sumInputTruthSegments
std::atomic< std::size_t > m_sumNodesRejectedByMinComponent
std::atomic< std::size_t > m_sumTrueEdgesTotal
std::atomic< std::size_t > m_sumRetainedTruthSegments
SG::WriteHandleKey< ConstDataVector< xAOD::MuonSegmentContainer > > m_filteredSegmentKey
Gaudi::Property< bool > m_recoverOrphanNodes
std::atomic< std::size_t > m_sumMutualTopKPairs
Gaudi::Property< unsigned int > m_maxEdgesPerNodePerLayer
SG::WriteDecorHandleKey< xAOD::MuonSegmentContainer > m_componentScoreDecorKey
Per-segment ML scores for MlMsTrackSeeder: [strongest selected cross-layer edge of the segment's comp...
std::atomic< std::size_t > m_sumRejectedComponents
SG::ReadHandleKey< xAOD::MuonSegmentContainer > m_segmentKey
Gaudi::Property< unsigned int > m_seedAnchorsPerComponent
Gaudi::Property< float > m_pairGateThreshold
std::atomic< std::size_t > m_sumTopologyNodes
std::atomic< std::size_t > m_sumComponentsKept
StatusCode record(std::unique_ptr< T > data)
Record a const object to the store.
virtual double eta() const override final
The pseudorapidity ( ) of the particle.
int count(std::string s, const std::string ®x)
count how many occurances of a regx are in a string
H5::CompType packed(H5::CompType in)
MuonR4::SegmentQualitySorter SegmentQualityOrder
Common quality ordering for segment representatives, shared with MlMsTrackSeeder so that graph constr...
int compareFloatDescending(float first, float second)
Three-way descending comparison which also orders NaN last.
bool isBarrel(const ChIndex index)
Returns true if the chamber index points to a barrel chamber.
@ VIEW_ELEMENTS
this data object is a view, it does not own its elmts
const T * get(const ReadCondHandleKey< T > &key, const EventContext &ctx)
Convenience function to retrieve an object given a ReadCondHandleKey.
@ u
Enums for curvilinear frames.
void sort(typename DataModel_detail::iterator< DVL > beg, typename DataModel_detail::iterator< DVL > end)
Specialization of sort for DataVector/List.
MuonSegmentContainer_v1 MuonSegmentContainer
Definition of the current "MuonSegment container version".
static constexpr std::size_t kCrossEdgeBins
static constexpr std::size_t kSizeBins
static constexpr std::size_t kScoreBins
static constexpr std::size_t kLayerBins
static constexpr std::size_t kCrossScoreBins
Components spanning >= 2 layers (the only ones that can seed), per outcome: